Robodrop p1: Develop an autonomous smart airdrop

Objective

We’re going to be building a user driven autonomous airdrop dapp with Solidity. We will start with simple concept and try to build up its features using other tech along the line.

Airdrop rules

The rules are (which are completly arbitary btw), a user will recieve an a Y amount of TokenX if and only if a user:

  1. Currently owns a VeeFriend
  2. Had an average of 200 Uni in their account between dateX and dateY

Composition

The Dapp will comprise of the 3 parts.

  • The Circuit: This is the magical part of the Dapp that will allow you to trust the airdrop rules have been applied.
  • The Web App: This will be the front end UI that will allow you to login with meta mask and pass your account to the rest of the system.
  • The Solidity contract: This is the part where the airdrop (transfer of tokens) will occur if the user’s account adhered to the rules given.

Implementation

Lets create an end to end implementing the rule#1, then enrich our DApp to also handle the rule#2.

Implementing rule 1: Currently owns a VeeFriend

Rule#1 states that we need to check if claimant of the airdrop currently owns an VeeFriend NFT. Since it is current blockchain data we need then we’ll do this verification on chain by using owerOf function available to us from the VeeFriend NFT contract.

Using Foundry to create our smart contracts lets first create the tests:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/Robodrop.sol";

contract RobodropTest is Test {

    Robodrop public rb;
    uint tokenId = 36718;
    address owner = 0x616D5b49738F6bc5290d5222115526339D9B6bB1;

    function setUp() public {
        vm.createSelectFork("sepolia");
        rb = new Robodrop();
    }

    function test_currentOwner() public {
        vm.prank(owner);
        assertEq(rb.isOwner(tokenId), true);
    }

    function test_notCurrentOwner() public {
        vm.prank(address(0));
        assertEq(rb.isOwner(tokenId), false);
    }
}

The above Test shows the most basic thing my smart contract can check and that is to check if the sender of the isOwner(tokeId) call to our Smart contract (Robodrop.sol) is infact the owner of the NFT then return. Again this is a basic test that’s mainly to verify the ownership of a VeeFriend that i bought from OpenSeas on Sepolia with my address 0x616D5b49738F6bc5290d5222115526339D9B6bB1. The setup of the tests forks Sepolia from the latest block and create our skeleton Robodrop.sol contract. I then make 2 tests for isOwner function to verify its only true when the identity of the caller is my address. One improvement to our test would be to simulate the purchase of the NFT rather than harding coding the NFT owner, the owner can transfer the nft in the future resulting in a failing tests.

Create the Robodrop contract

pragma solidity ^0.8.19;

import { IERC721 } from "@openzeppelin-contracts/token/ERC721/IERC721.sol";

contract Robodrop {

    address public constant VEE_ADD = 0x7A3159290ba6672c3Cc5741F6BcDF5261266CD15;
    IERC721 public nft;

    constructor() 
    {
        nft = IERC721(VEE_ADD);
    }

    function isOwner(uint tokenId) external view returns (bool) {
        return msg.sender == nft.ownerOf(tokenId);
    }
}

In order for our isOwner function to work, it will need to access the ownerOf call in the VeeFriend smart contract. We’ll use Openzeppelin’s interface to call the VeeFriend smart contract at our own constructor.

We’ll kickoff implementing our functionality with simulating airdrops of our UselessToken (which we’ll create for our usecase and we’ll use in our tests) to the claimant’s account should they own a VeeFriend. Lets modify the test file to see how the functionality would take place.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/Robodrop.sol";
import "../src/UselessToken.sol";

