👨‍🌾

Hop ExchangeのStake実装調査メモ(StakingRewards.sol)

2022/02/07に公開

https://app.hop.exchange/#/stake

コントラクト例

https://polygonscan.com/address/0x2C2Ab81Cf235e86374468b387e241DF22459A265#code

Compiler Version

  • v0.5.16+commit.9c3226ce

アウトライン

StakingRewards
interface IERC20 {...}
interface IStakingRewards {
    // Views
    function lastTimeRewardApplicable() external view returns (uint256);
    function rewardPerToken() external view returns (uint256);
    function earned(address account) external view returns (uint256);
    function getRewardForDuration() external view returns (uint256);
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);

    // Mutative
    function stake(uint256 amount) external;
    function withdraw(uint256 amount) external;
    function getReward() external;
    function exit() external;
}
interface IUniswapV2ERC20 {...}

contract ERC20Detailed is IERC20 {...}
contract ReentrancyGuard {...}
contract RewardsDistributionRecipient {...}
contract StakingRewards is IStakingRewards, RewardsDistributionRecipient, ReentrancyGuard {...}

library Math {...}
library SafeMath {...}
library Address {...}
library SafeERC20  {...}

ユースケースごとの処理詳細

LPトークンを預ける (StakingRewards.stake)

function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
    require(amount > 0, "Cannot stake 0");
    _totalSupply = _totalSupply.add(amount);
    _balances[msg.sender] = _balances[msg.sender].add(amount);
    stakingToken.safeTransferFrom(msg.sender, address(this), amount);
    emit Staked(msg.sender, amount);
}
  1. updateRewardにて対象者の獲得報酬を更新する(後述)
  2. ステーク額を加算する
  3. LPトークンをユーザからコントラクトに送る。例のコントラクトではstakingTokenHop USDC LP Tokenが設定されている

ステーク報酬を取得する (StakingRewards.getReward)

function getReward() public nonReentrant updateReward(msg.sender) {
    uint256 reward = rewards[msg.sender];
    if (reward > 0) {
        rewards[msg.sender] = 0;
        rewardsToken.safeTransfer(msg.sender, reward);
        emit RewardPaid(msg.sender, reward);
    }
}
  1. updateRewardにて対象者の獲得報酬を更新する(後述)
  2. 獲得報酬が存在したらユーザにすべて送る。例のコントラクトではrewardsTokenWMATICが設定されている

LPトークンを一部引き出す (StakingRewards.withdraw)

function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
    require(amount > 0, "Cannot withdraw 0");
    _totalSupply = _totalSupply.sub(amount);
    _balances[msg.sender] = _balances[msg.sender].sub(amount);
    stakingToken.safeTransfer(msg.sender, amount);
    emit Withdrawn(msg.sender, amount);
}
  1. updateRewardにて対象者の獲得報酬を更新する(後述)
  2. ステーク額を減算する
  3. トークンをコントラクトからユーザに送る

LPトークンをすべて引き出す (StakingRewards.exit)

function exit() external {
    withdraw(_balances[msg.sender]);
    getReward();
}
  1. 全額LPトークンを引き出す(前述の内容)
  2. ステーク報酬を獲得する(前述の内容)

獲得報酬額更新の仕組み(StakingRewards.updateReward)

関連コード
modifier updateReward(address account) {
    rewardPerTokenStored = rewardPerToken();
    lastUpdateTime = lastTimeRewardApplicable();
    if (account != address(0)) {
        rewards[account] = earned(account);
        userRewardPerTokenPaid[account] = rewardPerTokenStored;
    }
    _;
}

function rewardPerToken() public view returns (uint256) {
    if (_totalSupply == 0) {
        return rewardPerTokenStored;
    }
    return
        rewardPerTokenStored.add(
            lastTimeRewardApplicable().sub(lastUpdateTime).mul(rewardRate).mul(1e18).div(_totalSupply)
        );
}

