空投大杂烩 - 合约实现空投发放的三种方案

什么空投合约“空投合约”(AirdropContract)是指专门用于自动向一组地址发送代币或NFT的智能合约:https://learnblockchain.cn/shawn_shaw

什么空投合约

“空投合约”(Airdrop Contract)是指专门用于自动向一组地址发送代币或 NFT 的智能合约。常用与项目早期免费向参与者发送奖励,激励用户参与项目,获取流量。

加密货币历史上资金量最大的空投项目是 HyperliquidHYPE 代币分发。该项目于 202411 月向其社区分发了 3.1 亿枚 HYPE 代币,占总供应量的 31%。在短短几周内,这些代币的价值飙升至超过 108 亿美元,创下了空投历史上的新纪录。 image.png 下面我将以以太坊上的空投合约构建说起,使用以太坊 ERC20 合约来讲解智能合约中发放空投的三种实现方式。

空投合约的实现三种方式

空投合约的实现方式分别有三种。

  1. 批量铸造:项目方直接通过调用合约中的 mint 函数,主动给满足条件的参与者批量铸造代币。但是这种方式,虽然简单易懂,但在参与者较多的情况下,通常需要消耗海量 gas。极不经济。并且这种方式发送空投,权限全由项目方控制,中心化程度较高。

  2. 默克尔证明:这种方式通过在智能合约中存储一个默克尔根,链下维护默克尔树。默克尔树及默克尔证明原理请看默克尔树 用户领取空投,传入默克尔证明和空投地址即可完成空投地址的认证。

    默克尔证明的好处是,链上只需存储默克尔树根即可,通过节省合约的存储空间来降低 gas 消耗。链下维护默克尔树。在验证空投资格的时候,传入空投地址和相应的默克尔证明,通过合约内的计算即可完成空投的发放。缺点是,一旦部署合约后,合约中的默克尔根无法修改,没办法动态增加空投地址。

    1. 数字签名:数字签名发放空投的方式,相对而言更加有优势。数字签名的实现原理请看 数字签名,验证空投者的身份通过数字签名验证来完成,项目方只负责给空投者的地址发送签名,空投者拿着这个签名和空投数量即可调用合约的函数来进行领取空投。数字签名的方案,比默克尔证明的方式更省 gas,因为链上无需存储任何数据,签名全程在链下执行。并且,数字签名的方式还能实现空投地址的动态增加。

下面,我们来分别讲解三种方案的实现流程。

