├── .gitattributes ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .mocharc.json ├── .prettierrc ├── .yarnrc ├── README.md ├── contracts ├── RewardsDistributionRecipient.sol ├── StakingRewards.sol ├── StakingRewardsFactory.sol ├── interfaces │ └── IStakingRewards.sol └── test │ └── TestERC20.sol ├── package.json ├── test ├── StakingRewards.spec.ts ├── StakingRewardsFactory.spec.ts ├── fixtures.ts └── utils.ts ├── tsconfig.json ├── waffle.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | unit-tests: 11 | name: Unit Tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 12.x 19 | - run: yarn 20 | - run: yarn lint 21 | - run: yarn test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "./test/**/*.spec.ts", 4 | "require": "ts-node/register", 5 | "timeout": 12000 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | ignore-scripts true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @uniswap/liquidity-staker 2 | 3 | Forked from 4 | [https://github.com/Synthetixio/synthetix/tree/v2.27.2/](https://github.com/Synthetixio/synthetix/tree/v2.27.2/) 5 | 6 | -------------------------------------------------------------------------------- /contracts/RewardsDistributionRecipient.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.5.16; 2 | 3 | contract RewardsDistributionRecipient { 4 | address public rewardsDistribution; 5 | 6 | function notifyRewardAmount(uint256 reward) external; 7 | 8 | modifier onlyRewardsDistribution() { 9 | require(msg.sender == rewardsDistribution, "Caller is not RewardsDistribution contract"); 10 | _; 11 | } 12 | } -------------------------------------------------------------------------------- /contracts/StakingRewards.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.5.16; 2 | 3 | import "openzeppelin-solidity-2.3.0/contracts/math/Math.sol"; 4 | import "openzeppelin-solidity-2.3.0/contracts/math/SafeMath.sol"; 5 | import "openzeppelin-solidity-2.3.0/contracts/token/ERC20/ERC20Detailed.sol"; 6 | import "openzeppelin-solidity-2.3.0/contracts/token/ERC20/SafeERC20.sol"; 7 | import "openzeppelin-solidity-2.3.0/contracts/utils/ReentrancyGuard.sol"; 8 | 9 | // Inheritance 10 | import "./interfaces/IStakingRewards.sol"; 11 | import "./RewardsDistributionRecipient.sol"; 12 | 13 | contract StakingRewards is IStakingRewards, RewardsDistributionRecipient, ReentrancyGuard { 14 | using SafeMath for uint256; 15 | using SafeERC20 for IERC20; 16 | 17 | /* ========== STATE VARIABLES ========== */ 18 | 19 | IERC20 public rewardsToken; 20 | IERC20 public stakingToken; 21 | uint256 public periodFinish = 0; 22 | uint256 public rewardRate = 0; 23 | uint256 public rewardsDuration = 60 days; 24 | uint256 public lastUpdateTime; 25 | uint256 public rewardPerTokenStored; 26 | 27 | mapping(address => uint256) public userRewardPerTokenPaid; 28 | mapping(address => uint256) public rewards; 29 | 30 | uint256 private _totalSupply; 31 | mapping(address => uint256) private _balances; 32 | 33 | /* ========== CONSTRUCTOR ========== */ 34 | 35 | constructor( 36 | address _rewardsDistribution, 37 | address _rewardsToken, 38 | address _stakingToken 39 | ) public { 40 | rewardsToken = IERC20(_rewardsToken); 41 | stakingToken = IERC20(_stakingToken); 42 | rewardsDistribution = _rewardsDistribution; 43 | } 44 | 45 | /* ========== VIEWS ========== */ 46 | 47 | function totalSupply() external view returns (uint256) { 48 | return _totalSupply; 49 | } 50 | 51 | function balanceOf(address account) external view returns (uint256) { 52 | return _balances[account]; 53 | } 54 | 55 | function lastTimeRewardApplicable() public view returns (uint256) { 56 | return Math.min(block.timestamp, periodFinish); 57 | } 58 | 59 | function rewardPerToken() public view returns (uint256) { 60 | if (_totalSupply == 0) { 61 | return rewardPerTokenStored; 62 | } 63 | return 64 | rewardPerTokenStored.add( 65 | lastTimeRewardApplicable().sub(lastUpdateTime).mul(rewardRate).mul(1e18).div(_totalSupply) 66 | ); 67 | } 68 | 69 | function earned(address account) public view returns (uint256) { 70 | return _balances[account].mul(rewardPerToken().sub(userRewardPerTokenPaid[account])).div(1e18).add(rewards[account]); 71 | } 72 | 73 | function getRewardForDuration() external view returns (uint256) { 74 | return rewardRate.mul(rewardsDuration); 75 | } 76 | 77 | /* ========== MUTATIVE FUNCTIONS ========== */ 78 | 79 | function stakeWithPermit(uint256 amount, uint deadline, uint8 v, bytes32 r, bytes32 s) external nonReentrant updateReward(msg.sender) { 80 | require(amount > 0, "Cannot stake 0"); 81 | _totalSupply = _totalSupply.add(amount); 82 | _balances[msg.sender] = _balances[msg.sender].add(amount); 83 | 84 | // permit 85 | IUniswapV2ERC20(address(stakingToken)).permit(msg.sender, address(this), amount, deadline, v, r, s); 86 | 87 | stakingToken.safeTransferFrom(msg.sender, address(this), amount); 88 | emit Staked(msg.sender, amount); 89 | } 90 | 91 | function stake(uint256 amount) external nonReentrant updateReward(msg.sender) { 92 | require(amount > 0, "Cannot stake 0"); 93 | _totalSupply = _totalSupply.add(amount); 94 | _balances[msg.sender] = _balances[msg.sender].add(amount); 95 | stakingToken.safeTransferFrom(msg.sender, address(this), amount); 96 | emit Staked(msg.sender, amount); 97 | } 98 | 99 | function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) { 100 | require(amount > 0, "Cannot withdraw 0"); 101 | _totalSupply = _totalSupply.sub(amount); 102 | _balances[msg.sender] = _balances[msg.sender].sub(amount); 103 | stakingToken.safeTransfer(msg.sender, amount); 104 | emit Withdrawn(msg.sender, amount); 105 | } 106 | 107 | function getReward() public nonReentrant updateReward(msg.sender) { 108 | uint256 reward = rewards[msg.sender]; 109 | if (reward > 0) { 110 | rewards[msg.sender] = 0; 111 | rewardsToken.safeTransfer(msg.sender, reward); 112 | emit RewardPaid(msg.sender, reward); 113 | } 114 | } 115 | 116 | function exit() external { 117 | withdraw(_balances[msg.sender]); 118 | getReward(); 119 | } 120 | 121 | /* ========== RESTRICTED FUNCTIONS ========== */ 122 | 123 | function notifyRewardAmount(uint256 reward) external onlyRewardsDistribution updateReward(address(0)) { 124 | if (block.timestamp >= periodFinish) { 125 | rewardRate = reward.div(rewardsDuration); 126 | } else { 127 | uint256 remaining = periodFinish.sub(block.timestamp); 128 | uint256 leftover = remaining.mul(rewardRate); 129 | rewardRate = reward.add(leftover).div(rewardsDuration); 130 | } 131 | 132 | // Ensure the provided reward amount is not more than the balance in the contract. 133 | // This keeps the reward rate in the right range, preventing overflows due to 134 | // very high values of rewardRate in the earned and rewardsPerToken functions; 135 | // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. 136 | uint balance = rewardsToken.balanceOf(address(this)); 137 | require(rewardRate <= balance.div(rewardsDuration), "Provided reward too high"); 138 | 139 | lastUpdateTime = block.timestamp; 140 | periodFinish = block.timestamp.add(rewardsDuration); 141 | emit RewardAdded(reward); 142 | } 143 | 144 | /* ========== MODIFIERS ========== */ 145 | 146 | modifier updateReward(address account) { 147 | rewardPerTokenStored = rewardPerToken(); 148 | lastUpdateTime = lastTimeRewardApplicable(); 149 | if (account != address(0)) { 150 | rewards[account] = earned(account); 151 | userRewardPerTokenPaid[account] = rewardPerTokenStored; 152 | } 153 | _; 154 | } 155 | 156 | /* ========== EVENTS ========== */ 157 | 158 | event RewardAdded(uint256 reward); 159 | event Staked(address indexed user, uint256 amount); 160 | event Withdrawn(address indexed user, uint256 amount); 161 | event RewardPaid(address indexed user, uint256 reward); 162 | } 163 | 164 | interface IUniswapV2ERC20 { 165 | function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external; 166 | } 167 | -------------------------------------------------------------------------------- /contracts/StakingRewardsFactory.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.5.16; 2 | 3 | import 'openzeppelin-solidity-2.3.0/contracts/token/ERC20/IERC20.sol'; 4 | import 'openzeppelin-solidity-2.3.0/contracts/ownership/Ownable.sol'; 5 | 6 | import './StakingRewards.sol'; 7 | 8 | contract StakingRewardsFactory is Ownable { 9 | // immutables 10 | address public rewardsToken; 11 | uint public stakingRewardsGenesis; 12 | 13 | // the staking tokens for which the rewards contract has been deployed 14 | address[] public stakingTokens; 15 | 16 | // info about rewards for a particular staking token 17 | struct StakingRewardsInfo { 18 | address stakingRewards; 19 | uint rewardAmount; 20 | } 21 | 22 | // rewards info by staking token 23 | mapping(address => StakingRewardsInfo) public stakingRewardsInfoByStakingToken; 24 | 25 | constructor( 26 | address _rewardsToken, 27 | uint _stakingRewardsGenesis 28 | ) Ownable() public { 29 | require(_stakingRewardsGenesis >= block.timestamp, 'StakingRewardsFactory::constructor: genesis too soon'); 30 | 31 | rewardsToken = _rewardsToken; 32 | stakingRewardsGenesis = _stakingRewardsGenesis; 33 | } 34 | 35 | ///// permissioned functions 36 | 37 | // deploy a staking reward contract for the staking token, and store the reward amount 38 | // the reward will be distributed to the staking reward contract no sooner than the genesis 39 | function deploy(address stakingToken, uint rewardAmount) public onlyOwner { 40 | StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken]; 41 | require(info.stakingRewards == address(0), 'StakingRewardsFactory::deploy: already deployed'); 42 | 43 | info.stakingRewards = address(new StakingRewards(/*_rewardsDistribution=*/ address(this), rewardsToken, stakingToken)); 44 | info.rewardAmount = rewardAmount; 45 | stakingTokens.push(stakingToken); 46 | } 47 | 48 | ///// permissionless functions 49 | 50 | // call notifyRewardAmount for all staking tokens. 51 | function notifyRewardAmounts() public { 52 | require(stakingTokens.length > 0, 'StakingRewardsFactory::notifyRewardAmounts: called before any deploys'); 53 | for (uint i = 0; i < stakingTokens.length; i++) { 54 | notifyRewardAmount(stakingTokens[i]); 55 | } 56 | } 57 | 58 | // notify reward amount for an individual staking token. 59 | // this is a fallback in case the notifyRewardAmounts costs too much gas to call for all contracts 60 | function notifyRewardAmount(address stakingToken) public { 61 | require(block.timestamp >= stakingRewardsGenesis, 'StakingRewardsFactory::notifyRewardAmount: not ready'); 62 | 63 | StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken]; 64 | require(info.stakingRewards != address(0), 'StakingRewardsFactory::notifyRewardAmount: not deployed'); 65 | 66 | if (info.rewardAmount > 0) { 67 | uint rewardAmount = info.rewardAmount; 68 | info.rewardAmount = 0; 69 | 70 | require( 71 | IERC20(rewardsToken).transfer(info.stakingRewards, rewardAmount), 72 | 'StakingRewardsFactory::notifyRewardAmount: transfer failed' 73 | ); 74 | StakingRewards(info.stakingRewards).notifyRewardAmount(rewardAmount); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /contracts/interfaces/IStakingRewards.sol: -------------------------------------------------------------------------------- 1 | pragma solidity >=0.4.24; 2 | 3 | 4 | interface IStakingRewards { 5 | // Views 6 | function lastTimeRewardApplicable() external view returns (uint256); 7 | 8 | function rewardPerToken() external view returns (uint256); 9 | 10 | function earned(address account) external view returns (uint256); 11 | 12 | function getRewardForDuration() external view returns (uint256); 13 | 14 | function totalSupply() external view returns (uint256); 15 | 16 | function balanceOf(address account) external view returns (uint256); 17 | 18 | // Mutative 19 | 20 | function stake(uint256 amount) external; 21 | 22 | function withdraw(uint256 amount) external; 23 | 24 | function getReward() external; 25 | 26 | function exit() external; 27 | } -------------------------------------------------------------------------------- /contracts/test/TestERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity =0.5.16; 3 | 4 | import "openzeppelin-solidity-2.3.0/contracts/token/ERC20/ERC20Detailed.sol"; 5 | import "openzeppelin-solidity-2.3.0/contracts/token/ERC20/ERC20Mintable.sol"; 6 | 7 | contract TestERC20 is ERC20Detailed, ERC20Mintable { 8 | constructor(uint amount) ERC20Detailed('Test ERC20', 'TEST', 18) public { 9 | mint(msg.sender, amount); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uniswap/liquidity-staker", 3 | "version": "1.0.2", 4 | "author": "Noah Zinsmeister", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Uniswap/liquidity-staker" 8 | }, 9 | "files": [ 10 | "build" 11 | ], 12 | "engines": { 13 | "node": ">=10" 14 | }, 15 | "scripts": { 16 | "precompile": "rimraf ./build/", 17 | "compile": "waffle", 18 | "pretest": "yarn compile", 19 | "test": "mocha", 20 | "lint": "prettier ./test/**/*.ts --check", 21 | "prepublishOnly": "yarn test" 22 | }, 23 | "dependencies": { 24 | "openzeppelin-solidity-2.3.0": "npm:openzeppelin-solidity@2.3.0" 25 | }, 26 | "devDependencies": { 27 | "@types/chai": "^4.2.12", 28 | "@types/mocha": "^8.0.3", 29 | "@uniswap/v2-core": "^1.0.1", 30 | "chai": "^4.2.0", 31 | "ethereum-waffle": "^3.1.0", 32 | "ethereumjs-util": "^7.0.5", 33 | "mocha": "^8.1.3", 34 | "prettier": "^2.1.1", 35 | "rimraf": "^3.0.2", 36 | "solc": "0.5.16", 37 | "ts-node": "^9.0.0", 38 | "typescript": "^4.0.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/StakingRewards.spec.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai' 2 | import { Contract, BigNumber, constants } from 'ethers' 3 | import { solidity, MockProvider, createFixtureLoader, deployContract } from 'ethereum-waffle' 4 | import { ecsign } from 'ethereumjs-util' 5 | 6 | import { stakingRewardsFixture } from './fixtures' 7 | import { REWARDS_DURATION, expandTo18Decimals, mineBlock, getApprovalDigest } from './utils' 8 | 9 | import StakingRewards from '../build/StakingRewards.json' 10 | 11 | chai.use(solidity) 12 | 13 | describe('StakingRewards', () => { 14 | const provider = new MockProvider({ 15 | ganacheOptions: { 16 | hardfork: 'istanbul', 17 | mnemonic: 'horn horn horn horn horn horn horn horn horn horn horn horn', 18 | gasLimit: 9999999, 19 | }, 20 | }) 21 | const [wallet, staker, secondStaker] = provider.getWallets() 22 | const loadFixture = createFixtureLoader([wallet], provider) 23 | 24 | let stakingRewards: Contract 25 | let rewardsToken: Contract 26 | let stakingToken: Contract 27 | beforeEach(async () => { 28 | const fixture = await loadFixture(stakingRewardsFixture) 29 | stakingRewards = fixture.stakingRewards 30 | rewardsToken = fixture.rewardsToken 31 | stakingToken = fixture.stakingToken 32 | }) 33 | 34 | it('deploy cost', async () => { 35 | const stakingRewards = await deployContract(wallet, StakingRewards, [ 36 | wallet.address, 37 | rewardsToken.address, 38 | stakingToken.address, 39 | ]) 40 | const receipt = await provider.getTransactionReceipt(stakingRewards.deployTransaction.hash) 41 | expect(receipt.gasUsed).to.eq('1418436') 42 | }) 43 | 44 | it('rewardsDuration', async () => { 45 | const rewardsDuration = await stakingRewards.rewardsDuration() 46 | expect(rewardsDuration).to.be.eq(REWARDS_DURATION) 47 | }) 48 | 49 | const reward = expandTo18Decimals(100) 50 | async function start(reward: BigNumber): Promise<{ startTime: BigNumber; endTime: BigNumber }> { 51 | // send reward to the contract 52 | await rewardsToken.transfer(stakingRewards.address, reward) 53 | // must be called by rewardsDistribution 54 | await stakingRewards.notifyRewardAmount(reward) 55 | 56 | const startTime: BigNumber = await stakingRewards.lastUpdateTime() 57 | const endTime: BigNumber = await stakingRewards.periodFinish() 58 | expect(endTime).to.be.eq(startTime.add(REWARDS_DURATION)) 59 | return { startTime, endTime } 60 | } 61 | 62 | it('notifyRewardAmount: full', async () => { 63 | // stake with staker 64 | const stake = expandTo18Decimals(2) 65 | await stakingToken.transfer(staker.address, stake) 66 | await stakingToken.connect(staker).approve(stakingRewards.address, stake) 67 | await stakingRewards.connect(staker).stake(stake) 68 | 69 | const { endTime } = await start(reward) 70 | 71 | // fast-forward past the reward window 72 | await mineBlock(provider, endTime.add(1).toNumber()) 73 | 74 | // unstake 75 | await stakingRewards.connect(staker).exit() 76 | const stakeEndTime: BigNumber = await stakingRewards.lastUpdateTime() 77 | expect(stakeEndTime).to.be.eq(endTime) 78 | 79 | const rewardAmount = await rewardsToken.balanceOf(staker.address) 80 | expect(reward.sub(rewardAmount).lte(reward.div(10000))).to.be.true // ensure result is within .01% 81 | expect(rewardAmount).to.be.eq(reward.div(REWARDS_DURATION).mul(REWARDS_DURATION)) 82 | }) 83 | 84 | it('stakeWithPermit', async () => { 85 | // stake with staker 86 | const stake = expandTo18Decimals(2) 87 | await stakingToken.transfer(staker.address, stake) 88 | 89 | // get permit 90 | const nonce = await stakingToken.nonces(staker.address) 91 | const deadline = constants.MaxUint256 92 | const digest = await getApprovalDigest( 93 | stakingToken, 94 | { owner: staker.address, spender: stakingRewards.address, value: stake }, 95 | nonce, 96 | deadline 97 | ) 98 | const { v, r, s } = ecsign(Buffer.from(digest.slice(2), 'hex'), Buffer.from(staker.privateKey.slice(2), 'hex')) 99 | 100 | await stakingRewards.connect(staker).stakeWithPermit(stake, deadline, v, r, s) 101 | 102 | const { endTime } = await start(reward) 103 | 104 | // fast-forward past the reward window 105 | await mineBlock(provider, endTime.add(1).toNumber()) 106 | 107 | // unstake 108 | await stakingRewards.connect(staker).exit() 109 | const stakeEndTime: BigNumber = await stakingRewards.lastUpdateTime() 110 | expect(stakeEndTime).to.be.eq(endTime) 111 | 112 | const rewardAmount = await rewardsToken.balanceOf(staker.address) 113 | expect(reward.sub(rewardAmount).lte(reward.div(10000))).to.be.true // ensure result is within .01% 114 | expect(rewardAmount).to.be.eq(reward.div(REWARDS_DURATION).mul(REWARDS_DURATION)) 115 | }) 116 | 117 | it('notifyRewardAmount: ~half', async () => { 118 | const { startTime, endTime } = await start(reward) 119 | 120 | // fast-forward ~halfway through the reward window 121 | await mineBlock(provider, startTime.add(endTime.sub(startTime).div(2)).toNumber()) 122 | 123 | // stake with staker 124 | const stake = expandTo18Decimals(2) 125 | await stakingToken.transfer(staker.address, stake) 126 | await stakingToken.connect(staker).approve(stakingRewards.address, stake) 127 | await stakingRewards.connect(staker).stake(stake) 128 | const stakeStartTime: BigNumber = await stakingRewards.lastUpdateTime() 129 | 130 | // fast-forward past the reward window 131 | await mineBlock(provider, endTime.add(1).toNumber()) 132 | 133 | // unstake 134 | await stakingRewards.connect(staker).exit() 135 | const stakeEndTime: BigNumber = await stakingRewards.lastUpdateTime() 136 | expect(stakeEndTime).to.be.eq(endTime) 137 | 138 | const rewardAmount = await rewardsToken.balanceOf(staker.address) 139 | expect(reward.div(2).sub(rewardAmount).lte(reward.div(2).div(10000))).to.be.true // ensure result is within .01% 140 | expect(rewardAmount).to.be.eq(reward.div(REWARDS_DURATION).mul(endTime.sub(stakeStartTime))) 141 | }).retries(2) // TODO investigate flakiness 142 | 143 | it('notifyRewardAmount: two stakers', async () => { 144 | // stake with first staker 145 | const stake = expandTo18Decimals(2) 146 | await stakingToken.transfer(staker.address, stake) 147 | await stakingToken.connect(staker).approve(stakingRewards.address, stake) 148 | await stakingRewards.connect(staker).stake(stake) 149 | 150 | const { startTime, endTime } = await start(reward) 151 | 152 | // fast-forward ~halfway through the reward window 153 | await mineBlock(provider, startTime.add(endTime.sub(startTime).div(2)).toNumber()) 154 | 155 | // stake with second staker 156 | await stakingToken.transfer(secondStaker.address, stake) 157 | await stakingToken.connect(secondStaker).approve(stakingRewards.address, stake) 158 | await stakingRewards.connect(secondStaker).stake(stake) 159 | 160 | // fast-forward past the reward window 161 | await mineBlock(provider, endTime.add(1).toNumber()) 162 | 163 | // unstake 164 | await stakingRewards.connect(staker).exit() 165 | const stakeEndTime: BigNumber = await stakingRewards.lastUpdateTime() 166 | expect(stakeEndTime).to.be.eq(endTime) 167 | await stakingRewards.connect(secondStaker).exit() 168 | 169 | const rewardAmount = await rewardsToken.balanceOf(staker.address) 170 | const secondRewardAmount = await rewardsToken.balanceOf(secondStaker.address) 171 | const totalReward = rewardAmount.add(secondRewardAmount) 172 | 173 | // ensure results are within .01% 174 | expect(reward.sub(totalReward).lte(reward.div(10000))).to.be.true 175 | expect(totalReward.mul(3).div(4).sub(rewardAmount).lte(totalReward.mul(3).div(4).div(10000))) 176 | expect(totalReward.div(4).sub(secondRewardAmount).lte(totalReward.div(4).div(10000))) 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /test/StakingRewardsFactory.spec.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai' 2 | import { Contract, BigNumber } from 'ethers' 3 | import { solidity, MockProvider, createFixtureLoader } from 'ethereum-waffle' 4 | 5 | import { stakingRewardsFactoryFixture } from './fixtures' 6 | import { mineBlock } from './utils' 7 | 8 | import StakingRewards from '../build/StakingRewards.json' 9 | 10 | chai.use(solidity) 11 | 12 | describe('StakingRewardsFactory', () => { 13 | const provider = new MockProvider({ 14 | ganacheOptions: { 15 | hardfork: 'istanbul', 16 | mnemonic: 'horn horn horn horn horn horn horn horn horn horn horn horn', 17 | gasLimit: 9999999, 18 | }, 19 | }) 20 | const [wallet, wallet1] = provider.getWallets() 21 | const loadFixture = createFixtureLoader([wallet], provider) 22 | 23 | let rewardsToken: Contract 24 | let genesis: number 25 | let rewardAmounts: BigNumber[] 26 | let stakingRewardsFactory: Contract 27 | let stakingTokens: Contract[] 28 | 29 | beforeEach('load fixture', async () => { 30 | const fixture = await loadFixture(stakingRewardsFactoryFixture) 31 | rewardsToken = fixture.rewardsToken 32 | genesis = fixture.genesis 33 | rewardAmounts = fixture.rewardAmounts 34 | stakingRewardsFactory = fixture.stakingRewardsFactory 35 | stakingTokens = fixture.stakingTokens 36 | }) 37 | 38 | it('deployment gas', async () => { 39 | const receipt = await provider.getTransactionReceipt(stakingRewardsFactory.deployTransaction.hash) 40 | expect(receipt.gasUsed).to.eq('2080815') 41 | }) 42 | 43 | describe('#deploy', () => { 44 | it('pushes the token into the list', async () => { 45 | await stakingRewardsFactory.deploy(stakingTokens[1].address, 10000) 46 | expect(await stakingRewardsFactory.stakingTokens(0)).to.eq(stakingTokens[1].address) 47 | }) 48 | 49 | it('fails if called twice for same token', async () => { 50 | await stakingRewardsFactory.deploy(stakingTokens[1].address, 10000) 51 | await expect(stakingRewardsFactory.deploy(stakingTokens[1].address, 10000)).to.revertedWith( 52 | 'StakingRewardsFactory::deploy: already deployed' 53 | ) 54 | }) 55 | 56 | it('can only be called by the owner', async () => { 57 | await expect(stakingRewardsFactory.connect(wallet1).deploy(stakingTokens[1].address, 10000)).to.be.revertedWith( 58 | 'Ownable: caller is not the owner' 59 | ) 60 | }) 61 | 62 | it('stores the address of stakingRewards and reward amount', async () => { 63 | await stakingRewardsFactory.deploy(stakingTokens[1].address, 10000) 64 | const [stakingRewards, rewardAmount] = await stakingRewardsFactory.stakingRewardsInfoByStakingToken( 65 | stakingTokens[1].address 66 | ) 67 | expect(await provider.getCode(stakingRewards)).to.not.eq('0x') 68 | expect(rewardAmount).to.eq(10000) 69 | }) 70 | 71 | it('deployed staking rewards has correct parameters', async () => { 72 | await stakingRewardsFactory.deploy(stakingTokens[1].address, 10000) 73 | const [stakingRewardsAddress] = await stakingRewardsFactory.stakingRewardsInfoByStakingToken( 74 | stakingTokens[1].address 75 | ) 76 | const stakingRewards = new Contract(stakingRewardsAddress, StakingRewards.abi, provider) 77 | expect(await stakingRewards.rewardsDistribution()).to.eq(stakingRewardsFactory.address) 78 | expect(await stakingRewards.stakingToken()).to.eq(stakingTokens[1].address) 79 | expect(await stakingRewards.rewardsToken()).to.eq(rewardsToken.address) 80 | }) 81 | }) 82 | 83 | describe('#notifyRewardsAmounts', () => { 84 | let totalRewardAmount: BigNumber 85 | 86 | beforeEach(() => { 87 | totalRewardAmount = rewardAmounts.reduce((accumulator, current) => accumulator.add(current), BigNumber.from(0)) 88 | }) 89 | 90 | it('called before any deploys', async () => { 91 | await expect(stakingRewardsFactory.notifyRewardAmounts()).to.be.revertedWith( 92 | 'StakingRewardsFactory::notifyRewardAmounts: called before any deploys' 93 | ) 94 | }) 95 | 96 | describe('after deploying all staking reward contracts', async () => { 97 | let stakingRewards: Contract[] 98 | beforeEach('deploy staking reward contracts', async () => { 99 | stakingRewards = [] 100 | for (let i = 0; i < stakingTokens.length; i++) { 101 | await stakingRewardsFactory.deploy(stakingTokens[i].address, rewardAmounts[i]) 102 | const [stakingRewardsAddress] = await stakingRewardsFactory.stakingRewardsInfoByStakingToken( 103 | stakingTokens[i].address 104 | ) 105 | stakingRewards.push(new Contract(stakingRewardsAddress, StakingRewards.abi, provider)) 106 | } 107 | }) 108 | 109 | it('gas', async () => { 110 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 111 | await mineBlock(provider, genesis) 112 | const tx = await stakingRewardsFactory.notifyRewardAmounts() 113 | const receipt = await tx.wait() 114 | expect(receipt.gasUsed).to.eq('416215') 115 | }) 116 | 117 | it('no op if called twice', async () => { 118 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 119 | await mineBlock(provider, genesis) 120 | await expect(stakingRewardsFactory.notifyRewardAmounts()).to.emit(rewardsToken, 'Transfer') 121 | await expect(stakingRewardsFactory.notifyRewardAmounts()).to.not.emit(rewardsToken, 'Transfer') 122 | }) 123 | 124 | it('fails if called without sufficient balance', async () => { 125 | await mineBlock(provider, genesis) 126 | await expect(stakingRewardsFactory.notifyRewardAmounts()).to.be.revertedWith( 127 | 'SafeMath: subtraction overflow' // emitted from rewards token 128 | ) 129 | }) 130 | 131 | it('calls notifyRewards on each contract', async () => { 132 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 133 | await mineBlock(provider, genesis) 134 | await expect(stakingRewardsFactory.notifyRewardAmounts()) 135 | .to.emit(stakingRewards[0], 'RewardAdded') 136 | .withArgs(rewardAmounts[0]) 137 | .to.emit(stakingRewards[1], 'RewardAdded') 138 | .withArgs(rewardAmounts[1]) 139 | .to.emit(stakingRewards[2], 'RewardAdded') 140 | .withArgs(rewardAmounts[2]) 141 | .to.emit(stakingRewards[3], 'RewardAdded') 142 | .withArgs(rewardAmounts[3]) 143 | }) 144 | 145 | it('transfers the reward tokens to the individual contracts', async () => { 146 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 147 | await mineBlock(provider, genesis) 148 | await stakingRewardsFactory.notifyRewardAmounts() 149 | for (let i = 0; i < rewardAmounts.length; i++) { 150 | expect(await rewardsToken.balanceOf(stakingRewards[i].address)).to.eq(rewardAmounts[i]) 151 | } 152 | }) 153 | 154 | it('sets rewardAmount to 0', async () => { 155 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 156 | await mineBlock(provider, genesis) 157 | for (let i = 0; i < stakingTokens.length; i++) { 158 | const [, amount] = await stakingRewardsFactory.stakingRewardsInfoByStakingToken(stakingTokens[i].address) 159 | expect(amount).to.eq(rewardAmounts[i]) 160 | } 161 | await stakingRewardsFactory.notifyRewardAmounts() 162 | for (let i = 0; i < stakingTokens.length; i++) { 163 | const [, amount] = await stakingRewardsFactory.stakingRewardsInfoByStakingToken(stakingTokens[i].address) 164 | expect(amount).to.eq(0) 165 | } 166 | }) 167 | 168 | it('succeeds when has sufficient balance and after genesis time', async () => { 169 | await rewardsToken.transfer(stakingRewardsFactory.address, totalRewardAmount) 170 | await mineBlock(provider, genesis) 171 | await stakingRewardsFactory.notifyRewardAmounts() 172 | }) 173 | }) 174 | }) 175 | }) 176 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import { Contract, Wallet, BigNumber, providers } from 'ethers' 3 | import { solidity, deployContract } from 'ethereum-waffle' 4 | 5 | import { expandTo18Decimals } from './utils' 6 | 7 | import UniswapV2ERC20 from '@uniswap/v2-core/build/ERC20.json' 8 | import TestERC20 from '../build/TestERC20.json' 9 | import StakingRewards from '../build/StakingRewards.json' 10 | import StakingRewardsFactory from '../build/StakingRewardsFactory.json' 11 | 12 | chai.use(solidity) 13 | 14 | const NUMBER_OF_STAKING_TOKENS = 4 15 | 16 | interface StakingRewardsFixture { 17 | stakingRewards: Contract 18 | rewardsToken: Contract 19 | stakingToken: Contract 20 | } 21 | 22 | export async function stakingRewardsFixture([wallet]: Wallet[]): Promise { 23 | const rewardsDistribution = wallet.address 24 | const rewardsToken = await deployContract(wallet, TestERC20, [expandTo18Decimals(1000000)]) 25 | const stakingToken = await deployContract(wallet, UniswapV2ERC20, [expandTo18Decimals(1000000)]) 26 | 27 | const stakingRewards = await deployContract(wallet, StakingRewards, [ 28 | rewardsDistribution, 29 | rewardsToken.address, 30 | stakingToken.address, 31 | ]) 32 | 33 | return { stakingRewards, rewardsToken, stakingToken } 34 | } 35 | 36 | interface StakingRewardsFactoryFixture { 37 | rewardsToken: Contract 38 | stakingTokens: Contract[] 39 | genesis: number 40 | rewardAmounts: BigNumber[] 41 | stakingRewardsFactory: Contract 42 | } 43 | 44 | export async function stakingRewardsFactoryFixture( 45 | [wallet]: Wallet[], 46 | provider: providers.Web3Provider 47 | ): Promise { 48 | const rewardsToken = await deployContract(wallet, TestERC20, [expandTo18Decimals(1_000_000_000)]) 49 | 50 | // deploy staking tokens 51 | const stakingTokens = [] 52 | for (let i = 0; i < NUMBER_OF_STAKING_TOKENS; i++) { 53 | const stakingToken = await deployContract(wallet, TestERC20, [expandTo18Decimals(1_000_000_000)]) 54 | stakingTokens.push(stakingToken) 55 | } 56 | 57 | // deploy the staking rewards factory 58 | const { timestamp: now } = await provider.getBlock('latest') 59 | const genesis = now + 60 * 60 60 | const rewardAmounts: BigNumber[] = new Array(stakingTokens.length).fill(expandTo18Decimals(10)) 61 | const stakingRewardsFactory = await deployContract(wallet, StakingRewardsFactory, [rewardsToken.address, genesis]) 62 | 63 | return { rewardsToken, stakingTokens, genesis, rewardAmounts, stakingRewardsFactory } 64 | } 65 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, providers, utils, Contract } from 'ethers' 2 | 3 | const PERMIT_TYPEHASH = utils.keccak256( 4 | utils.toUtf8Bytes('Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)') 5 | ) 6 | 7 | function getDomainSeparator(name: string, tokenAddress: string) { 8 | return utils.keccak256( 9 | utils.defaultAbiCoder.encode( 10 | ['bytes32', 'bytes32', 'bytes32', 'uint256', 'address'], 11 | [ 12 | utils.keccak256( 13 | utils.toUtf8Bytes('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') 14 | ), 15 | utils.keccak256(utils.toUtf8Bytes(name)), 16 | utils.keccak256(utils.toUtf8Bytes('1')), 17 | 1, 18 | tokenAddress, 19 | ] 20 | ) 21 | ) 22 | } 23 | 24 | export async function getApprovalDigest( 25 | token: Contract, 26 | approve: { 27 | owner: string 28 | spender: string 29 | value: BigNumber 30 | }, 31 | nonce: BigNumber, 32 | deadline: BigNumber 33 | ): Promise { 34 | const name = await token.name() 35 | const DOMAIN_SEPARATOR = getDomainSeparator(name, token.address) 36 | return utils.keccak256( 37 | utils.solidityPack( 38 | ['bytes1', 'bytes1', 'bytes32', 'bytes32'], 39 | [ 40 | '0x19', 41 | '0x01', 42 | DOMAIN_SEPARATOR, 43 | utils.keccak256( 44 | utils.defaultAbiCoder.encode( 45 | ['bytes32', 'address', 'address', 'uint256', 'uint256', 'uint256'], 46 | [PERMIT_TYPEHASH, approve.owner, approve.spender, approve.value, nonce, deadline] 47 | ) 48 | ), 49 | ] 50 | ) 51 | ) 52 | } 53 | 54 | export const REWARDS_DURATION = 60 * 60 * 24 * 60 55 | 56 | export function expandTo18Decimals(n: number): BigNumber { 57 | return BigNumber.from(n).mul(BigNumber.from(10).pow(18)) 58 | } 59 | 60 | export async function mineBlock(provider: providers.Web3Provider, timestamp: number): Promise { 61 | return provider.send('evm_mine', [timestamp]) 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /waffle.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerType": "solcjs", 3 | "compilerVersion": "./node_modules/solc", 4 | "outputType": "all", 5 | "compilerOptions": { 6 | "outputSelection": { 7 | "*": { 8 | "*": [ 9 | "evm.bytecode.object", 10 | "evm.deployedBytecode.object", 11 | "abi", 12 | "evm.bytecode.sourceMap", 13 | "evm.deployedBytecode.sourceMap", 14 | "metadata" 15 | ], 16 | "": ["ast"] 17 | } 18 | }, 19 | "evmVersion": "istanbul", 20 | "optimizer": { 21 | "enabled": true, 22 | "runs": 999999 23 | } 24 | } 25 | } 26 | --------------------------------------------------------------------------------