diff --git a/contracts/farm/Farm.sol b/contracts/farm/Farm.sol new file mode 100644 index 0000000..ba7aa24 --- /dev/null +++ b/contracts/farm/Farm.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import "./libraries/SafeToken.sol"; +import "./IFarm.sol"; +import "../core/interfaces/IERC20.sol"; + +contract Farm is IFarm, OwnableUpgradeable, PausableUpgradeable { + using SafeToken for address; + + struct StakeInfo { + uint256 amount; + uint256 rewardDebt; + } + + struct PoolInfo { + uint256 allocPoint; + uint256 lastRewardBlock; + uint256 accRewardPerShare; + } + + address public rewardToken; + + uint256 public rewardPerBlock; + uint256 public totalAllocPoint; + + mapping(address => PoolInfo) public poolInfos; + mapping(address => mapping(address => StakeInfo)) public stakes; + mapping(address => uint256) private lpTokenBalances; + + event LogPoolAdded(address indexed lpToken, uint256 allocPoint); + event LogPoolAllocationUpdate(address indexed lpToken, uint256 allocPoint); + event LogPoolRewardUpdate( + address indexed lpToken, + uint256 accRewardPerShare, + uint256 lastRewardBlock + ); + event LogDeposit(address indexed user, address indexed lpToken, uint256 amount); + event LogWithdraw(address indexed user, address indexed lpToken, uint256 amount); + event LogRewardPaid(address indexed user, address indexed lpToken, uint256 reward); + + function initialize( + address _rewardToken, + uint256 _rewardPerBlock + ) external initializer { + OwnableUpgradeable.__Ownable_init(); + PausableUpgradeable.__Pausable_init(); + + rewardToken = _rewardToken; + rewardPerBlock = _rewardPerBlock; + totalAllocPoint = 0; + } + + function addPool( + address _lpToken, + uint256 _allocPoint + ) external override onlyOwner whenNotPaused { + poolInfos[_lpToken] = PoolInfo({ + allocPoint: _allocPoint, + lastRewardBlock: block.number, + accRewardPerShare: 0 + }); + + totalAllocPoint += _allocPoint; + + emit LogPoolAdded(_lpToken, _allocPoint); + } + + function updateAllocation( + address _lpToken, + uint256 _allocPoint + ) external override onlyOwner whenNotPaused { + PoolInfo storage pool = poolInfos[_lpToken]; + + totalAllocPoint = totalAllocPoint + _allocPoint - pool.allocPoint; + pool.allocPoint = _allocPoint; + + emit LogPoolAllocationUpdate(_lpToken, _allocPoint); + } + + function deposit(address _lpToken, uint256 _amount) external override whenNotPaused { + require(_lpToken != address(0), "Zero address"); + require(_amount > 0, "Zero amount"); + + updatePoolReward(_lpToken); + + PoolInfo storage pool = poolInfos[_lpToken]; + StakeInfo storage stake = stakes[_lpToken][msg.sender]; + + if (stake.amount > 0) { + uint256 pending = (stake.amount * pool.accRewardPerShare) / + 1e12 - + stake.rewardDebt; + if (pending > 0) { + address(rewardToken).safeTransfer(msg.sender, pending); + emit LogRewardPaid(msg.sender, _lpToken, pending); + } + } + + stake.amount += _amount; + stake.rewardDebt = (stake.amount * pool.accRewardPerShare) / 1e12; + + _lpToken.safeTransferFrom(msg.sender, address(this), _amount); + lpTokenBalances[_lpToken] += _amount; + + emit LogDeposit(msg.sender, _lpToken, _amount); + } + + function withdraw( + address _lpToken, + uint256 _amount + ) external override whenNotPaused { + require(_lpToken != address(0), "Zero address"); + PoolInfo storage pool = poolInfos[_lpToken]; + StakeInfo storage stake = stakes[_lpToken][msg.sender]; + + require(stake.amount >= _amount, "Not sufficient amount"); + + updatePoolReward(_lpToken); + + uint256 pending = (stake.amount * pool.accRewardPerShare) / + 1e12 - + stake.rewardDebt; + if (pending > 0) { + address(rewardToken).safeTransfer(msg.sender, pending); + emit LogRewardPaid(msg.sender, _lpToken, pending); + } + + if (_amount > 0) { + stake.amount -= _amount; + _lpToken.safeTransfer(msg.sender, _amount); + lpTokenBalances[_lpToken] -= _amount; + } + + stake.rewardDebt = (stake.amount * pool.accRewardPerShare) / 1e12; + + emit LogWithdraw(msg.sender, _lpToken, _amount); + } + + function emergencyWithdraw(address _lpToken) override external whenPaused { + StakeInfo storage stake = stakes[_lpToken][msg.sender]; + require(stake.amount > 0, "Zero balance"); + + uint256 amount = stake.amount; + + stake.amount = 0; + stake.rewardDebt = 0; + + _lpToken.safeTransfer(msg.sender, amount); + lpTokenBalances[_lpToken] -= amount; + + emit LogWithdraw(msg.sender, _lpToken, amount); + } + + function setRewardPerBlock(uint256 _rewardPerBlock) override external onlyOwner whenNotPaused { + rewardPerBlock = _rewardPerBlock; + } + + function pause() external override onlyOwner { + _pause(); + } + + + function unpause() external override onlyOwner { + _unpause(); + } + + + function updatePoolReward(address _lpToken) public override { + PoolInfo storage pool = poolInfos[_lpToken]; + + if (block.number <= pool.lastRewardBlock) { + return; + } + + uint256 lpSupply = lpTokenBalances[_lpToken]; + + if (lpSupply == 0) { + pool.lastRewardBlock = block.number; + return; + } + + uint256 multiplier = block.number - pool.lastRewardBlock; + uint256 reward = (multiplier * rewardPerBlock * pool.allocPoint) / totalAllocPoint; + pool.accRewardPerShare += (reward * 1e12) / lpSupply; + pool.lastRewardBlock = block.number; + + emit LogPoolRewardUpdate( + _lpToken, + pool.accRewardPerShare, + pool.lastRewardBlock + ); + } +} diff --git a/contracts/farm/IFarm.sol b/contracts/farm/IFarm.sol new file mode 100644 index 0000000..c886a84 --- /dev/null +++ b/contracts/farm/IFarm.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +interface IFarm { + function addPool(address _lpToken, uint _allocPoint) external; + + function updateAllocation(address _lpToken, uint _allocPoint) external; + + function deposit(address _lpToken, uint256 _amount) external; + + function withdraw(address _lpToken, uint256 _amount) external; + + function emergencyWithdraw(address _lpToken) external; + + function setRewardPerBlock(uint256 _rewardPerBlock) external; + + function pause() external; + + function unpause() external; + + function updatePoolReward(address _lpToken) external; + + function rewardPerBlock() external view returns (uint); + + function totalAllocPoint() external view returns (uint); + + function rewardToken() external view returns (address); +} diff --git a/contracts/farm/libraries/SafeToken.sol b/contracts/farm/libraries/SafeToken.sol new file mode 100644 index 0000000..de3596c --- /dev/null +++ b/contracts/farm/libraries/SafeToken.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +interface ERC20Interface { + function balanceOf(address user) external view returns (uint256); +} + +library SafeToken { + function myBalance(address token) internal view returns (uint256) { + return ERC20Interface(token).balanceOf(address(this)); + } + + function balanceOf(address token, address user) internal view returns (uint256) { + return ERC20Interface(token).balanceOf(user); + } + + function safeApprove(address token, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x095ea7b3, to, value)); // bytes4(keccak256(bytes('approve(address,uint256)'))); + require(success && (data.length == 0 || abi.decode(data, (bool))), "!safeApprove"); + } + + function safeTransfer(address token, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value)); // bytes4(keccak256(bytes('transfer(address,uint256)'))); + require(success && (data.length == 0 || abi.decode(data, (bool))), "!safeTransfer"); + } + + function safeTransferFrom(address token, address from, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value)); // bytes4(keccak256(bytes('transferFrom(address,address,uint256)'))); + require(success && (data.length == 0 || abi.decode(data, (bool))), "!safeTransferFrom"); + } + + function safeTransferETH(address to, uint256 value) internal { + (bool success, ) = to.call{ value: value }(new bytes(0)); + require(success, "!safeTransferETH"); + } +} diff --git a/package.json b/package.json index 13d8778..5b94878 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "license": "GPL-3.0", "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^1.0.2", - "@openzeppelin/contracts": "^4.0.0", + "@openzeppelin/contracts": "^4.7.3", + "@openzeppelin/contracts-upgradeable": "^4.7.3", "@uniswap/v2-core": "1.0.0", "@uniswap/v2-periphery": "^1.1.0-beta.0", "dotenv": "^16.0.1", diff --git a/scripts/deploy-farm.js b/scripts/deploy-farm.js new file mode 100644 index 0000000..507869c --- /dev/null +++ b/scripts/deploy-farm.js @@ -0,0 +1,26 @@ +const { ethers , upgrades} = require("hardhat"); + +const rewardAddress = '0x0000000000000000000000000000000000000000'; +const rewardPerBLock = '0'; + +async function main() { + let admin; + + [admin] = await ethers.getSigners(); + + console.log("Deploying contracts with the account:", admin.address); + console.log("Account balance:", (await admin.getBalance()).toString()); + + const Farm = await ethers.getContractFactory("Farm", admin); + const farm = await upgrades.deployProxy(Farm, [rewardAddress, rewardPerBLock]); + await farm.deployed(); + + console.log("Farm address: " + farm.address); +} + +main() +.then(() => process.exit(0)) +.catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/test/farm.test.js b/test/farm.test.js new file mode 100644 index 0000000..7b2ce99 --- /dev/null +++ b/test/farm.test.js @@ -0,0 +1,136 @@ +const { advanceBlock, latestNumber } = require('./helpers/time'); + +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const { parseEther } = require("ethers/lib/utils"); + +describe("Farm", () => { + beforeEach(async () => { + const signers = await ethers.getSigners(); + + this.dev = signers[0]; + this.alice = signers[1]; + this.bob = signers[2]; + + const BEP20 = await ethers.getContractFactory("BEP20"); + + this.reward = await BEP20.deploy('RewardToken', 'RWD'); + this.lp1 = await BEP20.deploy('LPToken', 'LP1'); + this.lp2 = await BEP20.deploy('LPToken', 'LP2'); + this.lp3 = await BEP20.deploy('LPToken', 'LP2'); + + const Farm = await ethers.getContractFactory("Farm"); + this.farm = await Farm.deploy() + await this.farm.initialize(this.reward.address, '100'); + + this.farmAsAlice = this.farm.connect(this.alice); + this.lp1AsAlice = this.lp1.connect(this.alice); + this.lp2AsAlice = this.lp2.connect(this.alice); + this.lp3AsAlice = this.lp3.connect(this.alice); + + await this.reward.mint(this.farm.address, parseEther('2000')) + + await this.lp1.mint(this.bob.address, '2000') + await this.lp2.mint(this.bob.address, '2000') + await this.lp3.mint(this.bob.address, '2000') + + await this.lp1.mint(this.alice.address, '2000') + await this.lp2.mint(this.alice.address, '2000') + await this.lp3.mint(this.alice.address, '2000') + }); + it('comlex scenario', async () => { + await this.farm.addPool(this.lp1.address, '2000'); + await this.farm.addPool(this.lp2.address, '200'); + await this.farm.addPool(this.lp3.address, '1000'); + + await this.lp1AsAlice.approve(this.farm.address, '1000'); + await this.lp2AsAlice.approve(this.farm.address, '1000'); + await this.lp3AsAlice.approve(this.farm.address, '1000'); + + await this.farmAsAlice.deposit(this.lp1.address, '1000'); + await this.farmAsAlice.deposit(this.lp2.address, '1000'); + await this.farmAsAlice.deposit(this.lp3.address, '1000'); + + for (let i = 0; i < 10; i++) { + await advanceBlock(); + } + + // const m = 1685365744 - 26 + await this.farm.updatePoolReward(this.lp1.address); + await this.farm.updatePoolReward(this.lp2.address); + await this.farm.updatePoolReward(this.lp3.address); + + let pool1 = await this.farm.poolInfos(this.lp1.address); + let pool2 = await this.farm.poolInfos(this.lp2.address); + let pool3 = await this.farm.poolInfos(this.lp3.address); + + expect(pool1.accRewardPerShare).to.be.eq('812000000000'); + expect(pool2.accRewardPerShare).to.be.eq('81000000000'); + expect(pool3.accRewardPerShare).to.be.eq('406000000000'); + + await this.farmAsAlice.withdraw(this.lp1.address, '0'); // 999 + expect(await this.reward.balanceOf(this.alice.address)).to.be.eq('999'); + await this.farmAsAlice.withdraw(this.lp2.address, '0'); // 99 + expect(await this.reward.balanceOf(this.alice.address)).to.be.eq('1098'); + await this.farmAsAlice.withdraw(this.lp3.address, '0'); // 599 + expect(await this.reward.balanceOf(this.alice.address)).to.be.eq('1597'); + }) + + it('deposit/withdraw', async () => { + await this.farm.addPool(this.lp1.address, '1000'); + + await this.lp1AsAlice.approve(this.farm.address, '2000'); + await this.farmAsAlice.deposit(this.lp1.address, '2000'); + + expect(await this.lp1.balanceOf(this.alice.address)).to.be.eq('0'); + expect(await this.lp1.balanceOf(this.farm.address)).to.be.eq('2000'); + + await this.farmAsAlice.withdraw(this.lp1.address, '1000'); + + expect(await this.lp1.balanceOf(this.alice.address)).to.be.eq('1000'); + expect(await this.lp1.balanceOf(this.farm.address)).to.be.eq('1000'); + }) + + it('emergency withdraw', async () => { + await this.farm.addPool(this.lp1.address, '1000'); + + await this.lp1AsAlice.approve(this.farm.address, '2000'); + await this.farmAsAlice.deposit(this.lp1.address, '2000'); + + expect(await this.lp1.balanceOf(this.alice.address)).to.be.eq('0'); + expect(await this.lp1.balanceOf(this.farm.address)).to.be.eq('2000'); + + await this.farm.pause(); + await this.farmAsAlice.emergencyWithdraw(this.lp1.address); + + expect(await this.lp1.balanceOf(this.alice.address)).to.be.eq('2000'); + expect(await this.lp1.balanceOf(this.farm.address)).to.be.eq('0'); + }) + + it('update allocation', async () => { + await this.farm.addPool(this.lp1.address, '1000'); + + let pool = await this.farm.poolInfos(this.lp1.address); + expect(pool.allocPoint).to.be.eq('1000'); + + await this.farm.updateAllocation(this.lp1.address, '2000'); + + pool = await this.farm.poolInfos(this.lp1.address); + expect(pool.allocPoint).to.be.eq('2000'); + }) + + it('update rewardPerBlock', async () => { + expect(await this.farm.rewardPerBlock()).to.be.eq('100'); + + await this.farm.setRewardPerBlock('200'); + + expect(await this.farm.rewardPerBlock()).to.be.eq('200'); + }) + + it('access control', async () => { + await expect(this.farmAsAlice.addPool(this.lp1.address, '1000')).to.be.revertedWith('Ownable: caller is not the owner'); + await expect(this.farmAsAlice.setRewardPerBlock('0')).to.be.revertedWith('Ownable: caller is not the owner'); + await expect(this.farmAsAlice.pause()).to.be.revertedWith('Ownable: caller is not the owner'); + await expect(this.farmAsAlice.updateAllocation(this.lp1.address, '1')).to.be.revertedWith('Ownable: caller is not the owner'); + }) +}); \ No newline at end of file diff --git a/test/helpers/time.js b/test/helpers/time.js new file mode 100644 index 0000000..5f3e7e8 --- /dev/null +++ b/test/helpers/time.js @@ -0,0 +1,34 @@ +const { ethers } = require("hardhat"); + +async function latest() { + const block = await ethers.provider.getBlock("latest") + return ethers.BigNumber.from(block.timestamp) +} + +async function latestNumber() { + const block = await ethers.provider.getBlock("latest") + return ethers.BigNumber.from(block.number) +} + +async function advanceBlock() { + await ethers.provider.send("evm_mine", []) +} + +async function increase(duration) { + await ethers.provider.send("evm_increaseTime", [Number(duration)]) + await advanceBlock() +} + +// async function increase2(integer) { +// // First we increase the time +// await web3.currentProvider.send({ +// jsonrpc: '2.0', +// method: 'evm_increaseTime', +// params: [Number(integer)], +// id: 0, +// }, () => {}); +// // Then we mine a block to actually get the time change to occurs +// await advanceBlock(); +// } + +module.exports = { latest, advanceBlock, increase, latestNumber} \ No newline at end of file