项目方批量铸造

  • 链上合约: 编写一个 dropForBatch 函数,只允许合约拥有者调用,使用 for 循环对代币进行 mint 。完成空投发送。

    /*方案一:项目方批量发送空投
    只能项目方(合约拥有者)调用
    */
    function dropForBatch(
        address[] calldata spenders,
        uint256[] calldata values
    ) external onlyOwner() {
        require(spenders.length == values.length, "Lengths of Addresses and Amounts NOT EQUAL");
        /*循环批量给用户 mint */
        for (uint i = 0; i < spenders.length; i++) {
            _mint(spenders[i], values[i]);
        }
    }
  • 链下调用: 构造 receivers 地址数组和 amounts 地址数组,直接发起调用即可。

    address[] memory receivers = new address[](2);
        uint256[] memory amounts = new uint256[](2);
    
        receivers[0] = user1;
        receivers[1] = user2;
        amounts[0] = 100 ether;
        amounts[1] = 200 ether;
    
        airdrop.dropForBatch(receivers, amounts);

    默克尔证明

  • 链上合约: 在链上,最主要是要保存一个默克尔树的树根,根据传入的地址和 proof 来构造出这个树根则说明地址是正确的。

    /*空投默克尔树根 hash*/
    bytes32 immutable public rootHash;

    调用方法上,先校验地址是否已空投过,然后使用 OZ 代码库中的 MerkleProof 库来验证 proofleafHash 是否能构建出 rootHash。如果能,则说明这个 leaf(空投地址)有资格领取空投。

    /*
    方案二:默克尔证明
    用 MerkleProof 库判断 (spender + value) 和 proof 构建的 recoverRoothash 是否和 rootHash 一致
    */
    function dropForMerkleProof(
        address spender,
        uint256 value,
        bytes32[] calldata proof
    ) external {
        /*已经空投过*/
        require(!mintedAddress[spender],"Aleardy minted!");
        /*用 地址 + 数量 构建叶子节点 hash*/
        bytes32 leafHash = keccak256(abi.encodePacked(spender,value));
        bool inAirdrop = MerkleProof.verify(proof,rootHash,leafHash);
        /*校验默克尔证明是否正确*/
        require(inAirdrop,"Invalid merkle proof!");
        mintedAddress[spender] = true;
        _mint(spender, value);
    }
  • 链下调用: 链下部分,我们首先要保存着一棵完整的默克尔树(前端有相应的 js 库可以处理,后面代码仅构造一个非常简单的二层树),有了这棵树之后,我们可以在这棵树中找到某个空投地址的 hash(在叶子节点上)。然后我们使用这个地址的 hash,往上去寻找其兄弟节点的 hash,构造出一个 proof 数组。然后使用空投地址、数量、proof 数组,发送到合约中即可。

        /*这里仅用 1 个兄弟节点替代,
        默克尔树较深的肯定不止一个,
        leaf2 为 leaf1 的兄弟节点
        */
        proof = new bytes32[](1);
        proof[0] = leaf2;
        token.dropForMerkleProof(user1, 100 ether, proof);

    数字签名

  • 链上合约: 链上合约部分,首先是使用到 ECDSA 的库来对 messageHashsignature 来恢复出地址,如果此地址等于我们的合约管理员的地址。则说明这个被签名的地址(参数中的 spender )有资格领取空投合约。(注意:这里未考虑签名重放攻击的隐患。理论上,需要设置 noncechainIddeadline

    /*方案三:使用数字签名验证空投人是否有权限
    未针对签名可重放攻击,
    需要避免签名可重放攻击需加上 nonce、chainId、deadline 来构建消息进行签名
    */
    function dropForSignature(
    address spender,
    uint256 value,
    bytes calldata signature
    ) external {
        require(!mintedAddress[spender], "Already minted!");
        /*恢复 messageHash*/
        bytes32 dataHash = keccak256(abi.encodePacked(spender,value));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(dataHash);
    
        /*从 messageHash 和 signature中恢复地址*/
        address recovered = ECDSA.recover(messageHash, signature);
        require(owner() == recovered,"verify signature fail");
        mintedAddress[spender] = true;
        _mint(spender,value);
    }
  • 链下调用: 链下调用部分,当然是首先需要使用合约管理员的私钥对 spenderuser1) 地址签发一个允许空投的签名(参数为 spender 地址、代币数量)。然后使用 spender 地址、代币数量签名 直接调用空投合约即可。

        uint256 amount = 100 ether;
    
        // 构造签名消息
        bytes32 dataHash = keccak256(abi.encodePacked(user1, amount));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(dataHash);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, messageHash);
        bytes memory signature = abi.encodePacked(r, s, v);
    
        // 伪造成 user1 调用合约领取
        vm.prank(user1);
        token.dropForSignature(user1, amount, signature);

三种方案的完整代码实现

代码仓库

