AirDropChannels - Payment Channels + AirDrops for cheap claimable token redemption

AirDropChannels - Payment Channels + AirDrops for cheap claimable token redemption


Recently while working on an airdrop contract I ran into the issue of extremely high gas costs to disperse tokens to a large audience which lead me to investigate combining airdrops, with payment channels. Payment Channels are extremely efficient at conducting many cheap transactions between any two parties, but that doesn't satisfy the airdrop requirement. Typical airdrop contracts allow you to send tokens to a large amount of people, often times by on-chain or off-chain iteration over a list of addresses which lets you make many transactions to many parties, but it is not cheap. This again does not exactly satisfy our use case.

So I took the payment channel concept, and made it one -> many in that anyone presenting the appropriate proof will be able to receive tokens from that channel. One issue that might arise is how do we authorize an undetermined number of people to withdraw from a specific channel, and only allow them to withdraw from that channel, and not allow other people to withdraw their tokens.

To get around this when each channel is opened, it is assigned a unique channel identifier. The proof that is submitted is comprised of three elements:

  1. Channel ID
  2. ID (this can be a unique identifier for each user, or a common value per channel, it is up to you)
  3. The address of the person the tokens are meant for

The key value that lets us get around not having to pre-authorize every single redeemer while simultaneously not allowing people to redeem tokens that aren't mean for them is that their ethereum address is part of the proof. This is pulled in when the function is called by retrieving the address utilizing the msg.sender variable. As soon as the proof is accepted, that particular address can't receive tokens. We also don't have to invalidate the proof saving some gas, since the function will throw if they try to call again after redeeming their tokens.

Lets dig down into the code and see what's happening:

Channel Opening


    function openChannel(
        address _tokenAddress,
        uint256 _channelValue,
        uint256 _durationInDays,
        uint256 _dropAmount)
        public
        payable
        returns (bool)
    {
        uint256 currentDate = now;
        // channel hash = keccak256(purchaser, vendor, channel value, date of open)
        bytes32 channelId = keccak256(msg.sender, _tokenAddress, currentDate);
        // make sure the channel id doens't already exist
        require(!channelIds[channelId]);
        channels[channelId].source = msg.sender;
        channels[channelId].tokenAddress = _tokenAddress;
        channels[channelId].value = _channelValue;
        channels[channelId].closingDate = (now + (_durationInDays * 1 days));
        channels[channelId].openDate = currentDate;
        channels[channelId].dropAmount = _dropAmount;
        channels[channelId].channelId = channelId;
        channels[channelId].state = ChannelStates.opened;
        channels[channelId].intf = ERC20Interface(_tokenAddress);
        channelIds[channelId] = true;
        ChannelOpened(channelId);
        require(channels[channelId].intf.transferFrom(msg.sender, address(this), _channelValue));
        return true;
}


Before opening the channel, you must approve the AirDropChannels contract to be able to transfer the appropriate number of tokens on your behalf utilizing the transferFrom function.

When opening the channel you must provide a few values lets break them down:

  1. _tokenAddress - This is the address of the token you are allowing people to receive tokens from
  2. _channelValue - This is the total value of the channel
  3. _durationInDays - The number of days you want the channel to be open for
  4. _dropAmount - The amount of tokens that is given out to each redeemer

