抵押挖矿合约 Uniswap StakingReward

源码

  1. 前端 uniswap-interface v3.0.0
  2. uniswap-sdk 3.0.3
  3. liquidity-staker 合约源码

关于合约

合约地址: https://github.com/Uniswap/liquidity-stake
质押挖矿项目合约比较小,只有4个sol文件:

├── RewardsDistributionRecipient.sol
├── StakingRewards.sol
├── StakingRewardsFactory.sol
├── interfaces
│   └── IStakingRewards.sol
└── test
    └── TestERC20.sol

IStakingRewards.sol

IStakingRewards.sol是一个接口文件,定义了质押合约的StakingRewards需要实现的一些函数,其中mutative只有四个:

    // Views
    function lastTimeRewardApplicable() external view returns (uint256);      // 有奖励的最近时间
    function rewardPerToken() external view returns (uint256);                      // 每单位Token的奖励数量
    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;                                   // 退出

RewardsDistributionRecipient.sol

RewardsDistributionRecipient.sol 类似Ownable合约,rewardsDistribution是管理员地址,还有一个modifier:onlyRewardsDistribution和onlyOwner()一样的功能。notifyRewardAmount是一个抽象函数,StakingRewards合约继承了该合约。

pragma solidity ^0.5.16;

contract RewardsDistributionRecipient {
    address public rewardsDistribution;

    function notifyRewardAmount(uint256 reward) external;

    modifier onlyRewardsDistribution() {
        require(msg.sender == rewardsDistribution, "Caller is not RewardsDistribution contract");
        _;
    }
}

StakingRewardsFactory.sol

工厂合约里定义了四个变量:

  • rewardsToken:用作奖励的代币,其实就是 UNI 代币,也可以改成其他erc20代币,但是uni前端代码需要修改
  • stakingRewardsGenesis:质押挖矿开始的时间
  • stakingTokens:用来质押的代币数组,一般就是各交易对的 LPToken
  • stakingRewardsInfoByStakingToken:一个 mapping,用来保存质押代币和质押合约信息之间的映射

质押合约信息则是一个数据结构:

    struct StakingRewardsInfo {
        address stakingRewards;  // 质押合约地址
        uint rewardAmount;       // 质押合约每周期的奖励总量
    }

rewardsToken 和 stakingRewardsGenesis 在工厂合约的构造函数里就初始化的。除了构造函数,工厂合约还有三个函数:

  • deploy:部署StakingRewards合约的函数
  • notifyRewardAmounts: 将用来挖矿的代币转入到质押合约中,并启动质押挖矿
  • notifyRewardAmount:

deploy

    function deploy(address stakingToken, uint rewardAmount) public onlyOwner {
        StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken];
        require(info.stakingRewards == address(0), 'StakingRewardsFactory::deploy: already deployed');

        info.stakingRewards = address(new StakingRewards(/*_rewardsDistribution=*/ address(this), rewardsToken, stakingToken));
        info.rewardAmount = rewardAmount;
        stakingTokens.push(stakingToken);
    }

两个入参,stakingToken 就是质押代币,在Uniswap中为 LPToken,在自己的Dapp中可以改成erc20 token。rewardAmount 则是奖励数量。

notifyRewardAmount

将用来挖矿的代币转入到质押合约中, 前提是需要先将用来挖矿奖励的 UNI 代币数量先转入该工厂合约。有个这个前提,工厂合约的该函数才能实现将 UNI 代币下发到质押合约中去。

    function notifyRewardAmount(address stakingToken) public {
        require(block.timestamp >= stakingRewardsGenesis, 'StakingRewardsFactory::notifyRewardAmount: not ready');

        StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken];
        require(info.stakingRewards != address(0), 'StakingRewardsFactory::notifyRewardAmount: not deployed');

        if (info.rewardAmount > 0) {
            uint rewardAmount = info.rewardAmount;
            info.rewardAmount = 0;

            require(
                IERC20(rewardsToken).transfer(info.stakingRewards, rewardAmount),
                'StakingRewardsFactory::notifyRewardAmount: transfer failed'
            );
            StakingRewards(info.stakingRewards).notifyRewardAmount(rewardAmount);
        }
    }

