前言本文对Web3空投机制进行了系统性的理论梳理,并深入探讨了两种主流分发模式——Push(推送式)与Pull(拉取式)的底层逻辑。在此基础上,提供了完整的智能合约开发指南,涵盖了从核心逻辑编写、单元测试验证到主网部署的全流程实操演示,旨在为开发者提供一套可落地的空投解决方案。一、空
本文对 Web3 空投机制进行了系统性的理论梳理,并深入探讨了两种主流分发模式 ——Push(推送式)与 Pull(拉取式)的底层逻辑。在此基础上,提供了完整的智能合约开发指南,涵盖了从核心逻辑编写、单元测试验证到主网部署的全流程实操演示,旨在为开发者提供一套可落地的空投解决方案。
一、空投核心定义与本质
Web3 空投是项目方的战略性代币分发策略,通过智能合约将资产直接转入用户钱包,无需用户支付对价(部分类型需承担 Gas 费),本质是项目冷启动、用户激励与去中心化治理的工具,全程链上可追溯与验证。
| 目的 | 说明 |
|---|---|
| 拉新促活 | 吸引新用户完成链上交互、下载钱包,扩大曝光与用户基数,激励老用户参与社区 |
| 奖励早期用户 | 回馈测试网参与者、社区创作者、流动性提供者等,强化认同感与忠诚度 |
| 分散代币所有权 | 避免代币集中于项目方 / 机构,优化分配结构,为去中心化治理奠基 |
| 分发治理代币 | 赋予持有者提案投票、参数修改等权利,推动 DAO 化发展 |
| 产品测试与反馈 | 通过测试网空投收集真实交互数据与问题反馈,优化主网功能 |
| 对比维度 | Push 空投(推送式) | Pull 空投(拉取式) |
|---|---|---|
| 触发方 | 项目方主动发起 | 用户主动领取 |
| Gas 费承担 | 全部由项目方支付 | 由领取的用户自行承担 |
| 失败风险 | 高(用户钱包异常 / 合约拒收会导致空投失败,Gas 费白耗) | 低(用户只在自己钱包正常时领取,无无效消耗) |
| 操作复杂度 | 项目方需遍历所有用户地址,代码逻辑简单但 Gas 成本高 | 项目方仅部署领取合约,代码需设计领取权限,逻辑稍复杂但成本可控 |
| 到账时效 | 即时到账(项目方发起后) | 用户领取后到账(可随时领) |
优点:用户体验好(零操作),适合大规模快速分发,能快速提升代币持有用户数;
缺点:Gas 费成本极高(用户越多成本越高),易出现 “空投失败但 Gas 照扣” 的情况,无法过滤无效 / 休眠钱包;
适用场景:项目冷启动、高预算大规模空投(如蓝筹 NFT 向持有者发代币)、希望用户 “无感接收” 的场景。
优点:项目方零 Gas 成本,仅承担合约部署费;能精准筛选活跃用户(休眠钱包不会领),避免资源浪费;
缺点:用户体验稍差(需手动操作),可能出现部分用户忘记领取的情况;
适用场景:中小项目低成本空投、Layer2 生态激励、需要筛选高价值活跃用户的场景。
代币合约
// 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 两种模式的优劣,结合完整的开发、测试与部署流程,为你提供了一套开箱即用的空投解决方案。掌握这些知识,不仅能帮助你规避链上操作的常见陷阱,更能在项目冷启动阶段通过合理的代币分发机制实现用户增长。代码已备好,剩下的就看你的创意了! 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!