From 46f588bf68e8d430b6321f419fd06b3da9344087 Mon Sep 17 00:00:00 2001 From: kw Date: Thu, 22 Feb 2024 14:11:07 +0800 Subject: [PATCH 1/2] added staking contract --- README.md | 9 +- contracts/timelock/TimeLockStaking.sol | 271 ++++++++++++++++++ contracts/timelock/base/BaseToken.sol | 45 +++ .../timelock/interfaces/ITimeLockDeposits.sol | 5 + 4 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 contracts/timelock/TimeLockStaking.sol create mode 100644 contracts/timelock/base/BaseToken.sol create mode 100644 contracts/timelock/interfaces/ITimeLockDeposits.sol diff --git a/README.md b/README.md index 7c00eef..644fcc6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ | PersonaToken | This is implementation contract for VIRTUAL staking. PersonaFactory will clone this during VIRTUAL instantiation. Staked token is non-transferable. | - | N | | PersonaDAO | This is implementation contract for VIRTUAL specific DAO. PersonaFactory will clone this during VIRTUAL instantiation. It holds the maturity score for each core service. | - | N | | PersonaReward | This is reward distribution center. | Roles: GOV_ROLE, TOKEN_SAVER_ROLE | Y | +| TimeLockStaking | Allows user to stake their $VIRTUAL in exchange for $sVIRTUAL | Roles: GOV_ROLE, TOKEN_SAVER_ROLE | N | # Main Activities @@ -56,9 +57,5 @@ ## Staking VIRTUAL -1. Call **PersonaToken**.stake , pass in the validator that you would like to delegate your voting power to. It will take in $VIRTUAL and mint $*PERSONA* to you. -2. Call **PersonaToken**.withdraw to withdraw , will burn your $*PERSONA* and return $VIRTUAL to you. - - -**Notes** -Everything inside contracts/dev are placeholder contracts for development purpose, to simulate token bridging and TBA functionality. They will not be deployed. +1. Call **PersonaToken**.stake , pass in the validator that you would like to delegate your voting power to. It will take in $sVIRTUAL and mint $*PERSONA* to you. +2. Call **PersonaToken**.withdraw to withdraw , will burn your $*PERSONA* and return $sVIRTUAL to you. \ No newline at end of file diff --git a/contracts/timelock/TimeLockStaking.sol b/contracts/timelock/TimeLockStaking.sol new file mode 100644 index 0000000..8418c0d --- /dev/null +++ b/contracts/timelock/TimeLockStaking.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./base/BaseToken.sol"; +import "./interfaces/ITimeLockDeposits.sol"; + +contract TimeLockStaking is BaseToken, ITimeLockDeposits { + using Math for uint256; + using SafeERC20 for IERC20; + + uint256 public maxBonus; + uint256 public maxLockDuration; + uint256 public constant MIN_LOCK_DURATION = 10 minutes; + bool public isAllowPositionStaking = true; + bool public isAllowDeposits = true; + bool public isAdminUnlock = false; + uint256[] public curve; + uint256 public unit; + + error MaxBonusError(); + error ShortCurveError(); + error CurveIncreaseError(); + + mapping(address => Deposit[]) public depositsOf; + + struct Deposit { + uint256 amount; + uint256 shareAmount; + uint64 start; + uint64 end; + } + constructor( + string memory _name, + string memory _symbol, + address _depositToken, + uint256 _maxBonus, + uint256 _maxLockDuration, + uint256[] memory _curve + ) BaseToken(_name, _symbol, _depositToken) { + require(_maxLockDuration >= MIN_LOCK_DURATION, "TimeLockPool.constructor: max lock duration must be greater or equal to mininmum lock duration"); + maxBonus = _maxBonus; + maxLockDuration = _maxLockDuration; + checkCurve(_curve); + for (uint i=0; i < _curve.length; i++) { + if (_curve[i] > _maxBonus) { + revert MaxBonusError(); + } + curve.push(_curve[i]); + } + unit = _maxLockDuration / (curve.length - 1); + + } + + event Deposited(uint256 amount, uint256 shareAmount, uint256 duration, address indexed receiver, address indexed from); + event Withdrawn(uint256 indexed depositId, address indexed receiver, address indexed from, uint256 amount); + event CurveChanged(address indexed sender); + + + function deposit(uint256 _amount, uint256 _duration, address _receiver) external override { + require(isAllowDeposits, "Cannot deposit now"); + require(_amount > 0, "cannot deposit 0"); + // Don't allow locking > maxLockDuration + uint256 duration = _duration.min(maxLockDuration); + // Enforce min lockup duration to prevent flash loan or MEV transaction ordering + duration = duration.max(MIN_LOCK_DURATION); + + depositToken.safeTransferFrom(_msgSender(), address(this), _amount); + + uint256 mintAmount = _amount * getMultiplier(duration) / 1e18; + + depositsOf[_receiver].push(Deposit({ + amount: _amount, + shareAmount: mintAmount, + start: uint64(block.timestamp), + end: uint64(block.timestamp) + uint64(duration) + })); + + _mint(_receiver, mintAmount); + emit Deposited(_amount, mintAmount, duration, _receiver, _msgSender()); + } + + + function withdraw(uint256 _depositId, address _receiver) external { + require(_depositId < depositsOf[_msgSender()].length, "Deposit does not exist"); + Deposit memory userDeposit = depositsOf[_msgSender()][_depositId]; + if (!isAdminUnlock) { + require(block.timestamp >= userDeposit.end, "TimeLockPool.withdraw: too soon"); + } + + // User must have enough to wthdraw + require(balanceOf(_msgSender()) >= userDeposit.shareAmount, "User does not have enough Staking Tokens to withdraw"); + // remove Deposit + depositsOf[_msgSender()][_depositId] = depositsOf[_msgSender()][depositsOf[_msgSender()].length - 1]; + depositsOf[_msgSender()].pop(); + + // burn pool shares + _burn(_msgSender(), userDeposit.shareAmount); + + // return tokens + depositToken.safeTransfer(_receiver, userDeposit.amount); + emit Withdrawn(_depositId, _receiver, _msgSender(), userDeposit.amount); + } + + function getMultiplier(uint256 _lockDuration) public view returns(uint256) { + + uint n = _lockDuration / unit; + if (n == curve.length - 1) { + return 1e18 + curve[n]; + } + return 1e18 + curve[n] + (_lockDuration - n * unit) * (curve[n + 1] - curve[n]) / unit; + } + + + function getTotalDeposit(address _account) public view returns(uint256) { + uint256 total; + for(uint256 i = 0; i < depositsOf[_account].length; i++) { + total += depositsOf[_account][i].amount; + } + + return total; + } + + function getDepositsOf(address _account) public view returns(Deposit[] memory) { + return depositsOf[_account]; + } + + function getDepositsOfLength(address _account) public view returns(uint256) { + return depositsOf[_account].length; + } + + function adjustPositionStaking() external onlyGov { + isAllowPositionStaking = !isAllowPositionStaking; + } + + function adjustDeposits() external onlyGov { + isAllowDeposits = !isAllowDeposits; + } + + function adjustAdminUnlock() external onlyGov { + isAdminUnlock = !isAdminUnlock; + } + + function adjustMaxBonus(uint256 _maxBonus) external onlyGov { + maxBonus = _maxBonus; + } + + function adjustMaxLockPeriod(uint256 _maxLockDuration) external onlyGov { + maxLockDuration = _maxLockDuration; + } + + + function bankersRoundedDiv(uint256 a, uint256 b) internal pure returns (uint256) { + require(b > 0, "div by 0"); + + uint256 halfB = 0; + if ((b % 2) == 1) { + halfB = (b / 2) + 1; + } else { + halfB = b / 2; + } + bool roundUp = ((a % b) >= halfB); + + // now check if we are in the center! + bool isCenter = ((a % b) == (b / 2)); + bool isDownEven = (((a / b) % 2) == 0); + + // select the rounding type + if (isCenter) { + // only in this case we rounding either DOWN or UP + // depending on what number is even + roundUp = !isDownEven; + } + + // round + if (roundUp) { + return ((a / b) + 1); + }else{ + return (a / b); + } + } + + + function maxBonusError(uint256 _point) internal view returns(uint256) { + if (_point > maxBonus) { + revert MaxBonusError(); + } else { + return _point; + } + } + /** + * @notice Can set an entire new curve. + * @dev This function can change current curve by a completely new one. By doing so, it does not + * matter if the new curve's length is larger, equal, or shorter because the function manages + * all of those cases. + * @param _curve uint256 array of the points that compose the curve. + */ + function setCurve(uint256[] calldata _curve) external onlyGov { + // same length curves + if (curve.length == _curve.length) { + for (uint i=0; i < curve.length; i++) { + curve[i] = maxBonusError(_curve[i]); + } + // replacing with a shorter curve + } else if (curve.length > _curve.length) { + for (uint i=0; i < _curve.length; i++) { + curve[i] = maxBonusError(_curve[i]); + } + uint initialLength = curve.length; + for (uint j=0; j < initialLength - _curve.length; j++) { + curve.pop(); + } + unit = maxLockDuration / (curve.length - 1); + // replacing with a longer curve + } else { + for (uint i=0; i < curve.length; i++) { + curve[i] = maxBonusError(_curve[i]); + } + uint initialLength = curve.length; + for (uint j=0; j < _curve.length - initialLength; j++) { + curve.push(maxBonusError(_curve[initialLength + j])); + } + unit = maxLockDuration / (curve.length - 1); + } + checkCurve(curve); + emit CurveChanged(_msgSender()); + } + + /** + * @notice Can set a point of the curve. + * @dev This function can replace any point in the curve by inputing the existing index, + * add a point to the curve by using the index that equals the amount of points of the curve, + * and remove the last point of the curve if an index greater than the length is used. The first + * point of the curve index is zero. + * @param _newPoint uint256 point to be set. + * @param _position uint256 position of the array to be set (zero-based indexing convention). + */ + function setCurvePoint(uint256 _newPoint, uint256 _position) external onlyGov { + if (_newPoint > maxBonus) { + revert MaxBonusError(); + } + if (_position < curve.length) { + curve[_position] = _newPoint; + } else if (_position == curve.length) { + curve.push(_newPoint); + unit = maxLockDuration / (curve.length - 1); + } else { + if (curve.length - 1 < 2) { + revert ShortCurveError(); + } + curve.pop(); + unit = maxLockDuration / (curve.length - 1); + } + checkCurve(curve); + emit CurveChanged(_msgSender()); + } + + function checkCurve(uint256[] memory _curve) internal pure { + if (_curve.length < 2) { + revert ShortCurveError(); + } + for (uint256 i; i < _curve.length - 1; ++i) { + if (_curve[i + 1] < _curve[i]) { + revert CurveIncreaseError(); + } + } + } +} \ No newline at end of file diff --git a/contracts/timelock/base/BaseToken.sol b/contracts/timelock/base/BaseToken.sol new file mode 100644 index 0000000..c54ccc2 --- /dev/null +++ b/contracts/timelock/base/BaseToken.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +import "../../libs/TokenSaver.sol"; + +abstract contract BaseToken is ERC20Votes, AccessControl, TokenSaver { + using SafeERC20 for IERC20; + using SafeCast for uint256; + using SafeCast for int256; + + IERC20 public immutable depositToken; + + error NotGovError(); + + bytes32 public constant GOV_ROLE = keccak256("GOV_ROLE"); + + modifier onlyGov() { + _onlyGov(); + _; + } + + function _onlyGov() private view { + if (!hasRole(GOV_ROLE, _msgSender())) { + revert NotGovError(); + } + } + + + constructor( + string memory _name, + string memory _symbol, + address _depositToken + ) ERC20Permit(_name) ERC20(_name, _symbol) { + require(_depositToken != address(0), "BasePool.constructor: Deposit token must be set"); + depositToken = IERC20(_depositToken); + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + +} \ No newline at end of file diff --git a/contracts/timelock/interfaces/ITimeLockDeposits.sol b/contracts/timelock/interfaces/ITimeLockDeposits.sol new file mode 100644 index 0000000..6b82536 --- /dev/null +++ b/contracts/timelock/interfaces/ITimeLockDeposits.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; +interface ITimeLockDeposits { + function deposit(uint256 _amount, uint256 _duration, address _receiver) external; +} \ No newline at end of file From d14d84661ade0215b30c9d0fe6c209f22a498c61 Mon Sep 17 00:00:00 2001 From: kw Date: Thu, 22 Feb 2024 15:36:11 +0800 Subject: [PATCH 2/2] updated staking contract deployment --- contracts/timelock/base/BaseToken.sol | 35 ++++++++++++++++++++++----- scripts/arguments/stakingArguments.js | 8 ++++++ scripts/deployTimeLock.ts | 17 +++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 scripts/arguments/stakingArguments.js create mode 100644 scripts/deployTimeLock.ts diff --git a/contracts/timelock/base/BaseToken.sol b/contracts/timelock/base/BaseToken.sol index c54ccc2..66ed562 100644 --- a/contracts/timelock/base/BaseToken.sol +++ b/contracts/timelock/base/BaseToken.sol @@ -1,15 +1,21 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.24; +pragma solidity 0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import "../../libs/TokenSaver.sol"; -abstract contract BaseToken is ERC20Votes, AccessControl, TokenSaver { +abstract contract BaseToken is + ERC20Permit, + ERC20Votes, + AccessControl, + TokenSaver +{ using SafeERC20 for IERC20; using SafeCast for uint256; using SafeCast for int256; @@ -31,15 +37,32 @@ abstract contract BaseToken is ERC20Votes, AccessControl, TokenSaver { } } - constructor( string memory _name, string memory _symbol, address _depositToken ) ERC20Permit(_name) ERC20(_name, _symbol) { - require(_depositToken != address(0), "BasePool.constructor: Deposit token must be set"); + require( + _depositToken != address(0), + "BasePool.constructor: Deposit token must be set" + ); depositToken = IERC20(_depositToken); - _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); } -} \ No newline at end of file + // The following functions are overrides required by Solidity. + + function _update( + address from, + address to, + uint256 value + ) internal override(ERC20, ERC20Votes) { + super._update(from, to, value); + } + + function nonces( + address owner + ) public view override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } +} diff --git a/scripts/arguments/stakingArguments.js b/scripts/arguments/stakingArguments.js new file mode 100644 index 0000000..63d2249 --- /dev/null +++ b/scripts/arguments/stakingArguments.js @@ -0,0 +1,8 @@ +module.exports = [ + 'Staked VIRTUAL', + 'VAI', + process.env.BRIDGED_TOKEN, + 100000000000, // maxBonus + 1000, // maxLockDuration + [1, 1] // curve +]; \ No newline at end of file diff --git a/scripts/deployTimeLock.ts b/scripts/deployTimeLock.ts new file mode 100644 index 0000000..2b2d4bd --- /dev/null +++ b/scripts/deployTimeLock.ts @@ -0,0 +1,17 @@ +import { ethers } from "hardhat"; +const deployArguments = require("./arguments/stakingArguments"); + +(async () => { + try { + const contract = await ethers.deployContract( + "TimeLockStaking", + deployArguments + ); + + await contract.waitForDeployment(); + + console.log(`Staking Contract deployed to ${contract.target}`); + } catch (e) { + console.log(e); + } +})();