contract RobodropTest is Test {

    Robodrop public rb;
    uint tokenId = 36718;
    address owner = 0x616D5b49738F6bc5290d5222115526339D9B6bB1;
    address randomAccount = 0xF55ede940a8038aDAEd34e497fd4ac4Ce3D173c9;
    UselessToken useless;

    function setUp() public {
        vm.createSelectFork("sepolia");
        rb = new Robodrop();
        // pass the rb address to the token constuctor. this will mint coins for rb so that it distributes it for airdrops
        // this isnt ideal as the token needs to know the address of rb at creation time.
        // idealy we need the token to be independent of the airdrop Dapp.
        // any way lets continue with the below then modify later.
        useless = new UselessToken(address(rb));
        rb.updateToken(address(useless));
    }

    function test_currentOwner() public {
        vm.prank(owner);
        assertEq(rb.isOwner(tokenId), true);
    }

    function test_notCurrentOwner() public {
        vm.prank(address(0));
        assertEq(rb.isOwner(tokenId), false);
    }

    function test_airdropClaim() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.prank(owner);
        rb.claimAirDrop(tokenId);
        assertEq(useless.balanceOf(owner), 10);
    }

    function testFail_tryToClaimButDontOwnNFT() public {
        assertEq(useless.balanceOf(randomAccount), 0);
        vm.prank(randomAccount);
        rb.claimAirDrop(tokenId);
    }

    function testFail_ifAlreadyClaimed() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.startPrank(owner);
        rb.claimAirDrop(tokenId);
        rb.claimAirDrop(tokenId);
        vm.stopPrank();
    }
}

In the set up of the test we create Robodrop contract and we create our UselessToken, we pass the address of the Robodrop contract so that upon initialisation the token will mint a bunch of UselessTokens and give it to the robodrop contract. The contract will use these funds to transfer from its the contract to the users. The Robodrop contract will then be aware of the UselessToken address to allow the tranfer of funds to take place.

Our UselessToken is a basic ERC20 standard

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { ERC20 } from "@openzeppelin-contracts/token/ERC20/ERC20.sol";

contract UselessToken is ERC20 {
    // UselessToken is deployed as a test token and holds no monetary value.
    constructor(address airdropContract) ERC20("UselessToken", "UT") {
        _mint(airdropContract, 10 ** 8 * 10 ** 18);
    }
}

Robodrop contract modifications

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { IERC721 } from "@openzeppelin-contracts/token/ERC721/IERC721.sol";
import { IERC20 } from "@openzeppelin-contracts/token/ERC20/IERC20.sol";

contract Robodrop {

    address public constant VEE_ADD = 0x7A3159290ba6672c3Cc5741F6BcDF5261266CD15;
    IERC721 public nft;

    IERC20 public useless;

    mapping(address => bool) public hasClaimed;

    constructor() 
    {
        nft = IERC721(VEE_ADD);
    }

    function updateToken(address tokenAdd) public {
        useless = IERC20(tokenAdd);
    }

    function isOwner(uint tokenId) external view returns (bool) {
        return msg.sender == nft.ownerOf(tokenId);
    }

    function claimAirDrop(uint256 tokenId) external {
        require(!hasClaimed[msg.sender], "Autonomous Airdrop already claimed");
        require(msg.sender == nft.ownerOf(tokenId), "Account doesnt own a VeeFriend");

        useless.transfer(msg.sender, 10); 
        hasClaimed[msg.sender] = true;
    }
}

We now have passing tests to assert an airdrop is performed when claimed by a valid user. The airdrop though is too simplistic. For one, currently the token itself has to be aware of the Robodrop contract. We need to remove this dependency if we want our Robodrop to be able to airdrop to any existing ERC20 compliant token. We need to Robodrop to acquire the coins it has privilege on without minting as this isnt realistic tokenomically. Updating the airdrop functionality to:

  1. Robodrop will acquire the airdrop Tokens (fair and square) from uniswap (for now a basic token transfer from another account).
  2. Robodrop will distribute the acquired tokens as airdrops for qualified claims.

This decouples the Token owners and the Robodrop and makes Robodrop just another balance account in any ERC20. Robodrop can now airdrop any token not just UselessToken.

Simplified process of how tokens are transfered to claimant

This approach allows the robodrop owner to take the risk of the token transfers since it Robodrop only posses the ability to spend tokens its was given by the airdrop owner. Lets modify our tests to implement the illustrated steps.

  1. The Robodrop owner obtains some UselessTokens (we’ll skip the uniswap part) – we’ll create the tokens and send Robodrop owner some.
  2. Robodrop owner funds the Robodrop contract with UselessTokens (upon creation) that it’s allowed to spend as airdrops.
  3. User attempts to claim their UselessToken airdrop
  4. If the user is valid (according to Robodrop rules) the airdrop happens.

