Web3 空投合约:Push 和 Pull 到底怎么选?

26 阅读9分钟

前言

本文对 Web3 空投机制进行了系统性的理论梳理,并深入探讨了两种主流分发模式 ——Push(推送式)与 Pull(拉取式)的底层逻辑。在此基础上,提供了完整的智能合约开发指南,涵盖了从核心逻辑编写、单元测试验证到主网部署的全流程实操演示,旨在为开发者提供一套可落地的空投解决方案。

一、空投核心定义与本质

Web3 空投是项目方的战略性代币分发策略,通过智能合约将资产直接转入用户钱包,无需用户支付对价(部分类型需承担 Gas 费),本质是项目冷启动、用户激励与去中心化治理的工具,全程链上可追溯与验证。


二、空投核心目的

目的说明
拉新促活吸引新用户完成链上交互、下载钱包,扩大曝光与用户基数,激励老用户参与社区
奖励早期用户回馈测试网参与者、社区创作者、流动性提供者等,强化认同感与忠诚度
分散代币所有权避免代币集中于项目方 / 机构,优化分配结构,为去中心化治理奠基
分发治理代币赋予持有者提案投票、参数修改等权利,推动 DAO 化发展
产品测试与反馈通过测试网空投收集真实交互数据与问题反馈,优化主网功能

三、空投核心模式定义

  • Push 空投(推送式) :项目方主动把代币 “硬塞” 到用户钱包里,就像快递员直接把包裹送到你家门口,全程不用你动手。
  • Pull 空投(拉取式) :项目方把代币放在 “公共领取池”,用户需要自己去 DApp / 合约里点击 “领取”,就像去快递柜取包裹,得自己动手输验证码。

四、核心差异

对比维度Push 空投(推送式)Pull 空投(拉取式)
触发方项目方主动发起用户主动领取
Gas 费承担全部由项目方支付由领取的用户自行承担
失败风险高(用户钱包异常 / 合约拒收会导致空投失败,Gas 费白耗)低(用户只在自己钱包正常时领取,无无效消耗)
操作复杂度项目方需遍历所有用户地址,代码逻辑简单但 Gas 成本高项目方仅部署领取合约,代码需设计领取权限,逻辑稍复杂但成本可控
到账时效即时到账(项目方发起后)用户领取后到账(可随时领)

五、优缺点与适用场景

Push 空投

优点:用户体验好(零操作),适合大规模快速分发,能快速提升代币持有用户数;

缺点:Gas 费成本极高(用户越多成本越高),易出现 “空投失败但 Gas 照扣” 的情况,无法过滤无效 / 休眠钱包;

适用场景:项目冷启动、高预算大规模空投(如蓝筹 NFT 向持有者发代币)、希望用户 “无感接收” 的场景。

Pull 空投

优点:项目方零 Gas 成本,仅承担合约部署费;能精准筛选活跃用户(休眠钱包不会领),避免资源浪费;

缺点:用户体验稍差(需手动操作),可能出现部分用户忘记领取的情况;

适用场景:中小项目低成本空投、Layer2 生态激励、需要筛选高价值活跃用户的场景。

六、核心结论

  • 项目方不差钱、追求用户体验 → 选 Push;
  • 项目方控成本、想筛选活跃用户 → 选 Pull;
  • 实操中很多项目会 “混合用”:对核心用户 Push(保证体验),对普通用户 Pull(控制成本)。

七、智能合约实操开发、测试、部署

代币合约

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.5.0
pragma solidity ^0.8.24;

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

contract BoykaYuriToken is ERC20, ERC20Burnable, Ownable, ERC20Permit {
    constructor(address recipient, address initialOwner)
        ERC20("MyToken", "MTK")
        Ownable(initialOwner)
        ERC20Permit("MyToken")
    {
        _mint(recipient, 1000000 * 10 ** decimals());
    }
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

生成Merkle Root的脚本

import { encodeAbiParameters, keccak256, parseEther, hexToBytes, concat } from 'viem';
import { MerkleTree } from 'merkletreejs';

// 1. 定义空投名单
const airdropList = [
  { address: 'Account1', amount: parseEther('100') },
  { address: 'Account2', amount: parseEther('200') },
];

// 2. 模拟合约逻辑计算叶子节点哈希
// 合约逻辑:keccak256(bytes.concat(keccak256(abi.encode(addr, amount))))
const leaves = airdropList.map((item) => {
  // 对应 abi.encode(address, uint256)
  const encoded = encodeAbiParameters(
    [{ type: 'address' }, { type: 'uint256' }],
    [item.address, item.amount]
  );
  
  // 对应 keccak256(abi.encode(...))
  const innerHash = keccak256(encoded);
  
  // 对应 keccak256(bytes.concat(innerHash))
  // 注意:innerHash 本身已经是 hex 字符串,需要转成 bytes 再拼接
  return keccak256(innerHash); 
});

// 3. 构建 Merkle Tree
// sortPairs: true 非常重要,必须与 OpenZeppelin 的 MerkleProof 库匹配
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });

