XEN 合约代码深入解读

这两天 XEN 特别火,看了看代码,相对比较简单。这篇文章就来结合文档来解读一下合约代码,仅做学习交流用。对于玩法还不熟悉的朋友可以先看看我昨天发的推文

整个玩法分成两部分,我这里将其区别为:

  1. 时间挖矿(claim to mint),也就是在参与时指定时间,时间到期后即可领取对应的 XEN,唯一付出的成本就是 gas 费用和等待的时间

  2. stake 挖矿,通过质押 XEN 来挖矿

时间挖矿

先来看第一部分,时间挖矿。用户通过调用 claimRank(uint256 term) 来参与,term 代表用户想要挖矿的天数,在这个时间到期之后才能领取 XEN 奖励。

function claimRank(uint256 term) external {
    // SECONDS_IN_DAY = 3_600 * 24;
    // 将天数转化为秒数
    uint256 termSec = term * SECONDS_IN_DAY;
    // 最短挖一天
    // MIN_TERM = 1 * SECONDS_IN_DAY - 1;
    require(termSec > MIN_TERM, "CRank: Term less than min");
    // 最长可参与的天数需要根据参与人数实时计算
    require(termSec < _calculateMaxTerm() + 1, "CRank: Term more than current max term");
    require(userMints[_msgSender()].rank == 0, "CRank: Mint already in progress");

    // create and store new MintInfo
    MintInfo memory mintInfo = MintInfo({
        user: _msgSender(),
        term: term,
        maturityTs: block.timestamp + termSec,
        rank: globalRank,
        amplifier: _calculateRewardAmplifier(),
        eaaRate: _calculateEAARate()
    });
    userMints[_msgSender()] = mintInfo;
    activeMinters++;
    emit RankClaimed(_msgSender(), term, globalRank++);
}

其中全局变量 globalRank 代表的是全局参与的总人数,只增不减。activeMinters 代表正在参与挖矿的人数,当用户参与时间挖矿时增加 1,到期领取奖励后减少 1userMints 代表用户的挖矿参数。我们看到,这里最短需要参与一天,最多参与的天数是通过 _calculateMaxTerm() 实时计算出来的。

function _calculateMaxTerm() private view returns (uint256) {
    // TERM_AMPLIFIER_THRESHOLD = 5_000;
    // TERM_AMPLIFIER = 15;
    // MAX_TERM_START = 100 * SECONDS_IN_DAY;
    // MAX_TERM_END = 1_000 * SECONDS_IN_DAY;
    if (globalRank > TERM_AMPLIFIER_THRESHOLD) {
        // 如果参与的人数大于 5000
        // globalRank.fromUInt(),先将 globalRank 转换为 int128 类型
        // 然后再对其进行对数 log_2() 计算
        // 即 log_2_globalRank * 15
        uint256 delta = globalRank.fromUInt().log_2().mul(TERM_AMPLIFIER.fromUInt()).toUInt();
        // newMax = 100 天 + delta 天
        uint256 newMax = MAX_TERM_START + delta * SECONDS_IN_DAY;
        // 取 1000 天 和 newMax 的最小值,也就是最多只能挖 1000 天
        return Math.min(newMax, MAX_TERM_END);
    }
    // 如果参与的人数没有超过 5000,则最大只能挖 100 天
    return MAX_TERM_START;
}

首先如果全部参与人数没有超过 5000,那么最多只能挖 100 天。如果达到了 5000,通过对参与人数进行对数运算,计算出对应的最大天数。对应于文档中的:

最大参与时间计算公式
最大参与时间计算公式

代码中的 fromUInt()log_2() 都来自于 ABDKMath64x64 库(代码)。其中 fromUInt() 的代码:

function fromUInt (uint256 x) internal pure returns (int128) {
    unchecked {
        require (x <= 0x7FFFFFFFFFFFFFFF);
        return int128 (int256 (x << 64));
    }
}

入参 x 有限制,这个最大值转换为 10 进制是 9223372036854775807,全部参与人数不可能超过这个数,所有可以安全使用。

在构造的挖矿系数 mintInfo 中,_calculateRewardAmplifier()_calculateEAARate() 也是实时计算的。

function _calculateRewardAmplifier() private view returns (uint256) {
    // genesisTs 是合约部署时间,也就是初始时间
    // 这里是计算出距离开始时间过去了多少天
    uint256 amplifierDecrease = (block.timestamp - genesisTs) / SECONDS_IN_DAY;
    // REWARD_AMPLIFIER_START = 3000
    // REWARD_AMPLIFIER_END = 1;
    if (amplifierDecrease < REWARD_AMPLIFIER_START) {
        // 如果是在 3000 天以内
        return Math.max(REWARD_AMPLIFIER_START - amplifierDecrease, REWARD_AMPLIFIER_END);
    } else {
        // 如果超过 3000 天,则使用 1
        return REWARD_AMPLIFIER_END;
    }
}

可以看到,越早参与,可以获得到的 AMP 就越多,最开始一天是 3000,每过一天会减少 1,最终超过 3000 天就会恒定为 1