Enabling The AirDropChannel



    // airdrop enable proof = keccak256(prefix, keccak256(channelId, airdropID))
    // used to enable the air drop channel
    function enableAirdrops(
        bytes32 _h,
        uint8   _v,
        bytes32 _r,
        bytes32 _s,
        bytes32 _channelId,
        uint256 _id)
        public
        returns (bool)
    {
        require(channelIds[_channelId]);
        require(channels[_channelId].state == ChannelStates.opened);
        require(msg.sender == channels[_channelId].source);
        // we need to recreate the signed message hash, so first lets compute the raw hash using the preimages
        bytes32 _proof = keccak256(_channelId, _id);
        // now lets add the prefix, to get the signed message hash, or pefixed message hash
        bytes32 proof = keccak256(prefix, _proof);
        // retrieve the signer of the message
        address signer = ecrecover(_h, _v, _r, _s);
        // if someone fails this chances are it was malicious, so lets waste their gas
        assert(signer == channels[_channelId].source);
        // however we need to make sure they also submitted the right message
        // so we compare our dynamically generated proof, with the passed in signed message hash
        assert(proof == _h);
        // mark channel as releasing
        channels[_channelId].state = ChannelStates.releasing;
        if (dev) { SigDebug(_h, _proof, proof, signer); }
        AirDropsEnabled(_channelId);
        SignatureRecovered(signer);
        return true;
}



I decided to have channels be disabled by default, allowing you to create a channel far in advance, perhaps when gas costs are cheap, and then enabling it in the future. When enabling the channel you must provide the enable proof which can be generated using the python script which I will link to at the end of the post. This is composed of the airdrop channel identifier, and an airdrop id. This airdrop ID is never stored on-chain and can be used for your own internal categorization methods.

Redeeming your tokens


    // air drop proof: keccak256(prefix, keccak256(_channelId, _id, msg.sender))
    function retrieveAirdrop(
        bytes32 _h,
        uint8   _v,
        bytes32 _r,
        bytes32 _s,
        bytes32 _channelId,
        uint256 _id)
        public
        returns (bool)
    {
        // verify channel id
        require(channelIds[_channelId]);
        // verify we are in the correct state
        require(channels[_channelId].state == ChannelStates.releasing);
        // verify channel balance
        require(channels[_channelId].value >= channels[_channelId].dropAmount);
        require(!receivedBonus[_channelId][msg.sender]);
        // this ensure only the intended recipient of a signed message can redeem
        bytes32 _proof = keccak256(_channelId, _id, msg.sender);
        bytes32 proof = keccak256(prefix, _proof);
        address signer = ecrecover(_h, _v, _r, _s);
        // validate the signer
        assert(signer == channels[_channelId].source);
        // signer checks out, but does the data?
        /**
            68 74 74 70 73 3a 2f 2f 70 69 63 73 2e 6d
            65 2e 6d 65 2f 69 74 73 2d 61 6e 2d 6f 6c
            64 65 72 2d 6d 65 6d 65 2d 73 69 72 2d 62
            75 74 2d 69 74 2d 63 68 65 63 6b 73 2d 6f
            75 74 2d 31 39 38 39 39 39 37 34 2e 70 6e
            67 
        */
        assert(proof == _h);
        // mark them as having received a bonus so they can't double dip.
        receivedBonus[_channelId][msg.sender] = true;
        // reduce the channel value
        channels[_channelId].value = channels[_channelId].value.sub(channels[_channelId].dropAmount);
        // increase number of air drops
        channels[_channelId].totalDrops = channels[_channelId].totalDrops.add(1);
        // notify blockchain
        AirDropDispersed(_channelId);
        // transfer tokens
        require(channels[_channelId].intf.transfer(msg.sender, channels[_channelId].dropAmount));
        return true;
}



When opening the channel, you must submit the proof that was given to you by the channel opener, as well as all the necessary preimages to reconstruct the signature to verify that the proof you are providing is indeed the right proof and not another one that was also signed by the channel opener. The proofs can be generated by the associated python script. The address you enter into the air drop proof, must be the address that submits the proof.


AirDropChannels Contract: https://github.com/postables/Postables-Payment-Channel/blob/develop/solidity/AirDropChannels.sol
Python Script: https://github.com/postables/Postables-Payment-Channel/blob/develop/python/signer.py

Conclusion

Thanks for reading! Let me know what you're thoughts are about this solution, and if you have any ways that it could be improved! Since I'm fascinated by space, from now on any of my blog posts will include an image that has to do with Space and the universe

H2
H3
H4
3 columns
2 columns
1 column
Join the conversation now