// 4. 获取 Root
const root = tree.getHexRoot();
console.log('Merkle Root:', root);

// 5. 获取某个用户的 Proof (用于调用合约 claim 函数)
const leaf = leaves[0];
const proof = tree.getHexProof(leaf);
console.log('Proof for user 0:', proof);

Push模式空投

智能合约

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SimpleAirdrop is Ownable {
   using SafeERC20 for IERC20;

   // 构造函数:初始化时需指定管理员
   constructor(address initialOwner) Ownable(initialOwner) {}

   /**
    * @dev 批量空投代币
    * @param token 代币合约地址
    * @param recipients 接收者地址数组
    * @param amounts 对应每个接收者的金额数组
    */
   function multiSend(address token, address[] calldata recipients, uint256[] calldata amounts) external onlyOwner {
       require(recipients.length == amounts.length, "Lengths mismatch");
       IERC20 airDropToken = IERC20(token);

       for (uint256 i = 0; i < recipients.length; i++) {
           airDropToken.safeTransferFrom(msg.sender, recipients[i], amounts[i]);
       }
   }
}

合约部署

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
 // 连接网络
 const { viem } = await network.connect({ network: network.name });//指定网络进行链接
 
 // 获取客户端
 const [deployer] = await viem.getWalletClients();
 const publicClient = await viem.getPublicClient();

 const deployerAddress = deployer.account.address;
  console.log("部署者的地址:", deployerAddress);
 // 加载合约
 const tokenArtifact = await artifacts.readArtifact("BoykaYuriToken");

 // 部署(构造函数参数:recipient, initialOwner)
 const hash = await deployer.deployContract({
   abi: tokenArtifact.abi,//获取abi
   bytecode: tokenArtifact.bytecode,//硬编码
   args: [deployerAddress,deployerAddress],//process.env.RECIPIENT, process.env.OWNER
 });

 // 等待确认并打印地址
 const tokenReceipt = await publicClient.waitForTransactionReceipt({ hash });
 console.log("合约地址:", tokenReceipt.contractAddress);
 const SimpleAirdropArtifact = await artifacts.readArtifact("SimpleAirdrop");
 // 部署(构造函数参数:tokenAddress)
 const hash2 = await deployer.deployContract({
   abi: SimpleAirdropArtifact.abi,//获取abi
   bytecode: SimpleAirdropArtifact.bytecode,//硬编码
   args: [deployerAddress],//tokenAddress
 });
 // 等待确认并打印地址
 const SimpleAirdropReceipt = await publicClient.waitForTransactionReceipt({ hash:hash2 });
 console.log("合约地址:", SimpleAirdropReceipt.contractAddress);
}

main().catch(console.error);

合约测试

import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, formatEther } from 'viem';
import { network } from "hardhat";

