# XEN 合约代码深入解读

By [xyyme.eth](https://paragraph.com/@xyyme) · 2022-10-10

---

这两天 XEN 特别火，看了看[代码](https://etherscan.io/address/0x06450dee7fd2fb8e39061434babcfc05599a6fb8#code)，相对比较简单。这篇文章就来结合文档来解读一下合约代码，仅做学习交流用。对于玩法还不熟悉的朋友可以先看看我昨天发的[推文](https://twitter.com/xyymeeth/status/1579107974722637824?s=20&t=WiWjzyzI9usqCeteCWQmHA)。

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

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`，到期领取奖励后减少 `1`。`userMints` 代表用户的挖矿参数。我们看到，这里最短需要参与一天，最多参与的天数是通过 `_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，通过对参与人数进行对数运算，计算出对应的最大天数。对应于文档中的：

![最大参与时间计算公式](https://storage.googleapis.com/papyrus_images/baf34cc06e65d51e4b731ce85190ccd80d05d2a3a612f2784bbc0cec28347c66.png)

最大参与时间计算公式

代码中的 `fromUInt()` 和 `log_2()` 都来自于 `ABDKMath64x64` 库（[代码](https://github.com/abdk-consulting/abdk-libraries-solidity/blob/master/ABDKMath64x64.sol)）。其中 `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 计算公式](https://storage.googleapis.com/papyrus_images/ca4d491b3b2c027a9517212d4014b00b10e66a4a2d3af9524a322615bd0b4457.png)

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 计算公式](https://storage.googleapis.com/papyrus_images/38b1b6b1c95d7c76c01d6f04ce92659a32ec1d7cd88b76646e7c58e0a260c27c.png)

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` 这一块，其他部分的计算正好对应于文档中的：

![时间挖矿奖励数量计算公式](https://storage.googleapis.com/papyrus_images/e61be9bfa5f9705cee04f5d71cfb4d9c2d16334f79a3cc0ab9b7497465c5870b.png)

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

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

    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);
    }
    

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

![扣除比例时间关系](https://storage.googleapis.com/papyrus_images/aef938b3bfec6aaa72ae59ddfcac2a59c36abd5adbdfb06c9ad1a1ee7239dbc8.png)

扣除比例时间关系

文档中显示超过七天就全部不能领取，但是代码中显示最多只会扣除 `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 时间关系](https://storage.googleapis.com/papyrus_images/6952f2f8aa48c95a2bb7a709e17d80fb241e31bc7342b076f87ec12dcd4b084d.png)

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 奖励计算公式](https://storage.googleapis.com/papyrus_images/112abfc072cd5fdc5c78b96ba759ec5bd05193cb48107be554cb2088e2004dc7.png)

stake 奖励计算公式

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

总结
--

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

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

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

关于我
---

欢迎[和我交流](https://linktr.ee/xyymeeth)

---

*Originally published on [xyyme.eth](https://paragraph.com/@xyyme/xen)*