批量铸造

  • 空投合约 Airdrop1.sol

    contract Airdrop1 is ERC20, Ownable {
    constructor(string memory _name, string memory _symbol) ERC20(_name,_symbol) Ownable(msg.sender) {
    }
    
    /*方案一:项目方批量发送空投
    只能项目方(合约拥有者)调用
    */
    function dropForBatch(
        address[] calldata spenders,
        uint256[] calldata values
    ) external onlyOwner() {
        require(spenders.length == values.length, "Lengths of Addresses and Amounts NOT EQUAL");
        /*循环批量给用户 mint */
        for (uint i = 0; i < spenders.length; i++) {
            _mint(spenders[i], values[i]);
        }
    }
    }

    - 测试 Airdrop1.t.sol

    
    contract Airdrop1Test is Test {
    Airdrop1 public airdrop;
    address public owner;
    address public user1 = address(0x1);
    address public user2 = address(0x2);
    
    function setUp() public {
        owner = address(this);
        airdrop = new Airdrop1("TestToken", "TTK");
    }
    
    function testBatchDrop() public {
        address[] memory receivers = new address[](2);
        uint256[] memory amounts = new uint256[](2);
    
        receivers[0] = user1;
        receivers[1] = user2;
        amounts[0] = 100 ether;
        amounts[1] = 200 ether;
    
        airdrop.dropForBatch(receivers, amounts);
    
        assertEq(airdrop.balanceOf(user1), 100 ether);
        assertEq(airdrop.balanceOf(user2), 200 ether);
    }

}