describe("空投 测试", function () {
   let Token: any, SimpleAirdrop: any;
   let publicClient: any;
   let owner: any, user1: any, user2: any, user3: any;
   let deployerAddress: string;
   let testClient: any; // 增加 testClient

   const FAUCET_AMOUNT = parseEther("100"); // 合约默认单次领取 100 
   const Airdrop_AMOUNT = parseEther("200"); // 合约默认单次领取 200 
   const INITIAL_FAUCET_FUND = parseEther("1000"); // 预存 1000 个代币到水龙头

   beforeEach(async function () {
       // 连接 Hardhat 节点
       const { viem } = await network.connect();
       publicClient = await viem.getPublicClient();
       [owner, user1, user2,user3] = await viem.getWalletClients();
       deployerAddress = owner.account.address;
       testClient = await viem.getTestClient(); // 获取测试客户端
       // 1. 部署代币合约 (假设合约名为 BoykaYuriToken)
       Token = await viem.deployContract("BoykaYuriToken", [
           deployerAddress,
           deployerAddress
       ]);

       // 2. 部署空投合约 (假设合约名为 SimpleAirdrop)
       SimpleAirdrop = await viem.deployContract("SimpleAirdrop", [deployerAddress]);
       console.log(SimpleAirdrop.address)
       console.log(Token.address)
       // 3. 向空投合约
       // 空投合约授权给 SimpleAirdrop 合约
       await Token.write.approve([SimpleAirdrop.address, INITIAL_FAUCET_FUND], { account: owner.account });

       
   });

  it("应该成功向两个地址空投代币", async function () {
   const recipients = [user1.account.address, user2.account.address,user3.account.address];
   const amounts = [FAUCET_AMOUNT,FAUCET_AMOUNT, Airdrop_AMOUNT];

   // 执行空投
   await SimpleAirdrop.write.multiSend([Token.address, recipients, amounts]);
   console.log(await Token.read.balanceOf([user1.account.address]));
   console.log(await Token.read.balanceOf([user2.account.address]));
   console.log(await Token.read.balanceOf([user3.account.address]));
   console.log(FAUCET_AMOUNT,FAUCET_AMOUNT,Airdrop_AMOUNT)
   // 检查余额是否正确
   assert.equal(await Token.read.balanceOf([user1.account.address]), FAUCET_AMOUNT);
   assert.equal(await Token.read.balanceOf([user2.account.address]), FAUCET_AMOUNT);
   assert.equal(await Token.read.balanceOf([user3.account.address]), Airdrop_AMOUNT);
 });

 it("如果非管理员调用应该报错", async function () {
   const recipients = [user1.address];
   const amounts = [Airdrop_AMOUNT];

   // 使用 addr1 身份去调用会触发 OwnableUnauthorizedAccount
   try {
     await SimpleAirdrop.write.multiSend([Token.address, recipients, amounts], { account: user1.account })
   } catch (error) {
     console.log("非管理员调用报错")
   }
 });
});

Pull模式空投

智能合约

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

import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MerkleAirdrop is Ownable {
   bytes32 public immutable merkleRoot;
   IERC20 public immutable token;
   mapping(address => bool) public hasClaimed;

   constructor(address _token, bytes32 _merkleRoot, address _initialOwner) Ownable(_initialOwner) {
       token = IERC20(_token);
       merkleRoot = _merkleRoot;
   }

   function claim(uint256 amount, bytes32[] calldata proof) external {
       require(!hasClaimed[msg.sender], "Already claimed");

       // 验证叶子节点:keccak256(地址 + 金额)
       bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
       require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

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

合约部署

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
 // 连接网络
 const { viem } = await network.connect({ network: network.name });//指定网络进行链接
 
 // 获取客户端
 const [deployer] = await viem.getWalletClients();
 const publicClient = await viem.getPublicClient();

 const deployerAddress = deployer.account.address;
  console.log("部署者的地址:", deployerAddress);
 // 加载合约
 const tokenArtifact = await artifacts.readArtifact("BoykaYuriToken");

 // 部署(构造函数参数:recipient, initialOwner)
 const hash = await deployer.deployContract({
   abi: tokenArtifact.abi,//获取abi
   bytecode: tokenArtifact.bytecode,//硬编码
   args: [deployerAddress,deployerAddress],//process.env.RECIPIENT, process.env.OWNER
 });

 // 等待确认并打印地址
 const tokenReceipt = await publicClient.waitForTransactionReceipt({ hash });
 console.log("合约地址:", tokenReceipt.contractAddress);
 const MerkleAirdropArtifact = await artifacts.readArtifact("MerkleAirdrop");
 // 部署(构造函数参数:tokenAddress)
 // 借助./MerkleRoot.js生成merkleRoot
 const merkleRoot = "0xa6c83ad864006ecc8a66b3b32eeecdef376b742d1c6b46b8fd498b85eff26326";
 const hash2 = await deployer.deployContract({
   abi: MerkleAirdropArtifact.abi,//获取abi
   bytecode: MerkleAirdropArtifact.bytecode,//硬编码
   args: [tokenReceipt.contractAddress,merkleRoot,deployerAddress],//tokenAddress,merkleRoot,deployerAddress
 });
 // 等待确认并打印地址
 const MerkleAirdropReceipt = await publicClient.waitForTransactionReceipt({ hash:hash2 });
 console.log("合约地址:", MerkleAirdropReceipt.contractAddress);
}

main().catch(console.error);

合约测试

import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, encodeAbiParameters, keccak256 } from 'viem';
import { network } from "hardhat";
import { MerkleTree } from 'merkletreejs';