代码逻辑比较简单,

  1. 是判断当前区块的时间需大于等于质押挖矿的开始时间;
  2. 读取出指定的质押代币 stakingToken 映射的质押合约 info,要求 info 的质押合约地址不能为零地址,否则说明还没部署。
  3. 判断 info.rewardAmount 是否大于零,如果为零也不用下发奖励。
    if 语句里面的逻辑主要就是调用 rewardsToken 的 transfer 函数将奖励代币转发给质押合约,再调用质押合约的 notifyRewardAmount 函数触发其内部处理逻辑。另外,将 info.rewardAmount 重置为 0,可以避免向质押合约重复下发奖励代币。

notifyRewardAmounts

notifyRewardAmounts 函数,遍历整个质押代币数组,对每个代币再调用 notifyRewardAmount,实现逻辑非常简单。

StakingRewards.sol

StakingRewards 合约继承 RewardsDistributionRecipient 合约和 IStakingRewards 接口。

StakingRewards 存储的变量比较多,除了继承自 RewardsDistributionRecipient 抽象合约里的 rewardsDistribution 变量之外,还有 11 个变量:

  • rewardsToken:奖励代币,即 UNI 代币
  • stakingToken:质押代币,即 LPToken
  • periodFinish:质押挖矿结束的时间,默认时为 0
  • rewardRate:挖矿速率,即每秒挖矿奖励的数量
  • rewardsDuration:挖矿时长,默认设置为 60 天
  • lastUpdateTime:最近一次更新时间
  • rewardPerTokenStored:每单位 token 奖励数量
  • userRewardPerTokenPaid:用户的每单位 token 奖励数量
  • rewards:用户的奖励数量
  • _totalSupply:私有变量,总质押量
  • _balances:私有变量,用户质押余额

首先看一下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);
        }

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

由于 periodFinish 默认值为 0 且只会在该函数中更新值,所以只会执行 block.timestamp >= periodFinish 的分支逻辑,将从工厂合约转过来的挖矿奖励总量除以挖矿奖励时长,得到挖矿速率 rewardRate,即每秒的挖矿数量。else 分支理论上是执行不到的,但是如果项目方在一个奖励周期中增加了奖励数量,也是可能的。之后,读取 balance 并校验下 rewardRate,可以保证收取到的挖矿奖励余额也是充足的,rewardRate 就不会虚高。最后,更新 lastUpdateTime 和 periodFinish。periodFinish 就是在当前区块时间上加上挖矿时长,就得到了挖矿结束的时间。

接着,再来看看几个核心业务函数的实现,包括 stake、withdraw、getReward。

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

函数体内的代码逻辑很简单,将用户指定的质押量 amount 增加到 _totalSupply(总质押量)和 _balances(用户的质押余额),最后调用 stakingToken 的 safeTransferFrom 将代币从用户地址转入当前合约地址。

withdraw 则是用来提取质押代币的,代码实现也同样很简单,_totalSupply 和 _balances 都减掉提取数量,且将代币从当前合约地址转到用户地址:

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

getReward 是领取挖矿奖励的函数,内部逻辑主要就是从 rewards 中读取出用户有多少奖励并清零和转账给到用户:

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

这几个核心业务函数体内的逻辑都非常好理解,值得一说的其实是每个函数声明最后的 updateReward(msg.sender),这是一个更新挖矿奖励的 modifer,我们来看其代码:

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

主要逻辑就是更新几个字段,包括 rewardPerTokenStored、lastUpdateTime 和用户的奖励相关的 rewards[account] 和 userRewardPerTokenPaid[account]。

其中,还调用到其他三个函数:rewardPerToken()、lastTimeRewardApplicable()、earned(account)。先来看看这三个函数的实现。最简单的就是 lastTimeRewardApplicable:

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

其逻辑就是从当前区块时间和挖矿结束时间两者中返回最小值。因此,当挖矿未结束时返回的就是当前区块时间,而挖矿结束后则返回挖矿结束时间。也因此,挖矿结束后,lastUpdateTime 也会一直等于挖矿结束时间,这点很关键。