- **测试命令**

    ```js
     forge test --match-path "./test/airdrop/Airdrop1.t.sol"  -vvvv

image.png

默克尔证明

  • 链上合约 Airdrop2.sol

    contract Airdrop2 is ERC20 {
    /*空投默克尔树根 hash*/
    bytes32 immutable public rootHash;
    /*记录已空投的地址*/
    mapping(address => bool) public mintedAddress;
    
    constructor(string memory _name, string memory _symbol, bytes32 _rootHash) ERC20(_name,_symbol){
        rootHash = _rootHash;
    }
    
    /*
    方案二:默克尔证明
    用 MerkleProof 库判断 (spender + value) 和 proof 构建的 recoverRoothash 是否和 rootHash 一致
    */
    function dropForMerkleProof(
        address spender,
        uint256 value,
        bytes32[] calldata proof
    ) external {
        /*已经空投过*/
        require(!mintedAddress[spender],"Aleardy minted!");
        /*用 地址 + 数量 构建叶子节点 hash*/
        bytes32 leafHash = keccak256(abi.encodePacked(spender,value));
        bool inAirdrop = MerkleProof.verify(proof,rootHash,leafHash);
        /*校验默克尔证明是否正确*/
        require(inAirdrop,"Invalid merkle proof!");
        mintedAddress[spender] = true;
        _mint(spender, value);
    }
    }
  • 测试 Airdrop2.t.sol

    contract Airdrop2Test is Test {
    Airdrop2 public token;
    
    address user1 = address(0x1);
    address user2 = address(0x2);
    address user3 = address(0x3); // 非空投地址
    
    bytes32 public root;
    bytes32[] public proof;
    bytes32 public leaf1;
    bytes32 public leaf2;
    
    function setUp() public {
        // 构建 Merkle Tree (2个地址)
        address[] memory recipients = new address[](2);
        uint256[] memory amounts = new uint256[](2);
        recipients[0] = user1;
        recipients[1] = user2;
        amounts[0] = 100 ether;
        amounts[1] = 200 ether;
    
        bytes32[] memory leaves = new bytes32[](2);
        leaves[0] = keccak256(abi.encodePacked(recipients[0], amounts[0]));
        leaves[1] = keccak256(abi.encodePacked(recipients[1], amounts[1]));
    
        // 构建 Merkle Tree 手动计算 root (简单两层)
        // 若已排序:hash(left, right)
        leaf1 = leaves[0];
        leaf2 = leaves[1];
        if (leaf1 < leaf2) {
            root = keccak256(abi.encodePacked(leaf1, leaf2));
        } else {
            root = keccak256(abi.encodePacked(leaf2, leaf1));
        }
        console.log("==== root =====");
        console.logBytes32(root);
        token = new Airdrop2("AirdropToken", "ATK", root);
    }
    
    /*有效 proof*/
    function testAirdropWithValidProof() public {
        vm.prank(user1);
        /*这里仅用 1 个兄弟节点替代,
        默克尔树较深的肯定不止一个,
        leaf2 为 leaf1 的兄弟节点
        */
        proof = new bytes32[](1);
        proof[0] = leaf2;
        token.dropForMerkleProof(user1, 100 ether, proof);
        assertEq(token.balanceOf(user1), 100 ether);
        assertTrue(token.mintedAddress(user1));
    }
    
    /*无效地址*/
    function testAirdropWithWrongProof() public {
        vm.prank(user3);
        proof = new bytes32[](1);
        proof[0] = leaf2;
        vm.expectRevert("Invalid merkle proof!");
        token.dropForMerkleProof(user3, 300 ether, proof); // 错误地址 + 错误 proof
    
    }
    }
  • 测试命令
    forge test --match-path "./test/airdrop/Airdrop2.t.sol"  -vvv

image.png

数字签名

  • 链上合约 Airdrop3.sol

    contract Airdrop3 is ERC20, Ownable{
    mapping(address => bool) public mintedAddress;
    constructor(string memory _name, string memory _symbol) ERC20(_name,_symbol) Ownable(msg.sender){
    }
    
    /*方案三:使用数字签名验证空投人是否有权限
    未针对签名可重放攻击,
    需要避免签名可重放攻击需加上 nonce、chainId、deadline 来构建消息进行签名
    */
    function dropForSignature(
    address spender,
    uint256 value,
    bytes calldata signature
    ) external {
        require(!mintedAddress[spender], "Already minted!");
        /*恢复 messageHash*/
        bytes32 dataHash = keccak256(abi.encodePacked(spender,value));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(dataHash);
    
        /*从 messageHash 和 signature中恢复地址*/
        address recovered = ECDSA.recover(messageHash, signature);
        require(owner() == recovered,"verify signature fail");
        mintedAddress[spender] = true;
        _mint(spender,value);
    }
    }
  • 测试 Airdrop3.t.sol

    contract Airdrop3Test is Test {
    Airdrop3 public token;
    address public owner;
    uint256 public ownerPrivateKey;
    address public user1;
    uint256 public user1PrivateKey;
    
    function setUp() public {
        ownerPrivateKey = 0xAAAAA;
        owner = vm.addr(ownerPrivateKey); // owner 拥有合约
        vm.prank(owner);
        token = new Airdrop3("AirdropToken", "ADT");
    
        // 创建 user1 地址
        user1PrivateKey = 0xA11CE; // 只是个示例私钥
        user1 = vm.addr(user1PrivateKey);
    }
    
    /*测试成功签名领取空投*/
    function testSuccessfulAirdropBySignature() public {
        uint256 amount = 100 ether;
    
        // 构造签名消息
        bytes32 dataHash = keccak256(abi.encodePacked(user1, amount));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(dataHash);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, messageHash);
        bytes memory signature = abi.encodePacked(r, s, v);
    
        // 伪造成 user1 调用合约领取
        vm.prank(user1);
        token.dropForSignature(user1, amount, signature);
    
        // 校验领取是否成功
        assertEq(token.balanceOf(user1), amount);
        assertTrue(token.mintedAddress(user1));
    }
    
    /*测试验签失败*/
    function testAirdropBySignatureFail() public {
        uint256 amount = 100 ether;
    
        // 构造签名消息
        bytes32 dataHash = keccak256(abi.encodePacked(user1, amount));
        bytes32 messageHash = MessageHashUtils.toEthSignedMessageHash(dataHash);
        /*给个错误的私钥去签名*/
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(user1PrivateKey, messageHash);
        bytes memory signature = abi.encodePacked(r, s, v);
    
        // 伪造成 user1 调用合约领取
        vm.prank(user1);
        vm.expectRevert("verify signature fail");
        token.dropForSignature(user1, amount, signature);
    }
    }
  • 测试命令

     forge test --match-path "./test/airdrop/Airdrop3.t.sol"  -vvv

image.png

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
shawn_shaw
shawn_shaw
web3潜水员、技术爱好者、web3钱包开发工程师、欢迎交流工作机会。欢迎骚扰:vx:cola_ocean