大数跨境
0
0

代币合约中通过双向空投确认锁定推荐关系的合约代码实现

代币合约中通过双向空投确认锁定推荐关系的合约代码实现 老赵外贸严选
2025-09-23
12
导读:下面是一篇面向工程/生产的技术文章,完整说明如何在 ERC20 代币合约中用 “双向空投确认” 的方式锁定上下级推荐关系(即 A 向 B 空投任意数量代币,随后 B 向 A 空投任意数量代币,触发关系

下面是一篇面向工程/生产的技术文章,完整说明如何在 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 地址。

二、设计要点与决策理由

  1. 为何用双向空投?

单向空投容易被伪造或被机器人/闪电贷利用;双向确认提高了“当事人意愿”的强度(A 与 B 都主动参与一笔互转),减少误判或误绑的概率。

  1. 在合约内部实现(而非链外)有什么优点?

链上即时、可审计、不可篡改,查询简单;所有判定逻辑统一,避免前端或路由器绕过。

  1. 是否设置超时时间?

为避免 pending 状态长期占用并被后来任意人利用,合约支持可配置的 pendingExpiry(默认 7 天),A->B 产生 pending 后若 B 在有效期内未向 A 转账,则 pending 失效,需要重新建立。管理员(多签)可调整该参数。

  1. 不可改变性

一旦 referrer[B] 锁定为 A 后不可更改(除非治理/多签通过特殊函数人工修改;当前实现不提供修改接口),以保证链上关系一致性。

  1. root 地址

合约在构造时设定 root 地址(例如协议官方或 Burn 地址),查询 getReferrer(user) 时若没有 referrer 就返回 root。

三、行为流程(用户视角)

场景:用户 A 想成为用户 B 的一代上级

  1. A 用钱包把任意数量的 TokenA transfer 给 B(这步可称为“空投示意”)。合约记录 pendingFrom[B] = A 并标记时间戳。合约发事件 PendingReferral(A->B)。

  2. B 在有效期内把任意数量的 TokenA transfer(或 transferFrom)给 A。合约检测到 pendingFrom[B] == A 且未过期,于是把 referrer[B] = A(永久锁定),并把 B 添加到 A 的下级列表。触发事件 ReferralLocked(A <- B)。

  3. 之后调用 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 建议

  1. 前端应该在发起“希望成为上级”或“想绑定上级”时提示用户正确流程,并在钱包里发起两笔互转示例交易:

“A 空投给 B” 端:前端提示 A 向 B 转账,金额可低(避免资金成本);

“B 空投回 A” 端:指导 B 向 A 转账。

  1. 若要让 UX 更友好,可以在前端做预绑定(仅 UI 层面)并生成要发送的两笔签名交易供双方逐笔签署并广播。

  2. 在前端显示 pending 状态(通过 getPendingFrom 及 pendingTimestamp),并提示剩余有效期。

  3. 为防止链上垃圾或刷榜,建议配合防刷规则:例如限制一个地址在短时间内能设置多少 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(本合约未提供直接覆盖接口以减少滥用)。


图片
扫描二维码关注我们

本公众号发布的内容除特别标明外版权归原作者所有。若涉及版权问题,请联系我们。所有信息及评论区内容仅供参考,请读者自行判断信息真伪,不构成任何投资建议。据此产生的任何损失,本公众号概不负责,亦不负任何法律责任。


【声明】内容源于网络
0
0
老赵外贸严选
跨境分享馆 | 持续分享跨境资讯
内容 39488
粉丝 0
老赵外贸严选 跨境分享馆 | 持续分享跨境资讯
总阅读230.9k
粉丝0
内容39.5k