下面是一篇面向工程/生产的技术文章,完整说明如何在 ERC20 代币合约中用 “双向空投确认” 的方式锁定上下级推荐关系(即 A 向 B 空投任意数量代币,随后 B 向 A 空投任意数量代币,触发关系锁定:A 成为 B 的一代上级)。文末附上可直接编译的 Solidity 合约源码,并解释每个模块、边界情况与安全注意点。
一、方案概述(一句话)
用代币合约本身监听 token 转账事件:当 A 给 B 转账(amount>0)时,标记 B 的“pending 上级”为 A;当之后 B 给 A 转账(amount>0)并且 pending 仍为 A(且在允许的有效期内),就把 B 的上级永久锁定为 A。若某用户没有被锁定上级,则查询时返回合约中预先设置的 root 地址。
二、设计要点与决策理由
-
为何用双向空投?
单向空投容易被伪造或被机器人/闪电贷利用;双向确认提高了“当事人意愿”的强度(A 与 B 都主动参与一笔互转),减少误判或误绑的概率。
-
在合约内部实现(而非链外)有什么优点?
链上即时、可审计、不可篡改,查询简单;所有判定逻辑统一,避免前端或路由器绕过。
-
是否设置超时时间?
为避免 pending 状态长期占用并被后来任意人利用,合约支持可配置的 pendingExpiry(默认 7 天),A->B 产生 pending 后若 B 在有效期内未向 A 转账,则 pending 失效,需要重新建立。管理员(多签)可调整该参数。
-
不可改变性
一旦 referrer[B] 锁定为 A 后不可更改(除非治理/多签通过特殊函数人工修改;当前实现不提供修改接口),以保证链上关系一致性。
-
root 地址
合约在构造时设定 root 地址(例如协议官方或 Burn 地址),查询 getReferrer(user) 时若没有 referrer 就返回 root。
三、行为流程(用户视角)
场景:用户 A 想成为用户 B 的一代上级
-
A 用钱包把任意数量的 TokenA transfer 给 B(这步可称为“空投示意”)。合约记录 pendingFrom[B] = A 并标记时间戳。合约发事件 PendingReferral(A->B)。
-
B 在有效期内把任意数量的 TokenA transfer(或 transferFrom)给 A。合约检测到 pendingFrom[B] == A 且未过期,于是把 referrer[B] = A(永久锁定),并把 B 添加到 A 的下级列表。触发事件 ReferralLocked(A <- B)。
-
之后调用 getReferrer(B) 将返回 A;若未锁定返回 root。
四、实现要点(高层)
-
放在代币合约里,_afterTokenTransfer(from, to, amount) 覆盖/钩子中检测转账,忽略 mint(from == address(0))或 burn(to == address(0))场景。
数据结构:
-
mapping(address => address) public referrer; // 已锁定的上级(0 表示未锁定)
-
mapping(address => address) public pendingFrom; // 被谁给过一次(待确认)
-
mapping(address => uint256) public pendingTimestamp; // pending 产生时戳(用于过期判断)
-
mapping(address => addres) public referrals; // 便于查询某人所有直接下级
事件:PendingReferral(from,to), ReferralLocked(leader, follower)。
管理:owner(建议 multi-sig)能设置 root、修改 pendingExpiry、回收误发 token 等。避免单人权限风险请用多签。
五、合约源码(Solidity 0.8.x,可直接用 OpenZeppelin 编译)
说明:下方合约实现了 ERC20 代币 + 双向空投锁定推荐关系 + 查询接口 + 管理功能。请在生产部署前由多人签名治理,并做安全审计、Gas 测试与主网 fork 测试。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/*
TokenWithMutualAirdropReferral.sol
功能:
- 标准 ERC20(OpenZeppelin)
- 在每次转账后检测并实现“双方空投确认 -> 锁定上级关系”逻辑
- 支持 pending 过期(可配置)
- 提供查询接口 getReferrer (若无上级返回 root)
- 提供 referrals 列表查看直接下级
- 管理员(owner)可设置 root、pending expiry、recover tokens
注意:
- 请把 owner 转移到多签 (Gnosis Safe) 后再运营
- 一旦锁定的 referrer 在本合约逻辑上不可被覆盖
*/
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract TokenWithMutualAirdropReferral is ERC20, Ownable {
using SafeERC20 for IERC20;
// root address returned when user has no referrer
address public root;
// pending expiry seconds (default 7 days)
uint256 public pendingExpirySeconds = 7 days;
// referrer mapping: user => their locked referrer (0 if none locked)
mapping(address => address) private _referrer;
// pending mapping: when A -> B occurs, pendingFrom[B] = A (if B not yet locked)
mapping(address => address) private _pendingFrom;
// pending timestamp: time when pendingFrom was set
mapping(address => uint256) private _pendingTimestamp;
// referrals list: leader => list of direct referrals (followers)
mapping(address => address[]) private _referrals;
// events
event PendingReferral(address indexed from, address indexed to, uint256 timestamp);
event ReferralLocked(address indexed leader, address indexed follower, uint256 timestamp);
event RootChanged(address indexed oldRoot, address indexed newRoot);
event PendingExpiryChanged(uint256 oldExpiry, uint256 newExpiry);
constructor(
string memory name_,
string memory symbol_,
address rootAddress_,
uint256 initialSupply // initial mint to owner
) ERC20(name_, symbol_) {
require(rootAddress_ != address(0), "root zero");
root = rootAddress_;
if (initialSupply > 0) {
// mint to owner
_mint(msg.sender, initialSupply);
}
}
// -----------------------------
// Public view helpers
// -----------------------------
/**
* @notice Get locked referrer (if none, returns root)
*/
function getReferrer(address user) public view returns (address) {
address r = _referrer[user];
if (r == address(0)) return root;
return r;
}
/**
* @notice Get pendingFrom (who last sent tokens to this user and is waiting for reciprocal transfer)
* returns address(0) if none or expired (note: view cannot detect expiry mutation; callers should check timestamp)
*/
function getPendingFrom(address user) public view returns (address) {
address p = _pendingFrom[user];
if (p == address(0)) return address(0);
// check expiry
uint256 ts = _pendingTimestamp[user];
if (block.timestamp > ts + pendingExpirySeconds) {
return address(0);
}
return p;
}
/**
* @notice Return number of direct referrals for a leader
*/
function getReferralCount(address leader) external view returns (uint256) {
return _referrals[leader].length;
}
/**
* @notice Return direct referral at index
*/
function getReferralAt(address leader, uint256 index) external view returns (address) {
require(index < _referrals[leader].length, "index out");
return _referrals[leader][index];
}
// -----------------------------
// Owner / governance functions
// -----------------------------
function setRoot(address newRoot) external onlyOwner {
require(newRoot != address(0), "zero root");
address old = root;
root = newRoot;
emit RootChanged(old, newRoot);
}
function setPendingExpirySeconds(uint256 secs) external onlyOwner {
require(secs > 0, "must>0");
uint256 old = pendingExpirySeconds;
pendingExpirySeconds = secs;
emit PendingExpiryChanged(old, secs);
}
/**
* @notice Emergency recover ERC20 mistakenly sent to this contract
* @param token token address
* @param to destination
* @param amount amount
*/
function recoverERC20(address token, address to, uint256 amount) external onlyOwner {
require(to != address(0), "zero to");
IERC20(token).safeTransfer(to, amount);
}
// -----------------------------
// Referral core logic inside transfer hook
// -----------------------------
/**
* Override _afterTokenTransfer to inspect transfers and update referral states.
*
* Logic:
* - Ignore mint (from == address(0)) and burn (to == address(0)).
* - If from == to or amount == 0, ignore.
* - If recipient already has a locked referrer -> do nothing (cannot overwrite).
* - If recipient has no pendingFrom:
* set pendingFrom[recipient] = from, pendingTimestamp = now, emit PendingReferral
* - Else if recipient.pendingFrom == from: (means from previously sent to recipient and pending still active)
* -> if now transfer is from recipient -> from ? Wait: we must detect reciprocal direction.
* However we need to detect **A->B then B->A** pattern.
*
* Implementation:
* - When transfer happens (from => to):
* 1) If referrer[to] == 0 and _pendingFrom[to] == address(0): set _pendingFrom[to] = from
* 2) If referrer[to] == 0 and _pendingFrom[to] == from and amount > 0 and also (from == _pendingFrom[to] ? no, need B->A):
* Actually we need to check reciprocal transfer: when transfer is (from => to), check if _pendingFrom[from] == to.
* Because A->B sets pendingFrom[B] = A; then B->A transfer will be caught when from == B and to == A -> at that moment _pendingFrom[from] == A (true) and to == A matches; so we lock.
*/
function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
super._afterTokenTransfer(from, to, amount);
// ignore mint/burn
if (from == address(0) || to == address(0)) return;
if (from == to) return;
if (amount == 0) return;
// if `to` already has a locked referrer, nothing to dofor to
if (_referrer[to] == address(0)) {
// if there is no pendingFrom[to] OR pending expired -> set pendingFrom[to] = from
address currPending = _pendingFrom[to];
uint256 ts = _pendingTimestamp[to];
if (currPending == address(0) || block.timestamp > ts + pendingExpirySeconds) {
// only setif'from' is not the same as 'to', and not the same as root maybe allowed
if (from != to) {
_pendingFrom[to] = from;
_pendingTimestamp[to] = block.timestamp;
emit PendingReferral(from, to, block.timestamp);
}
}
}
// Now check reciprocal: if `_pendingFrom[from] == to` and from has no locked referrer, then lock from's referrer = to
// Why? Because earlier to -> from happened, setting pendingFrom[from] = to; now from -> to (this transfer) completes reciprocal? Wait:
// Our desired pattern: A -> B (sets pendingFrom[B] = A), then B -> A (when from == B and to == A) we want to lock referrer[B] = A.
// When B -> A occurs, `from == B`. So check _pendingFrom[from] == to => _pendingFrom[B] == A true, so lock `_referrer[from] = to` (i.e. referrer[B] = A).
// This is correct.
if (_referrer[from] == address(0)) {
address pending = _pendingFrom[from];
if (pending != address(0)) {
// pending must be equal to 'to' to be reciprocal
if (pending == to) {
// check expiry
uint256 pTs = _pendingTimestamp[from];
if (block.timestamp <= pTs + pendingExpirySeconds) {
// Lock referral: to becomes referrer (leader) of from (follower)
_referrer[from] = to;
_referrals[to].push(from);
// clear pending
delete _pendingFrom[from];
delete _pendingTimestamp[from];
emit ReferralLocked(to, from, block.timestamp);
} else {
// pending expired: clear and set a new pending for `to`? we handle above on next transfer
delete _pendingFrom[from];
delete _pendingTimestamp[from];
}
}
}
}
}
// -----------------------------
// Additional helpers (optional)
// -----------------------------
/**
* @notice Returns direct referral list of a leader (careful gas when large)
*/
function getReferrals(address leader) external view returns (address[] memory) {
return _referrals[leader];
}
/**
* @notice Check whether a user has locked referrer (true means locked and not root)
*/
function hasLockedReferrer(address user) external view returns (bool) {
return _referrer[user] != address(0);
}
}
六、合同中关键实现细节说明(逐项解释)
_afterTokenTransfer:OpenZeppelin ERC20 在每次转账、mint、burn 后会回调此 hook。我们在该 hook 中实现所有 referral 逻辑,因而所有 transfer 与 transferFrom(以及由合约发起的转账)都会受逻辑约束。
pending 逻辑:
第一次 A->B:_pendingFrom[B] = A,并记录时间戳;触发事件 PendingReferral(A,B)。
第二次 B->A(在有效期内):在 B->A 的转账中,from==B,合约会检测 _pendingFrom[from] == to(即 _pendingFrom[B] == A),满足条件后把 _referrer[B] = A,并 push 到 _referrals[A]。触发事件 ReferralLocked(A,B)。
过期机制:pendingExpirySeconds 默认 7 天;若过期则 pending 无效,需重新开始流程。此值可通过 setPendingExpirySeconds(owner)调整。
root:当 _referrer[user] 为 0 时 getReferrer 返回 root(必要时 root 可是协议地址或 burn 地址)。owner 可更新 root。
recoverERC20:用于回收误发到合约的 token(需要多签治理)。
七、使用与前端 / UX 建议
-
前端应该在发起“希望成为上级”或“想绑定上级”时提示用户正确流程,并在钱包里发起两笔互转示例交易:
“A 空投给 B” 端:前端提示 A 向 B 转账,金额可低(避免资金成本);
“B 空投回 A” 端:指导 B 向 A 转账。
-
若要让 UX 更友好,可以在前端做预绑定(仅 UI 层面)并生成要发送的两笔签名交易供双方逐笔签署并广播。
-
在前端显示 pending 状态(通过 getPendingFrom 及 pendingTimestamp),并提示剩余有效期。
-
为防止链上垃圾或刷榜,建议配合防刷规则:例如限制一个地址在短时间内能设置多少 pending、给合约添加最小空投金额门槛(在合约里可轻松加入 minAirdropAmount)。
八、边界条件、攻击向量与缓解
机器人/批量刷关系:攻击者用大量地址互相空投绑定大量下级。缓解:引入 minAirdropAmount、对每个地址绑定次数限定或人类 KYC 后解锁高级功能。
绕过合约的交易:因为逻辑在代币合约内部实现,任何对该代币的 transfer 都会触发检测,无法绕过(这是优点)。
合约地址与EOA:若某合约地址转账,会被当作普通地址处理,可能出现“合约与人互转”也能绑定关系——若不希望合约作为 referrer,可在 _afterTokenTransfer 中检测 to/from 是否为合约(to.isContract()),并拒绝将合约作为 referrer(需引入 Address 库)。目前合约允许合约参与,你可根据需求修改。
pending 被抢:A->B 产生 pending 后,若有别的地址 C 在 B 未回复情况下对 B 做出转账(C->B),会把 pendingFrom[B] 置为 C(覆盖 A 的 pending)。为避免替换,合约可以选择:一旦首次 pendingFrom 产生就不允许被覆盖直到过期;当前实现就是先检查 currPending == address(0) || expired 才设置,因此不会覆盖未过期的 pending(见实现)。这降低了被攻击的风险。
时间同步问题:pending 的有效期以链上区块时间为准(block.timestamp),请注意该时间可被矿工在小幅度操控;但短窗口内无大风险。
一旦锁定不能改:设计为不可改变(避免上级被随意覆盖)。若需要治理变更,建议由多签在外部注册特殊脚本来调用 recover/modify(本合约未提供直接覆盖接口以减少滥用)。
❝本公众号发布的内容除特别标明外版权归原作者所有。若涉及版权问题,请联系我们。所有信息及评论区内容仅供参考,请读者自行判断信息真伪,不构成任何投资建议。据此产生的任何损失,本公众号概不负责,亦不负任何法律责任。

