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

  • 木西
  • 发布于 3天前
  • 阅读 49

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

前言

本文对 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 两种模式的优劣,结合完整的开发、测试与部署流程,为你提供了一套开箱即用的空投解决方案。掌握这些知识,不仅能帮助你规避链上操作的常见陷阱,更能在项目冷启动阶段通过合理的代币分发机制实现用户增长。代码已备好,剩下的就看你的创意了!
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
木西
木西
0x5D5C...2dD7
江湖只有他的大名,没有他的介绍。