Updated unit tests looks now like this

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/Robodrop.sol";
import "../src/UselessToken.sol";

contract RobodropTest is Test {

    Robodrop public rb;
    uint tokenId = 36718;
    address owner = 0x616D5b49738F6bc5290d5222115526339D9B6bB1;
    address randomAccount = 0xF55ede940a8038aDAEd34e497fd4ac4Ce3D173c9;
    address ownerOfToken = 0xC015f727B997c34767D275C306B0527E900Da2E9; // make this a DAO may be in the future.
    UselessToken useless;

    address public constant VEE_ADD = 0x7A3159290ba6672c3Cc5741F6BcDF5261266CD15;

    function setUp() public {
        vm.createSelectFork("sepolia");

        rb = new Robodrop(VEE_ADD, 10);
        // our Robodrop contract now has 20000 UselessTokens
        vm.startPrank(ownerOfToken);
        useless = new UselessToken();
        useless.transfer(address(rb), 20000);
        vm.stopPrank();
    }

    function test_airdropClaim() public {
        assertEq(useless.balanceOf(owner), 0);

        vm.prank(owner);
        rb.claimAirDrop(tokenId, address(useless));

        assertEq(useless.balanceOf(owner), 10);
    }

    function testFail_insuffcientFunds() public {
        vm.startPrank(ownerOfToken);
        useless = new UselessToken();
        useless.transfer(address(rb), 5);
        vm.stopPrank();

        assertEq(useless.balanceOf(owner), 0);

        vm.prank(owner);
        rb.claimAirDrop(tokenId, address(useless));
    }

    function testFail_tryToClaimButDontOwnNFT() public {
        assertEq(useless.balanceOf(randomAccount), 0);
        vm.prank(randomAccount);
        rb.claimAirDrop(tokenId, address(useless));
    }

    function testFail_isAlreadyClaimed() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.startPrank(owner);
        rb.claimAirDrop(tokenId, address(useless));
        rb.claimAirDrop(tokenId, address(useless));
        vm.stopPrank();
    }

    function testFail_invalidSender() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.startPrank(address(0));
        rb.claimAirDrop(tokenId, address(useless));
        vm.stopPrank();
    }

}

And the Robodrop and the UselessToken files look like so

import { IERC721 } from "@openzeppelin-contracts/token/ERC721/IERC721.sol";
import { IERC20 } from "@openzeppelin-contracts/token/ERC20/IERC20.sol";