function lastTimeRewardApplicable() public view returns (uint256) {
    return Math.min(block.timestamp, periodFinish);
}

function earned(address account) public view returns (uint256) {
    return _balances[account].mul(rewardPerToken().sub(userRewardPerTokenPaid[account])).div(1e18).add(rewards[account]);
}
  • 報酬額は、決められている毎秒の報酬額をステークシェア率に応じて分配される
    • R = 1秒間の報酬
    • l(u,t) = ユーザuが時間tにステークしているトークンの量
    • L(t) = 時間tにおけるステークされているトークンの総量
r(u, a, b) = \displaystyle\sum_{t=a}^b R \text{\(\frac {l(u,t)} {L(t)}\)}
  • スマートコントラクト上で動くように効率的な式に変換したものが以下(詳細は参考にある動画を参照)
Rk( \displaystyle\sum_{t=0}^b \text{\(\frac 1 {L(t)}\)} - \displaystyle\sum_{t=0}^{a-1} \text{\(\frac 1 {L(t)}\)} )

具体例

  • 秒間30の報酬を10秒間得られるとする
    • reward: 300
    • rewardRate: 30
    • rewardsDuration: 10

毎秒計算した場合

user
_balances earned _balances earned _balances earned _totalSupply
t=1 0 0 0 0 0 0 0
t=2 100 30 0 0 0 0 100
t=3 100 60 0 0 0 0 100
t=4 100 75 100 15 0 0 200
t=5 100 85 100 25 100 10 300
t=6 100 95 100 35 100 20 300
t=7 100 110 0 35 100 35 200
t=8 100 125 0 35 100 50 200
t=9 500 150 0 35 100 55 600
t=10 500 175 0 35 100 60 600
  • t=1はステーキングがないためその分を除いた合計270になる

コントラクト実行した場合

  • _totalSupplyは処理終了時の値
  • _totalSupply以外はupdateReward内での変数代入時点の値
t user type rewardPerTokenStored rewards[account] _totalSupply
0 0 0 0
1 stake 0 0 100
3 stake 60*10^16 0 200
4 stake 75*10^16 0 300
6 exit 95*10^16 35 200
8 stake 125*10^16 125 600
10 exit 135*10^16 175 100
11 exit 135*10^16 60 0
  • 合計獲得報酬は以下の通り毎秒計算した場合と一致
    175 35 60

備考

  • Synthetixのステークもほぼ同じコード(HopがSynthetix等のコピーのはず)

  • notifyRewardAmount

    コード
    function notifyRewardAmount(uint256 reward) external onlyRewardsDistribution updateReward(address(0)) {
        if (block.timestamp >= periodFinish) {
            rewardRate = reward.div(rewardsDuration);
        } else {
            uint256 remaining = periodFinish.sub(block.timestamp);
            uint256 leftover = remaining.mul(rewardRate);
            rewardRate = reward.add(leftover).div(rewardsDuration);
        }
    
        // Ensure the provided reward amount is not more than the balance in the contract.
        // This keeps the reward rate in the right range, preventing overflows due to
        // very high values of rewardRate in the earned and rewardsPerToken functions;
        // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow.
        uint balance = rewardsToken.balanceOf(address(this));
        require(rewardRate <= balance.div(rewardsDuration), "Provided reward too high");
    
        lastUpdateTime = block.timestamp;
        periodFinish = block.timestamp.add(rewardsDuration);
        emit RewardAdded(reward);
    }
    
    • rewardrewardsDurationで割ってrewardRateを設定する
      • 未分配の報酬がある場合はその値がrewardに加算された値でrewardRateが算出される
    • 終了時刻はblock.timestamp + rewardsDurationに更新される

参考

https://github.com/hop-protocol/hop/blob/develop/packages/frontend/src/pages/Stake/StakeWidget.tsx
https://www.youtube.com/watch?v=6ZO5aYg1GI8

GitHubで編集を提案

Discussion