对应于文档中 AMP 的计算方式:

AMP 计算公式
AMP 计算公式
function _calculateEAARate() private view returns (uint256) {
    // EAA_PM_STEP = 1
    // EAA_RANK_STEP = 100000
    uint256 decrease = (EAA_PM_STEP * globalRank) / EAA_RANK_STEP;
    // EAA_PM_START = 100
    // 也就是说,如果参与人数大于 1000 万,返回 0
    if (decrease > EAA_PM_START) return 0;
    // 否则返回 100 - 人数 / 100000
    return EAA_PM_START - decrease;
}

100 开始,每当有 十万人 参与时,下降 1,最终若达到 一千万人 参与,则恒定为 0。同样也是越早参与越好。对应于文档:

EAA 计算公式
EAA 计算公式

由于 Solidity 中没有小数,因此在代码中将其放大了 1000 倍,后面在 getGrossReward 方法中会再缩小 1000 倍。

到这里,我们可以看到,在用户参与时间挖矿时,已经确定的数据有

  1. 用户在全局中的位置(rank

  2. 参与时长(term),由用户在参与时指定

  3. AMP,越早参与越大

  4. EAA,越早参与越大

接下来我们来看用户领取奖励时的方法 claimMintReward()

function claimMintReward() external {
    // 获取用户的挖矿系数
    MintInfo memory mintInfo = userMints[_msgSender()];
    require(mintInfo.rank > 0, "CRank: No mint exists");
    // 要求满足时间限制
    require(block.timestamp > mintInfo.maturityTs, "CRank: Mint maturity not reached");

    // calculate reward and mint tokens
    uint256 rewardAmount = _calculateMintReward(
        mintInfo.rank,
        mintInfo.term,
        mintInfo.maturityTs,
        mintInfo.amplifier,
        mintInfo.eaaRate
    ) * 1 ether;
    _mint(_msgSender(), rewardAmount);

    // 清除掉用户的挖矿数据
    _cleanUpUserMint();
    emit MintClaimed(_msgSender(), rewardAmount);
}

校验限制后,计算可得奖励数量,然后 _mint 给用户,计算奖励数量的主要计算逻辑在 _calculateMintReward() 中:

function _calculateMintReward(
    uint256 cRank,
    uint256 term,
    uint256 maturityTs,
    uint256 amplifier,
    uint256 eeaRate
) private view returns (uint256) {
    // 计算当前时间与到期时间的差值
    uint256 secsLate = block.timestamp - maturityTs;
    // 根据时间差值计算需要扣除的数量
    uint256 penalty = _penalty(secsLate);
    // 计算用户后面还有多少人参与游戏
    uint256 rankDelta = Math.max(globalRank - cRank, 2);
    uint256 EAA = (1_000 + eeaRate);
    uint256 reward = getGrossReward(rankDelta, amplifier, term, EAA);
    return (reward * (100 - penalty)) / 100;
}

function getGrossReward(
    uint256 rankDelta,
    uint256 amplifier,
    uint256 term,
    uint256 eaa
) public pure returns (uint256) {
    // log_2_rankDelta
    int128 log128 = rankDelta.fromUInt().log_2();
    int128 reward128 = log128.mul(amplifier.fromUInt()).mul(term.fromUInt()).mul(eaa.fromUInt());
    return reward128.div(uint256(1_000).fromUInt()).toUInt();
}

这里我们先忽略 penalty 这一块,其他部分的计算正好对应于文档中的:

时间挖矿奖励数量计算公式
时间挖矿奖励数量计算公式

在计算最终奖励数量的时候,自己参与的位置越靠前,后面的人越多,那么

cRG - cRu

就会越大,同样说明越早参与越好。

我们再来看 penalty 这部分,这块其实就是系统限制用户必须在到期后一定时间内领取走,如果没有领取则会随着时间越来越少,最终归零。

function _penalty(uint256 secsLate) private pure returns (uint256) {
    // =MIN(2^(daysLate+3)/window-1,99)
    // 计算这个差值有多少天
    uint256 daysLate = secsLate / SECONDS_IN_DAY;
    // WITHDRAWAL_WINDOW_DAYS = 7
    // 如果这个差值在七天及以上,返回 99
    if (daysLate > WITHDRAWAL_WINDOW_DAYS - 1) return MAX_PENALTY_PCT;
    // 也就是 2 ^ (daysLate + 3) / 7 - 1
    uint256 penalty = (uint256(1) << (daysLate + 3)) / WITHDRAWAL_WINDOW_DAYS - 1;
    return Math.min(penalty, MAX_PENALTY_PCT);
}

对应于文档中的扣除比例:

扣除比例时间关系
扣除比例时间关系

文档中显示超过七天就全部不能领取,但是代码中显示最多只会扣除 99%

到这里,我们就介绍完了时间挖矿的代码部分,接下来我们来看看 stake 挖矿的部分。

stake 挖矿

这里的 stake 其实比常见的挖矿计算逻辑要简单。常见的挖矿 APY 是根据用户质押数量占比以及参与时间来计算的,属于随挖随走类型的。而这里的 stake 挖矿的 APY 在参与时就已经固定了,且需要在参与时就指定参与时间,在时间到期后才能领取奖励,如果没有到期就领取,只能取回本金,没有任何的奖励。

用户可以在前面时间挖矿到期时调用 claimMintRewardAndStake 同时领取奖励并进行 stake,或者单独调用 stake(uint256 amount, uint256 term) 进行 stake 挖矿:

function stake(uint256 amount, uint256 term) external {
    require(balanceOf(_msgSender()) >= amount, "XEN: not enough balance");
    // XEN_MIN_STAKE = 0
    require(amount > XEN_MIN_STAKE, "XEN: Below min stake");
    // 最少 stake 1 天,最多 stake 1000 天
    require(term * SECONDS_IN_DAY > MIN_TERM, "XEN: Below min stake term");
    require(term * SECONDS_IN_DAY < MAX_TERM_END + 1, "XEN: Above max stake term");
    require(userStakes[_msgSender()].amount == 0, "XEN: stake exists");

    // burn staked XEN
    // 直接 burn,而不是从用户手里转账
    _burn(_msgSender(), amount);
    // create XEN Stake
    _createStake(amount, term);
    emit Staked(_msgSender(), amount, term);
}

function _createStake(uint256 amount, uint256 term) private {
    userStakes[_msgSender()] = StakeInfo({
        term: term,
        maturityTs: block.timestamp + term * SECONDS_IN_DAY,
        amount: amount,
        apy: _calculateAPY()
    });
    activeStakes++;
    totalXenStaked += amount;
}

整体的逻辑也比较简单,参与的时候需要指定时间 term。有一个小细节是在 stake 的时候直接 burn 掉了用户的 token,而不是通过转账的方法,这样可以少一步授权操作。由于合约本身既包含了挖矿操作,同时也是 ERC20,因此可以实现这个逻辑。

接下来我们看看计算 APY 的方法 _calculateAPY()

function _calculateAPY() private view returns (uint256) {
    // 这里 genesisTs 是合约部署时间
    // (SECONDS_IN_DAY * XEN_APY_DAYS_STEP) -> 86400 * 90,也就是 90 天
    // 也就是计算,当前时间距离最开始的合约部署时间有多少个 90 天的差距
    uint256 decrease = (block.timestamp - genesisTs) / (SECONDS_IN_DAY * XEN_APY_DAYS_STEP);
    // XEN_APY_START = 20
    // XEN_APY_END = 2
    // 如果 decrease 差值大于 (20 - 2),也就是上面的时间差值大于 18 * 90 = 1620 天
    // 则返回 2
    if (XEN_APY_START - XEN_APY_END < decrease) return XEN_APY_END;
    // 否则返回 20 - decrease
    return XEN_APY_START - decrease;
}

基本逻辑也是类似于上面计算 EAA 的方法,一次函数递减,参与的时间越早,相对应的 APY 就越大。初始值为 20,每过 90 天,减少 1。最终在 1620 天后,恒定为 2。对应于文档:

APY 时间关系
APY 时间关系

最终在 stake 到期后,可以调用 withdraw() 取出本金和奖励:

function withdraw() external {
    StakeInfo memory userStake = userStakes[_msgSender()];
    require(userStake.amount > 0, "XEN: no stake exists");

    uint256 xenReward = _calculateStakeReward(
        userStake.amount,
        userStake.term,
        userStake.maturityTs,
        userStake.apy
    );
    activeStakes--;
    totalXenStaked -= userStake.amount;

    // mint staked XEN (+ reward)
    _mint(_msgSender(), userStake.amount + xenReward);
    emit Withdrawn(_msgSender(), userStake.amount, xenReward);
    delete userStakes[_msgSender()];
}

其中计算奖励数量的方法 _calculateStakeReward()

function _calculateStakeReward(
    uint256 amount,
    uint256 term,
    uint256 maturityTs,
    uint256 apy
) private view returns (uint256) {
    if (block.timestamp > maturityTs) {
        // DAYS_IN_YEAR = 365
        uint256 rate = (apy * term * 1_000_000) / DAYS_IN_YEAR;
        return (amount * rate) / 100_000_000;
    }
    return 0;
}

对应于文档中的:

stake 奖励计算公式
stake 奖励计算公式

对于 stake 挖矿而言,没有领取的限制,奖励数量不会变化。

总结

到这里我们就看完了主要的逻辑代码。这个玩法有意思的地方在于越早参与获得的奖励越多,相当于普通的挖头矿,但是同时也取决于总体的参与人数,如果后面没有人参与,那么也没啥意义。必须是参与的早且后面还有更多人参与的情况下,奖励才会更多。目前时刻总参与人数已经快达到 50 万了,热度确实很高。

同时,前面的时间挖矿和后面的 stake 挖矿也存在博弈关系,如果前面选择的时间越长,获得的奖励就越多,但是来到后面的 stake 挖矿的 APY 就会降低,需要大家自行抉择。

合约本身代码没啥难度,但是整体机制比较有趣,值得花点时间了解。

关于我

欢迎和我交流

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.