考试通知
Dapp-Learning Merkle Distributor Airdrop 实战:NFT/ERC20 空投与链上红包合约全解析 示例工程区块链【免费下载链接】Dapp-LearningDapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.项目地址https://gitcode.com/gh_mirrors/da/Dapp-Learning点击查看免费下载导读空投Airdrop是 Web3 项目冷启动与社区激励最常用的分发手段而如何在不泄露白名单、不付出高昂 gas、防机器人抢跑的前提下公平发放代币与 NFT一直是合约开发者的核心命题。本文以 Dapp-Learning 仓库中 basic/42-merkle-distributor-airdrop 为骨架完整拆解 5 种 NFT 空投方案、ERC20 Merkle 空投MerkleDistributor以及链上抢红包合约HappyRedPacket的实现原理、源码级细节与可复现的测试流程。读完本文你将掌握从直接 mint到线下签名 线上验证再到链下 Merkle 证明 链上验证的完整演进脉络并能独立部署、测试和审计自己的空投合约。一、三种空投场景与仓库结构总览该样例目录将空投场景划分为三大类覆盖了当前主流空投玩法的全部形态场景核心问题对应合约NFT 空投如何将 NFT 精确分发给目标用户并防止抢跑/冒领contracts/erc721/下 5 个合约ERC20 空投如何按快照批量分发代币且链上仅需保存一个 Merkle 根contracts/erc20/下 MerkleDistributor 系列抢红包如何在节假日发放随机金额的红包并防刷防重复领取contracts/redpacket/HappyRedPacket.sol仓库内部结构均以仓库根目录为起点合约源码erc721/5 种 NFT 空投 SmartWallet、erc20/MerkleDistributor、Factory、MerkleProof、TestERC20、redpacket/HappyRedPacket、SimpleToken测试用例0-ERC721Mint.test.js~4-ERC721MerkleDrop.test.js、ERC20MerkleDrop.test.js以及链下 Merkle 树工具merkle-tree.js、balance-tree.js部署脚本NFT 流程的1-deploy.js/2-sign.js/3-redeem.js红包流程的scripts/redpacket/1-deploySimpleToken.js~4-claimRedpacket.js工程配置、依赖清单、部署数据读写工具二、NFT 空投的五种实现方案文档给出了 NFT 空投能力从弱到强的五种实现本质上对应信任模型的逐步升级从信任项目方直接 mint到信任离线签名再到信任 Merkle 数学证明。以下逐一结合合约源码与测试用例展开。2.1 ERC721Basic直接对特定账户空投这是最简单的方式项目方拥有MINTER_ROLE的角色直接调用mint(account, tokenId)把 NFT 铸造给指定账户。合约继承 OpenZeppelin 的ERC721与AccessControl构造时将部署者授予DEFAULT_ADMIN_ROLE后续通过grantRole分配铸币权限代码见 ERC721Basic.solfunction mint(address account, uint256 tokenId) external onlyRole(MINTER_ROLE) { _safeMint(account, tokenId); }对应的测试 0-ERC721Mint.test.js 展示了完整调用链先给accounts[1]授予MINTER_ROLE再由其发起 mint并通过监听Transfer事件from为AddressZero即铸造行为断言成功await expect(this.registry.connect(this.accounts[1]).mint(account, tokenId)) .to.emit(this.registry, Transfer) .withArgs(ethers.constants.AddressZero, account, tokenId);适用场景项目方自己掌控名单、gas 成本可控、不需要用户交互的场景。缺点也很明显所有用户地址与 tokenId 的映射必须由项目方线下维护链上没有任何授权证明。2.2 ERC721LazyMint线下签名 线上验证针对项目方先通过邮件把 tokenId 发给用户用户线下签名后再回传的场景ERC721LazyMint 引入了签名即授权的机制。流程为项目方选定(tokenId, account)并计算哈希keccak256(abi.encodePacked(tokenId, account))使用拥有MINTER_ROLE的账户对哈希做signMessage即 EthSignedMessageHash 格式签名任意人Relayer拿着account, tokenId, signature调用链上redeem合约恢复签名地址并校验其是否拥有MINTER_ROLE通过后_safeMint。核心实现见 ERC721LazyMint.solfunction redeem(address account, uint256 tokenId, bytes calldata signature) external { require(_verify(_hash(account, tokenId), signature), Invalid signature); _safeMint(account, tokenId); } function _hash(address account, uint256 tokenId) internal pure returns (bytes32) { return MessageHashUtils.toEthSignedMessageHash(keccak256(abi.encodePacked(tokenId, account))); } function _verify(bytes32 digest, bytes memory signature) internal view returns (bool) { return hasRole(MINTER_ROLE, ECDSA.recover(digest, signature)); }测试 1-ERC721LazyMint.test.js 完整演示了签名生成与链上赎回// 线下签名minter 签名 const signature await this.accounts[1].signMessage(hashToken(tokenId, account)); // 线上验证并铸造任何人可代为提交 await expect(this.registry.connect(this.accounts[2]).redeem(account, tokenId, signature)) .to.emit(this.registry, Transfer) .withArgs(ethers.constants.AddressZero, account, tokenId);该方案的关键安全属性防抢跑Frontrun测试中把签名用于另一个账户的 tokenId 时会以Invalid signature回滚因为签名内容绑定了account抢跑者无法改地址冒领防重复铸造同一 tokenId 第二次 redeem 会触发 OpenZeppelin ERC721 的ERC721: token already minted回滚。局限签名内容是(tokenId, account)的裸哈希用户在线下签名的过程中例如 MetaMask 弹出窗口时无法直观看到签名的具体字段含义容易被钓鱼或误签。2.3 ERC721LazyMintWith712EIP-712 结构化签名EIP-712 解决了盲签问题它把待签名的数据结构化Domain Types Value钱包可以在签名弹窗中把字段名如tokenId、account清晰展示给用户用户确认的是一笔可读的授权。合约实现见 ERC721LazyMintWith712.sol核心变化在于_hash使用_hashTypedDataV4对 EIP-712 结构化数据求哈希function _hash(address account, uint256 tokenId) internal view returns (bytes32) { return _hashTypedDataV4(keccak256(abi.encode( keccak256(NFT(uint256 tokenId,address account)), tokenId, account ))); }构造时通过EIP712(name, 1.0.0)绑定 Domain 的name与version链下签名时 Domain 还包含chainId与verifyingContract验证合约地址从而保证签名跨链不可复用、跨合约不可复用。文档给出的链下签名样例为// Domain { name: Name, version: 1.0.0, chainId: this.chainId, verifyingContract: this.registry.address, }, // Types { NFT: [ { name: tokenId, type: uint256 }, { name: account, type: address }, ], }, // Value this.token,测试 2-ERC721LazyMintWith712.test.js 使用 ethers 的_signTypedData生成签名流程与 LazyMint 一致redeem(account, tokenId, signature)链上校验通过后铸造并同样覆盖了重复铸造与更换 owner 抢跑两个负向用例。2.4 ERC721LazyMintWith712SignatureChecker兼容合约钱包的 SignatureChecker在上一方案中链上校验是ECDSA.recover它只支持 EOA 私钥签名的验证。若授权方是一个智能合约钱包如 Gnosis Safe则需要 ERC-1271 标准的isValidSignature回调。SignatureChecker.isValidSignatureNow会同时兼容这两种情形先尝试 ECDSA 恢复若失败则回调查询合约是否实现 ERC-1271。合约源码见 ERC721LazyMintWith712SignatureChecker.sol其redeem额外接收一个signer参数因为合约钱包的签名者地址必须显式指定验证逻辑正是文档强调的核心片段function _verify(address signer, bytes32 digest, bytes memory signature) internal view returns (bool) { return hasRole(MINTER_ROLE, signer) SignatureChecker.isValidSignatureNow(signer, digest, signature); }仓库配套提供了一个最小化的 ERC-1271 智能钱包示例 SmartWallet.sol它继承Ownable与IERC1271isValidSignature内部通过ECDSA.recover校验 owner 签名后返回this.isValidSignature.selector作为 magic value。测试 3-ERC721LazyMintWith712Signature.test.js 展示了关键点将MINTER_ROLE授予智能钱包地址而非 EOA由 EOA owner 链下签名、链上传入smartwallet.address作为 signer 完成赎回await expect(this.registry.connect(this.accounts[2]).redeem(account, tokenId, this.smartwallet.address, signature)) .to.emit(this.registry, Transfer) .withArgs(ethers.constants.AddressZero, account, tokenId);结论该方案在 EIP-712 可读签名的基础上进一步把授权方从 EOA 扩展到了合约钱包是 DAO 金库、多签钱包发起空投时的更完整方案。2.5 ERC721MerkleDrop链下 Merkle 证明 链上验证前面几种方案中签名必须由拥有MINTER_ROLE的账户逐一签发每个用户一份签名当白名单规模达到数万乃至数十万时签名分发与管理成本极高。Merkle 空投的解法是把整张白名单压缩成一颗 Merkle 树链上只保存 32 字节的根root每个用户只需要自己从根到叶子的一条证明路径proof即可自证属于白名单。合约实现见 ERC721MerkleDrop.solfunction redeem(address account, uint256 tokenId, bytes32[] calldata proof) external { require(_verify(_leaf(account, tokenId), proof), Invalid merkle proof); _safeMint(account, tokenId); } function _leaf(address account, uint256 tokenId) internal pure returns (bytes32) { return keccak256(abi.encodePacked(tokenId, account)); } function _verify(bytes32 leaf, bytes32[] memory proof) internal view returns (bool) { return MerkleProof.verify(proof, root, leaf); }链下部分文档给出的流程是用merkletreejs构建树并生成叶子证明// 链下生成 Merkle 证明 this.token.proof this.merkleTree.getHexProof(hashToken(this.token.tokenId, this.token.account)); // 调用链上接口进行验证, 同时生成 NFT token await expect(this.registry.redeem(this.token.account, this.token.tokenId, this.token.signature)) .to.emit(this.registry, Transfer) .withArgs(ethers.constants.AddressZero, this.token.account, this.token.tokenId);注意此处文档代码片段沿用了 LazyMint 的参数名signature实际 ERC721MerkleDrop 测试 中第三个参数是proofbytes32[]以合约redeem(account, tokenId, proof)为准。测试 4-ERC721MerkleDrop.test.js 展示了三个关键用例正常领取遍历tokens.json中全部白名单逐个getHexProof后 redeem 成功防重复同一 token 二次 redeem 回滚ERC721: token already minted防抢跑把 A 的 proof 改用到accounts[0]地址上 redeem因叶子哈希不匹配而回滚Invalid merkle proof——Merkle 证明天然绑定叶子内容tokenId account抢跑者无法为他人地址伪造有效证明。方案对比小结方案授权载体链上存储适用规模用户可读性钱包类型ERC721Basic无项目方直接 mint无额外存储小——ERC721LazyMint裸哈希签名无中差盲签仅 EOAERC721LazyMintWith712EIP-712 结构化签名无中好仅 EOAERC721LazyMintWith712SignatureCheckerEIP-712 ERC-1271无中好EOA 合约钱包ERC721MerkleDropMerkle 证明仅 32 字节 root大万级无需签名任意三、ERC20 Merkle 空投MerkleDistributor 深度解析ERC20 空投的场景是按快照余额把代币分发给海量地址。文档指出 1inch、dydx、Uniswap 等项目的空投均采用 Merkle 方案其核心思路与 NFT 版一致但多了金额维度和领取防重设计。3.1 合约核心逻辑MerkleDistributor.sol 的构造参数为token代币地址、merkleRoot白名单 Merkle 根、_duration领取有效期单位秒与_owner到期后可回收剩余代币的地址constructor(address token_, bytes32 merkleRoot_, uint _duration, address _owner) public { owner _owner; token token_; merkleRoot merkleRoot_; expire_time block.timestamp _duration; }claim函数的完整校验链如下function claim(uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof) external override { require (expire_time block.timestamp, Expired); require(!isClaimed(index), MerkleDistributor: Drop already claimed.); // Verify the merkle proof. bytes32 node keccak256(abi.encodePacked(index, account, amount)); require(MerkleProof.verify(merkleProof, merkleRoot, node), MerkleDistributor: Invalid proof.); // Mark it claimed and send the token. _setClaimed(index); require(IERC20(token).transfer(account, amount), MerkleDistributor: Transfer failed.); emit Claimed(index, account, amount); }要点解析叶子节点为keccak256(abi.encodePacked(index, account, amount))同时绑定领取序号、地址与金额任何一项被篡改都会导致证明校验失败防重复领取合约用claimedBitMapmapping(uint256 uint256)的位图标记已领取的 index——每个 uint256 字节约 256 个 index极大节省存储详见isClaimed/_setClaimed中index / 256与index % 256的位运算过期回收claimRestTokens(to)仅允许owner在expire_time之后调用把合约内剩余代币一次性转出避免资金永久锁死。3.2 批量部署工厂MerkleDistributorFactory.sol 提供了按需创建多个 Distributor 的能力createDistributor(token, merkleRoot, _duration, owner)会部署新实例并记录到distributors数组getDistributor(index)/getDistributorsCount()用于查询同时发出DistributorCreated事件。对需要多轮空投或多个项目共用一套工厂的场景直接复用该工厂即可。3.3 链下 Merkle 树构建与测试验证链下部分仓库提供了两份工具merkle-tree.js通用 Merkle 树实现叶子排序、去重后逐层哈希支持getHexRoot()与getHexProof(el)balance-tree.js面向余额快照的封装叶子为solidityKeccak256([uint256,address,uint256], [index, account, amount])与链上node计算完全一致// keccak256(abi.encode(index, account, amount)) toNode(index, account, amount) { return Buffer.from( utils.solidityKeccak256([uint256, address, uint256], [index, account, amount]).substr(2), hex ) }测试 ERC20MerkleDrop.test.js 演示了两条完整链路内存树用BalanceTree构造 alice100/ bob101两个叶子部署MerkleDistributor后调用claim断言Claimed事件参数(index, account, amount)文件快照从test/erc20.json读取地址-金额快照构建大树部署后用文件树生成的 proof 完成领取证明基于真实快照文件批量空投的可行性。测试同时展示了工厂的用法createDistributor(erc20.address, tree.getHexRoot(), 3600, owner.address)有效期 3600 秒后通过getDistributor(0)取回实例。部署前需要先向 Distributor 转入足够代币测试中erc20.transfer(distributor.address, 201)否则claim会因Transfer failed回滚。四、链上抢红包HappyRedPacket 合约实战文档指出抢红包合约可在节假日部署以发放红包对应合约路径为contracts/redpacket。它其实是Merkle 白名单 随机金额分配 过期退款的组合玩法源码见 HappyRedPacket.sol。4.1 数据打包用位运算把存储成本压到极致该合约最值得学习的设计是Packed 数据打包把多个字段塞进两个uint256显著节省 SSTORE 的 gas 成本。数据结构如下struct Packed { uint256 packed1; // 0 (128) total_tokens (96) expire_time(32) uint256 packed2; // 0 (64) token_addr (160) claimed_numbers(15) total_numbers(15) token_type(1) ifrandom(1) }packed1高位 128 位留空中间 96 位存剩余代币总量低 32 位存过期时间戳可覆盖到 2106 年packed2低 64 位留空之后 160 位存代币地址再往上 1515 位分别存已领取数/总份数最大 16384最高两位分别表示 token 类型0ETH1ERC20与是否随机分配。合约内部通过内联汇编实现box写入、unbox读取与rewriteBox原位改写三个位操作工具见源码中assembly段并以注释标明各字段的位宽与容量边界如 total tokens 80 bits ~8 * 10^10 18 decimals。4.2 创建红包与领取逻辑create_red_packet的参数含义与 3-createRedPacket.js 中的实际调用一一对应参数含义样例脚本中的值_merkleroot白名单 Merkle 根claimerList.json构建的根_number红包份数93_ifrandom是否随机分配金额true_duration有效期秒2592003 天_seed随机种子bytes32formatBytes32String(lajsdklfjaskldfhaikl)_message/_name红包留言与名称Hi/cache_token_type0ETH1ERC201_token_addr代币地址SimpleToken 地址_total_tokens红包总金额438000000合约对入参有一系列硬校验_total_tokens _number每份至少能分到钱、0 _number 256最多 255 份、_token_type只能是 0 或 1、且每份金额不低于 0.1_total_tokens 10**(decimals()-1) * _number。若_token_type 1合约通过safeTransferFrom从创建者处拉取代币并以转账前后余额差作为实际到账金额避免代币合约的 fee-on-transfer 机制导致记账偏差。claim(id, proof)的领取流程校验红包未过期、未领完claimed_number total_number用MerkleProof.verify(proof, merkleroot, _leaf(msg.sender))验证领取者确实在claimerList白名单中若为随机模式先给后续领取者预留每人至少 0.1 的保底金额再用random(seed, nonce)在剩余可分配额度内取随机数并把结果向下取整到 0.1 的整数倍若只剩最后一份则直接领取全部剩余金额把重复领取检查放在所有状态变更之后源码注释 Penalize greedy attackers by placing duplication check at the very lastrequire(rp.claimed_list[msg.sender] 0, Already claimed)以此惩罚试图通过恶意构造 proof 抢跑的机器人——即使抢跑成功也会因重复领取被回滚而白白支付 gas。4.3 过期退款refund(id)仅允许creator在过期后调用将剩余代币按 token 类型转回创建者并发出RefundSuccess事件check_availability(id)提供查询接口返回代币地址、剩余金额、总份数、已领份数、是否过期以及调用者本人的已领金额。4.4 红包部署与领取的完整流程文档给出了 kovan 测试网的完整操作链以--network kovan运行命令保留原样Step 1 配置环境变量复制环境变量模板后填写私钥与节点信息cp .env.exmpale .env ## 在 .env 文件中配置 PRIVATE_KEY, PRIVATE_KEY1, PRIVATE_KEY2, INFURA_ID, PROJECT_ID, TARGET_ACCOUNT ## 比如有一个账户 A那么 PRIVATE_KEY 为账户 A 对应的 PRIVATE_KEYTARGET_ACCOUNT 为账户 A 对应的账户地址 ## 模拟多人抢红包需要配置三个私钥说明结合 hardhat.config.js 的实现mnemonic()读取PRIVATE_KEY、PRIVATE_KEY1、PRIVATE_KEY2三个私钥作为各网络rinkeby/kovan/mainnet/ropsten/matic/optim的签名账户INFURA_ID用于拼接 Infura RPC 地址文档要求的PROJECT_ID、TARGET_ACCOUNT供红包部署与领取脚本区分模拟账户使用。Step 2 安装依赖yarnStep 3 部署 ERC20SimpleToken记录输出的Token addressnpx hardhat run scripts/redpacket/1-deploySimpleToken.js --network kovan ## 输入信息如下: Deploying contracts with the account: 0x3238f24e7C752398872B768Ace7dd63c54CfEFEc Account balance: 796474026501725149 Token address: 0xdc6999dC3f818B4f74550569CCC7C82091cA419F 1000000000Step 4 部署 HappyRedPacket 合约记录输出的RedPacket addressnpx hardhat run scripts/redpacket/2-deployHappyRedPacket.js --network kovan ## 输出信息如下: Deploying contracts with the account: 0x3238f24e7C752398872B768Ace7dd63c54CfEFEc Account balance: 783625061469463255 RedPacket address: 0x6F35e57a7421F5b04DDb47b67453A5a5Be32e58BStep 5 创建红包内部会先从claimerList.json构建 Merkle 树并打印根npx hardhat run scripts/redpacket/3-createRedPacket.js --network kovan ## 输出值 Approve Successfully merkleTree Root: 0x5cc6f1ff34a2c6f871d40cdc4559468f96a7ec06d7bf6ab0f9b5aeccc9b33154 CreationSuccess Event, total: 10000 RedpacketId: 0x45eb11e56a1b699f5e99bd16785c84b73a8257c712e0d1f31306ab1e3423b2e0 Create Red Packet successfullyStep 6 领取红包npx hardhat run scripts/redpacket/4-claimRedpacket.js --network kovan ## 得到的输出 Sign Message: 即为领取红包时需要输入的签名信息防止恶意领取源码层面的补充红包地址与红包 ID 通过 utils.js 的readRedpacketDeployment/saveRedpacketDeployment读写scripts/redpacket/deployment.json在脚本间传递4-claimRedpacket.js 会为领取者计算merkleTree.getHexProof(hashToken(user.address))并调用claim(redpacketID, proof)通过对比领取前后SimpleToken余额输出实际到账金额。五、Merkle 空投的测试流程文档给出了两类测试的运行方式。Merkle airdropNFT 五种方案 ERC20 Distributoryarn npx hardhat testnpx hardhat test会依次执行 0-ERC721Mint.test.js 至 4-ERC721MerkleDrop.test.js 以及 ERC20MerkleDrop.test.js。每个测试文件都覆盖三类断言正向redeem/claim 后发出Transfer或Claimed事件参数精确匹配防重复重复领取回滚NFT 为token already mintedERC20 为Drop already claimed防抢跑篡改 account/amount 后回滚NFT 为Invalid merkle proof/Invalid signatureERC20 为Invalid proof.。HappyRedPacket按第四节的红包流程依次部署与调用依赖真实测试网或本地节点需先配置.env。工程侧的可复现性说明测试数据源为 test/tokens.jsonNFT 白名单与 test/erc20.jsonERC20 余额快照红包白名单为 scripts/redpacket/claimerList.json合约编译环境在 hardhat.config.js 中声明了 0.5.16 ~ 0.8.20 多版本编译器并开启 optimizerruns200依赖版本见 package.json。六、安全要点总结与选型建议综合五类合约源码与对应测试可将空投合约的通用安全模型归纳为以下四条授权绑定签名或证明必须绑定领取者地址 资产标识tokenId / index / amount否则可被抢跑者改地址冒领防重入与防重领NFT 依赖 ERC721 的同 tokenId 不可重复铸造ERC20 依赖claimedBitMap位图红包依赖最后校验 状态后置策略过期与回收ERC20 Distributor 提供claimRestTokens、HappyRedPacket 提供refund避免空投资金因无人领取而永久锁死链下链上哈希一致BalanceTree.toNode与MerkleDistributor的叶子计算均为keccak256(abi.encodePacked(index, account, amount))NFT 版为keccak256(abi.encodePacked(tokenId, account))任何一端的字段顺序、编码方式不一致都会导致验证失败——这是集成 Merkle 空投最常见的坑。选型建议小规模、项目方主导的空投用ERC721Basic即可需要用户参与但名单可控用ERC721LazyMint系列涉及合约钱包或 DAO 金库授权选ERC721LazyMintWith712SignatureChecker大规模万级地址以上或需要公开自取的空投直接采用ERC721MerkleDrop/MerkleDistributor并配套MerkleDistributorFactory支持多轮空投社区节日活动则可基于HappyRedPacket封装自己的随机金额 白名单 过期退款玩法。赞分享示例工程区块链【免费下载链接】Dapp-LearningDapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.项目地址https://gitcode.com/gh_mirrors/da/Dapp-Learning点击查看免费下载相关推荐WTF-Solidity 第 33 讲深度解析智能合约 ERC20 空投合约Airdrop Contract源码级实战WTF Solidity 第 33 讲深度解析智能合约 ERC20 空投合约Airdrop Contract源码级实战 本篇文章基于 WTF Solidi示例工程区块链教程FancyZones拖进区域窗口自动归位FancyZones拖进区域窗口自动归位 窗口布局的活儿不该留给手抠边框。FancyZones 是 PowerToys 里的窗口管理模块把屏幕划成区域桌面应用开发工具Variant Form3-vite终极指南从可视化设计到企业级表单的深度实践Variant Form3 vite终极指南从可视化设计到企业级表单的深度实践 你是否曾为复杂业务表单的开发而头疼面对频繁变化的表单需求传统开发模式需要反前端低代码创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考