rewardPerToken 函数则是获取每单位质押代币的奖励数量,其实现代码如下:

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

这其实就是用累加计算的方式存储到 rewardPerTokenStored 变量中。当挖矿结束后,则不会再产生增量,rewardPerTokenStored 就不会再增加了。

earned 函数则是计算用户当前的挖矿奖励,代码实现也只有一行代码:

    function earned(address account) public view returns (uint256) {
        return _balances[account].mul(rewardPerToken().sub(userRewardPerTokenPaid[account])).div(1e18).add(rewards[account]);
    }

其逻辑也是计算出增量的每单位质押代币的挖矿奖励,再乘以用户的质押余额得到增量的总挖矿奖励,再加上之前已存储的挖矿奖励,就得到当前总的挖矿奖励。

至此,StakingRewards 合约的主要实现逻辑也都讲解完了。

如果只是想部署一个质押挖矿合约,可以不使用挖矿工厂的合约,直接部署StakingReward合约。以下是单独部署一个质押挖矿合约的一个例子

部署一个质押挖坑合约

部署合约

前提,部署、调通unsiwap,创建了交易对。。。

复制eth mainnet的lp staker合约:https://etherscan.io/address/0xa1484C3aa22a66C62b77E0AE78E15258bd0cB711#code 可以做修改,使用remix部署,将address _rewardsDistribution,address _rewardsToken,address _stakingToken准备好,部署好以后获得合约地址0xdcf71E0741bdfFB3cAcd1dA5FdC86314F490c853,然后去修改uniswap-interface的源码,增加STAKING_REWARDS_INFO.

给StakingRewards 转uni代币

使用已有uni的account向0xdcf71E0741bdfFB3cAcd1dA5FdC86314F490c853转uni。让StakingRewards合约可以分发奖励,合约的uni数量需要>=分法的数量。

激活StakingRewards合约,开始挖矿

remix 执行notifyRewardAmount方法,入参reward为总的发放的uni数量。
一开始periodFinish = 0 ,就是启动挖矿,开始时间为block.timestamp
该方法后续也可以增加奖励uni的额度。

修改前端代码 uniswap-interface

path: /Users/walker/Desktop/uniswap/web-3.0.0/src/state/stake

修改挖矿开始时间, 周期

export const STAKING_GENESIS = 1631203200 //挖矿开始时间
export const REWARDS_DURATION_DAYS = 60 //挖矿周期 

添加、修改挖矿池地址

// TODO add staking rewards addresses here
export const STAKING_REWARDS_INFO: {
  [chainId in ChainId]?: {
    tokens: [Token, Token]
    stakingRewardAddress: string
  }[]
} = {
  [ChainId.MAINNET]: [
    {
      tokens: [WETH[ChainId.MAINNET], DAI],
      stakingRewardAddress: '0xa1484C3aa22a66C62b77E0AE78E15258bd0cB711'
    },
    {
      tokens: [WETH[ChainId.MAINNET], USDC],
      stakingRewardAddress: '0x7FBa4B8Dc5E7616e59622806932DBea72537A56b'
    },
    {
      tokens: [WETH[ChainId.MAINNET], USDT],
      stakingRewardAddress: '0x6C3e4cb2E96B01F4b866965A91ed4437839A121a'
    },
    {
      tokens: [WETH[ChainId.MAINNET], WBTC],
      stakingRewardAddress: '0xCA35e32e7926b96A9988f61d510E038108d8068e'
    }
  ],
//新增,或者修改
  [ChainId.ROPSTEN]: [
    {
      tokens: [WETH[ChainId.ROPSTEN], DAI], //交易对
      stakingRewardAddress:  '0xdcf71E0741bdfFB3cAcd1dA5FdC86314F490c853' //抵押挖矿合约地址
    }
  ]
}

我使用的是ethereum的ROPSTEN测试链,按照实际修改好。

重新部署前端,就可以开始抵押挖矿了。

参考:
剖析DeFi交易产品之Uniswap:V2下篇

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容