源码
关于合约
合约地址: 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);
}
}
代码逻辑比较简单,
- 是判断当前区块的时间需大于等于质押挖矿的开始时间;
- 读取出指定的质押代币 stakingToken 映射的质押合约 info,要求 info 的质押合约地址不能为零地址,否则说明还没部署。
- 判断 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测试链,按照实际修改好。
重新部署前端,就可以开始抵押挖矿了。