describe("MerkleAirdrop 空投测试", function () {
   let Token: any, MerkleAirdrop: any;
   let owner: any, user1: any, user2: any;
   let merkleTree: MerkleTree;
   let root: string;

   const AMOUNT1 = parseEther("100");
   const AMOUNT2 = parseEther("200");

   // 辅助函数:模拟 Solidity 中的双重哈希逻辑
   // keccak256(bytes.concat(keccak256(abi.encode(addr, amount))))
   const hashLeaf = (address: `0x${string}`, amount: bigint) => {
       const encoded = encodeAbiParameters(
           [{ type: 'address' }, { type: 'uint256' }],
           [address, amount]
       );
       const innerHash = keccak256(encoded);
       return keccak256(innerHash);
   };

   beforeEach(async function () {
       const { viem } = await network.connect();
       [owner, user1, user2] = await viem.getWalletClients();

       // 1. 构建 Merkle Tree
       const leaves = [
           hashLeaf(user1.account.address, AMOUNT1),
           hashLeaf(user2.account.address, AMOUNT2)
       ];
       // 必须设置 sortPairs: true 以匹配 OpenZeppelin 库
       merkleTree = new MerkleTree(leaves, keccak256, { sortPairs: true });
       root = merkleTree.getHexRoot();

       // 2. 部署代币
       Token = await viem.deployContract("BoykaYuriToken", [owner.account.address, owner.account.address]);

       // 3. 部署空投合约
       MerkleAirdrop = await viem.deployContract("MerkleAirdrop", [
           Token.address,
           root as `0x${string}`,
           owner.account.address
       ]);

       // 4. 向空投合约注入资金 (1000 Tokens)
       await Token.write.transfer([MerkleAirdrop.address, parseEther("1000")]);
   });

   it("用户1 应该能够凭正确的 Proof 领取代币", async function () {
       // 生成该用户的叶子节点和证明
       const leaf = hashLeaf(user1.account.address, AMOUNT1);
       const proof = merkleTree.getHexProof(leaf);

       // 使用 user1 身份调用 claim
       // 注意:viem 合约实例需用 .write.claim 调用
       await MerkleAirdrop.write.claim([AMOUNT1, proof], { account: user1.account });

       // 验证余额
       const balance = await Token.read.balanceOf([user1.account.address]);
       assert.equal(balance, AMOUNT1);

       // 验证 hasClaimed 状态
       const hasClaimed = await MerkleAirdrop.read.hasClaimed([user1.account.address]);
       assert.equal(hasClaimed, true);
   });

   it("如果使用错误的金额,领取应该失败", async function () {
       const leaf = hashLeaf(user1.account.address, AMOUNT1);
       const proof = merkleTree.getHexProof(leaf);

       // 尝试领取错误的金额 (AMOUNT2 而非白名单中的 AMOUNT1)
       await assert.rejects(
           () => MerkleAirdrop.write.claim([AMOUNT2, proof], { account: user1.account }),
           /Invalid proof/
       );
   });

   it("重复领取应该失败", async function () {
       const leaf = hashLeaf(user1.account.address, AMOUNT1);
       const proof = merkleTree.getHexProof(leaf);

       // 第一次领取
       await MerkleAirdrop.write.claim([AMOUNT1, proof], { account: user1.account });
        // 第二次领取报错
       try {
           await MerkleAirdrop.write.claim([AMOUNT1, proof], { account: user1.account }); 
       } catch (error) {
           console.log("重复领取报错:", error);
       }
   });
});

结语

至此,关于空投相关理论知识梳理以及相关代码实现已全部结束。本文通过对比 Push 与 Pull 两种模式的优劣,结合完整的开发、测试与部署流程,为你提供了一套开箱即用的空投解决方案。掌握这些知识,不仅能帮助你规避链上操作的常见陷阱,更能在项目冷启动阶段通过合理的代币分发机制实现用户增长。代码已备好,剩下的就看你的创意了!