contract Robodrop {

    IERC721 public nft;
    uint public airdropAmount;

    mapping(address => bool) public hasClaimed;

    constructor(address _nftAddress, uint _airdropAmount) {
        nft = IERC721(_nftAddress);
        airdropAmount = _airdropAmount;
    }

    function claimAirDrop(uint256 tokenId, address token) external {
        require(!hasClaimed[msg.sender], "Autonomous Airdrop already claimed");
        require(msg.sender == nft.ownerOf(tokenId), "Account doesnt own a VeeFriend");

        IERC20(token).transfer(msg.sender, airdropAmount); 
        hasClaimed[msg.sender] = true;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { ERC20 } from "@openzeppelin-contracts/token/ERC20/ERC20.sol";
import { Ownable } from "@openzeppelin-contracts/access/Ownable.sol";

contract UselessToken is ERC20, Ownable {


    // Define the supply of UselessTokens: 1,000,000,000 
    uint256 constant initialSupply = 1000000000 * (10**18);

    constructor() 
    ERC20("UselessToken", "UT")
    Ownable(msg.sender)
    { 
        _mint(msg.sender, initialSupply);
    }

}

We have now generalised our Robodrop smart contract to airdrop any configured amount if any configured ERC721 compatible NFT is present by the claimant. The airdrop will happen as long as the Robodrop has sufficient tokens to dispense.

Implementing rule 2: Had an average of 200 TokenX in their account between dateX and dateY

The second rule shows addtional complexity added to the airdrop but it also shows how partnerships can be formed. Let me explain; currently our Robodrop only applies the logic that if the user owns a Veefriend. Lets say VeeFriends will partner up with an TokenX. As a token (no pun) of their appreciation to their fans. They want to want to airdrop a cool TokenY to users that not only currently hold a VeeFriend but also had an average of 200 TokenX between the dateX and dateY (showed diamond hands during a bear market for example). At a first glance this looks a great usecase for Merkle trees.

Merkle trees & Merkle proofs

Current example used when demonstrating airdrops is the Merkle Proof, simply put this allows you to use a array of data, in ourcase a list of addresses who qualify for the airdrop. We create a Merkle root from this list, allowing us to obtain a hash that represent our list address and their order. We store this hash in our Robodrop smart contract. The claimant can then use a proof, supply it to the Robodrop, the bot will then be able to verify that the claimant was part of the list that formed Merkle root has that it has.

Robodrop creator gets a list of qualified addresses and hashes them. Robodrop will only store the Merkle root hash in its smart contract

The airdrop claimant must obtain the exact list (qualified addresses) that the Robodrop calimed to have hashed. Claimant can put the list through the same merkle root config and obtain a hash. The 2 hashes match ? great ! Both the claimant and Robodrop are talking about the same data (list of addresses). The claimant will then ask the merkle tree to create a proof that an item in the list (the claimant’s address in this case) exist

Claimant generates Proof by supplying the qualified addresses and states which one of the addresses he/she is.

Merkle will take the list of data, and the index of the item that it will provide the proof path for.

Merkle generates the proof path for us

The claimant will then take that proof and submit a transaction to the Robodrop contract that says “Hay there Im here to collect my airdrop. I have a proof path that says thats says my address is amongst those that produced your Merkle root hash, here is the proof path and there is my address G”. Robodrop, using the root hash that was given to it by its admins, the proof path and the address of the claimant will be able check if this address was amongst the list that constructred its root hash.

Lets merkelize our unit tests:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/Robodrop.sol";
import "../src/UselessToken.sol";
import "./Merkle.sol";

import { MerkleProof } from "@openzeppelin-contracts/utils/cryptography/MerkleProof.sol";

contract RobodropTest is Test {

    Robodrop public rb;
    uint tokenId = 36718;
    address owner = 0x616D5b49738F6bc5290d5222115526339D9B6bB1;
    address randomAccount = 0xF55ede940a8038aDAEd34e497fd4ac4Ce3D173c9;
    address ownerOfToken = 0xC015f727B997c34767D275C306B0527E900Da2E9; // make this a DAO may be in the future.
    UselessToken useless;

    address public constant VEE_ADD = 0x7A3159290ba6672c3Cc5741F6BcDF5261266CD15;

    // we need to create a the data hash root
    Merkle m;
    bytes32 rootHash;
    bytes32[] data;
    
    function setUp() public {
        data = new bytes32[](3);
        data[0] = bytes32(uint256(uint160(owner)));
        data[1] = bytes32(uint256(uint160(randomAccount)));
        data[2] = bytes32(uint256(uint160(randomAccount)));

        vm.createSelectFork("sepolia");


        m = new Merkle();
        rootHash = m.getRoot(data);
        rb = new Robodrop(VEE_ADD, 10, rootHash);
        vm.startPrank(ownerOfToken);
        useless = new UselessToken();
        useless.transfer(address(rb), 20000);
        vm.stopPrank();
    }

    function _getData() private returns(bytes32[] memory) {
        bytes32[] memory matching_data = new bytes32[](3);
        matching_data[0] = bytes32(uint256(uint160(owner)));
        matching_data[1] = bytes32(uint256(uint160(randomAccount)));
        matching_data[2] = bytes32(uint256(uint160(randomAccount)));
        return matching_data;
    }

    function _get_proof() private returns(bytes32[] memory) {
        return m.getProof(_getData(), 0);
    }

    function testFail_airdropClaimWithInvalidProof() public {
        bytes32[] memory matching_data = new bytes32[](3);
        // create a proof from a different dataset than what the robodrop.sol used
        matching_data[0] = bytes32(uint256(uint160(owner)));
        matching_data[1] = bytes32(uint256(uint160(ownerOfToken))); // modified info
        matching_data[2] = bytes32(uint256(uint160(randomAccount)));
        // to get the proof we need to 
        bytes32[] memory proof =  m.getProof(matching_data, 0);
        vm.startPrank(owner);
        rb.claimAirDrop(tokenId, address(useless), proof);
        vm.stopPrank();       
    }

    function test_airdropClaim() public {
        assertEq(useless.balanceOf(owner), 0);

        vm.startPrank(owner);
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
        vm.stopPrank();

        assertEq(useless.balanceOf(owner), 10);
    }

    function testFail_insuffcientFunds() public {
        vm.startPrank(ownerOfToken);
        useless = new UselessToken();
        useless.transfer(address(rb), 5);
        vm.stopPrank();

        assertEq(useless.balanceOf(owner), 0);

        vm.prank(owner);
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
    }

    function testFail_tryToClaimButDontOwnNFT() public {
        assertEq(useless.balanceOf(randomAccount), 0);
        vm.prank(randomAccount);
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
    }

    function testFail_isAlreadyClaimed() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.startPrank(owner);
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
        vm.stopPrank();
    }

    function testFail_invalidSender() public {
        assertEq(useless.balanceOf(owner), 0);
        vm.startPrank(address(0));
        rb.claimAirDrop(tokenId, address(useless), _get_proof());
        vm.stopPrank();
    }

}

The above test ok but we can improve them (we’ll do that later). Our Merkelized robodrop smart contract will therefore look like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { IERC721 } from "@openzeppelin-contracts/token/ERC721/IERC721.sol";
import { IERC20 } from "@openzeppelin-contracts/token/ERC20/IERC20.sol";
import { Ownable } from "@openzeppelin-contracts/access/Ownable.sol";
import { MerkleProof } from "@openzeppelin-contracts/utils/cryptography/MerkleProof.sol";

contract Robodrop is Ownable {

    IERC721 public nft;
    uint public airdropAmount;
    bytes32 rootHash; 

    mapping(address => bool) public hasClaimed;

    constructor(address _nftAddress, uint _airdropAmount, bytes32 _rootHash) Ownable(msg.sender){
        nft = IERC721(_nftAddress);
        airdropAmount = _airdropAmount;
        rootHash = _rootHash;
    }

    function updateRootHash(bytes32 _newRootHash) external onlyOwner() {
        rootHash = _newRootHash;
    }

    function claimAirDrop(uint256 tokenId, address token, bytes32[] memory proof) external {
        require(!hasClaimed[msg.sender], "Autonomous Airdrop already claimed");
        require(msg.sender == nft.ownerOf(tokenId), "Account doesnt own a VeeFriend");

        // lets check if they had an average 200 tokenXs in their account between dateX and dateY
        bytes32 sender = bytes32(uint256(uint160(msg.sender)));
        require(MerkleProof.verify(proof, rootHash, sender), "Couldnt verify proof");

        IERC20(token).transfer(msg.sender, airdropAmount); 
        hasClaimed[msg.sender] = true;
    }
}

Our Robodrop can now technically airdrop tokens to holders of an certain NFT and is in a list whos merkle root hash is stored in the Robodrop smart contract. We still have to do the offline process to get the qualifed addresses i.e. Since we’re looking for holders of TokenX between dateX and dateY AND held an average of 200. We will need to query the ethereum blockchain to get those lists of addresses. This is an offchain process and can be done in a couple of ways that we wont go into for now.

Limitations of the Merkle solution

Ok so now we have semi implemented our airdrop rules. However our implementation of second rule has presented us with a limitation. The second rule states: if an account had held 200 TokenXs in the period between dateX and dateY then they will be allowed to claim and airdrop of TokenY. This rule is only stated and not inforced. Our current merkle tree solution means that the root hash can be whatever the robodrop admins say ie. the admins are not obliged to adhere to the airdrop rules they proposed. They can remove qualifed addresses from their list before making the root hash and when the removed address comes to collect, they would be denied. The claimants are always at the mercy of the Robodrop admins. We need a better, tamper proof solution that can be trusted in a decentralised setting. In comes zero-knowledge proofs, this neat little tech will help us improve our Robodrop. We’ll explore that in my next blog.

Similar Posts