From 4f205b0127a3c374eee118796a177c253e229332 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:00:47 +0400 Subject: [PATCH 01/16] Add OGNRewardsSource contract --- contracts/OGNRewardsSource.sol | 156 ++++++++++++++++ contracts/tests/MockOGN.sol | 12 ++ contracts/upgrades/Initializable.sol | 39 ++++ contracts/upgrades/OGNRewardsSourceProxy.sol | 7 + tests/staking/OGNRewardsSource.t.sol | 181 +++++++++++++++++++ 5 files changed, 395 insertions(+) create mode 100644 contracts/OGNRewardsSource.sol create mode 100644 contracts/tests/MockOGN.sol create mode 100644 contracts/upgrades/Initializable.sol create mode 100644 contracts/upgrades/OGNRewardsSourceProxy.sol create mode 100644 tests/staking/OGNRewardsSource.t.sol diff --git a/contracts/OGNRewardsSource.sol b/contracts/OGNRewardsSource.sol new file mode 100644 index 00000000..3985e94c --- /dev/null +++ b/contracts/OGNRewardsSource.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import {Governable} from "./Governable.sol"; +import {Initializable} from "./upgrades/Initializable.sol"; +import "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/IERC20.sol"; + +contract OGNRewardsSource is Governable, Initializable { + error UnauthorizedCaller(); + error RewardsTargetNotSet(); + error InvalidRewardRate(); + + event StrategistUpdated(address _address); + event RewardsTargetChange(address target, address previousTarget); + event RewardsPerSecondChanged(uint256 newRPS, uint256 oldRPS); + event RewardCollected(uint256 amountCollected); + + address public immutable ogn; + + address public strategistAddr; + + address public rewardsTarget; + + struct RewardConfig { + // Inspired by (Copied from) `Dripper.Drip` struct. + uint64 lastCollect; // Overflows 262 billion years after the sun dies + uint192 rewardsPerSecond; + } + + RewardConfig public rewardConfig; + + /** + * @dev Verifies that the caller is either Governor or Strategist. + */ + modifier onlyGovernorOrStrategist() { + if (msg.sender != strategistAddr && !isGovernor()) { + revert UnauthorizedCaller(); + } + + _; + } + + constructor(address _ogn) { + ogn = _ogn; + } + + /// @dev Initialize the proxy implementation + /// @param _strategistAddr Address of the Strategist + /// @param _rewardsTarget Address that receives rewards + /// @param _rewardsPerSecond Rate of reward emission + function initialize(address _strategistAddr, address _rewardsTarget, uint256 _rewardsPerSecond) + external + initializer + { + _setStrategistAddr(_strategistAddr); + _setRewardsTarget(_rewardsTarget); + + // Rewards start from the moment the contract is initialized + rewardConfig.lastCollect = uint64(block.timestamp); + + _setRewardsPerSecond(_rewardsPerSecond); + } + + /// @dev Collect pending rewards + /// @return rewardAmount Amount of reward collected + function collectRewards() external returns (uint256) { + return _collectRewards(); + } + + /// @dev Collect pending rewards + /// @return rewardAmount Amount of reward collected + function _collectRewards() internal returns (uint256 rewardAmount) { + address _target = rewardsTarget; + if (_target == address(0)) { + revert RewardsTargetNotSet(); + } + + // Compute pending rewards + RewardConfig storage _config = rewardConfig; + rewardAmount = _previewRewards(_config); + + // Update timestamp + _config.lastCollect = uint64(block.timestamp); + + if (rewardAmount > 0) { + // Should not revert if there's no reward to transfer. + + emit RewardCollected(rewardAmount); + + // Intentionally skipping balance check to save some gas + // since `transfer` anyway would fail in case of low balance + IERC20(ogn).transfer(_target, rewardAmount); + } + } + + /// @dev Compute pending rewards since last collect + /// @return rewardAmount Amount of reward that'll be distributed if collected now + function previewRewards() external view returns (uint256) { + return _previewRewards(rewardConfig); + } + + /// @dev Compute pending rewards since last collect + /// @param _rewardConfig RewardConfig + /// @return rewardAmount Amount of reward that'll be distributed if collected now + function _previewRewards(RewardConfig memory _rewardConfig) internal view returns (uint256) { + return (block.timestamp - _rewardConfig.lastCollect) * rewardConfig.rewardsPerSecond; + } + + /// @dev Set address of the strategist + /// @param _address Address of the Strategist + function setStrategistAddr(address _address) external onlyGovernor { + _setStrategistAddr(_address); + } + + function _setStrategistAddr(address _address) internal { + emit StrategistUpdated(_address); + // Can be set to zero to disable + strategistAddr = _address; + } + + /// @dev Set the address of the contract than can collect rewards + /// @param _rewardsTarget contract address that can collect rewards + function setRewardsTarget(address _rewardsTarget) external onlyGovernor { + _setRewardsTarget(_rewardsTarget); + } + + /// @dev Set the address of the contract than can collect rewards + /// @param _rewardsTarget contract address that can collect rewards + function _setRewardsTarget(address _rewardsTarget) internal { + emit RewardsTargetChange(_rewardsTarget, rewardsTarget); + // Can be set to zero to disable + rewardsTarget = _rewardsTarget; + } + + /// @dev Set the rate of reward emission + /// @param _rewardsPerSecond Amount of OGN to distribute per second + function setRewardsPerSecond(uint256 _rewardsPerSecond) external onlyGovernorOrStrategist { + _setRewardsPerSecond(_rewardsPerSecond); + } + + /// @dev Set the rate of reward emission + /// @param _rewardsPerSecond Amount of OGN to distribute per second + function _setRewardsPerSecond(uint256 _rewardsPerSecond) internal { + if (_rewardsPerSecond > type(uint192).max) { + revert InvalidRewardRate(); + } + + // Collect any pending rewards at current rate + _collectRewards(); + + // Update storage + RewardConfig storage _config = rewardConfig; + emit RewardsPerSecondChanged(_rewardsPerSecond, _config.rewardsPerSecond); + _config.rewardsPerSecond = uint192(_rewardsPerSecond); + } +} diff --git a/contracts/tests/MockOGN.sol b/contracts/tests/MockOGN.sol new file mode 100644 index 00000000..952d3ea9 --- /dev/null +++ b/contracts/tests/MockOGN.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import {ERC20} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/ERC20.sol"; + +contract MockOGN is ERC20 { + constructor() ERC20("OGN", "OGN") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/contracts/upgrades/Initializable.sol b/contracts/upgrades/Initializable.sol new file mode 100644 index 00000000..ae20787f --- /dev/null +++ b/contracts/upgrades/Initializable.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title Base contract any contracts that need to initialize state after deployment. + * @author Origin Protocol Inc + */ +abstract contract Initializable { + /** + * @dev Indicates that the contract has been initialized. + */ + bool private initialized; + + /** + * @dev Indicates that the contract is in the process of being initialized. + */ + bool private initializing; + + /** + * @dev Modifier to protect an initializer function from being invoked twice. + */ + modifier initializer() { + require(initializing || !initialized, "Initializable: contract is already initialized"); + + bool isTopLevelCall = !initializing; + if (isTopLevelCall) { + initializing = true; + initialized = true; + } + + _; + + if (isTopLevelCall) { + initializing = false; + } + } + + uint256[50] private ______gap; +} diff --git a/contracts/upgrades/OGNRewardsSourceProxy.sol b/contracts/upgrades/OGNRewardsSourceProxy.sol new file mode 100644 index 00000000..b5b74297 --- /dev/null +++ b/contracts/upgrades/OGNRewardsSourceProxy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.10; + +import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol"; + +contract OGNRewardsSourceProxy is InitializeGovernedUpgradeabilityProxy {} diff --git a/tests/staking/OGNRewardsSource.t.sol b/tests/staking/OGNRewardsSource.t.sol new file mode 100644 index 00000000..4635263c --- /dev/null +++ b/tests/staking/OGNRewardsSource.t.sol @@ -0,0 +1,181 @@ +import "forge-std/Test.sol"; +import "contracts/upgrades/OGNRewardsSourceProxy.sol"; +import "contracts/OGNRewardsSource.sol"; +import "contracts/tests/MockOGN.sol"; + +contract OGNRewardsSourceTest is Test { + MockOGN ogn; + OGNRewardsSource rewards; + + address staking = address(0x42); + address governor = address(0x43); + address alice = address(0x44); + address strategist = address(0x45); + + function setUp() public { + vm.startPrank(governor); + ogn = new MockOGN(); + rewards = new OGNRewardsSource(address(ogn)); + + // Setup Rewards Proxy + OGNRewardsSourceProxy rewardsProxy = new OGNRewardsSourceProxy(); + rewardsProxy.initialize(address(rewards), governor, ""); + rewards = OGNRewardsSource(address(rewardsProxy)); + + // Configure Rewards + rewards.initialize(strategist, staking, uint192(100 ether)); // 100 OGN per second + + // Make sure contract has enough OGN for rewards + ogn.mint(address(rewardsProxy), 1000000 ether); + vm.stopPrank(); + } + + function testPreviewRewards() public { + // Should show correct rewards for a block + vm.warp(block.number + 100); + + assertEq(rewards.previewRewards(), 10000 ether, "Pending reward mismatch"); + + vm.warp(block.number + 149); + + assertEq(rewards.previewRewards(), 14900 ether, "Pending reward mismatch"); + } + + function testCollectRewards() public { + // Accumulate some rewards + vm.warp(block.number + 100); + + // Should allow collecting rewards + rewards.collectRewards(); + + assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); + + assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking"); + } + + function testCollectBeforeChangeRate() public { + // Accumulate some rewards + vm.warp(block.number + 100); + + // Should allow Strategist to change + vm.prank(strategist); + rewards.setRewardsPerSecond(1.25 ether); + + // Should've collected reward before change + assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking"); + } + + function testRevertWhenTargetNotSet() public { + // Disable rewards + vm.prank(governor); + rewards.setRewardsTarget(address(0)); + + // Time travel + vm.warp(block.number + 100); + + vm.expectRevert(bytes4(keccak256("RewardsTargetNotSet()"))); + rewards.collectRewards(); + } + + function testNoRevertCollect() public { + // Disable rewards + vm.prank(strategist); + rewards.setRewardsPerSecond(0 ether); + + // Time travel + vm.warp(block.number + 100); + + // Should allow collecting rewards + rewards.collectRewards(); + + // Shouldn't have any change + assertEq(ogn.balanceOf(address(staking)), 0 ether, "Invalid reward distributed"); + } + + function testDisableRewards() public { + // Should also allow disabling rewards + vm.prank(strategist); + rewards.setRewardsPerSecond(0); + + assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); + + vm.warp(block.number + 1234); + + assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); + } + + function testInvalidRewardRate() public { + vm.prank(strategist); + vm.expectRevert(bytes4(keccak256("InvalidRewardRate()"))); + rewards.setRewardsPerSecond(type(uint256).max); + } + + function testRewardRatePermission() public { + // Should allow Strategist to change + vm.prank(strategist); + rewards.setRewardsPerSecond(1 ether); + + // Should allow Governor to change + vm.prank(governor); + rewards.setRewardsPerSecond(2 ether); + + // Should not allow anyone else to change + vm.prank(alice); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); + rewards.setRewardsPerSecond(2 ether); + + vm.prank(staking); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); + rewards.setRewardsPerSecond(2 ether); + } + + function testDisableRewardsTarget() public { + // Should allow Governor to disable rewards + vm.prank(governor); + rewards.setRewardsTarget(address(0x0)); + + assertEq(rewards.rewardsTarget(), address(0x0), "Storage not updated"); + } + + function testSetRewardsTargetPermission() public { + // Should allow Governor to change + vm.prank(governor); + rewards.setRewardsTarget(address(0xdead)); + + assertEq(rewards.rewardsTarget(), address(0xdead), "Storage not updated"); + + // Should not allow anyone else to change + vm.prank(alice); + vm.expectRevert("Caller is not the Governor"); + rewards.setRewardsTarget(address(0xdead)); + + vm.prank(strategist); + vm.expectRevert("Caller is not the Governor"); + rewards.setRewardsTarget(address(0xdead)); + } + + function testDisableStrategistAddr() public { + // Should allow Governor to disable rewards + vm.prank(governor); + rewards.setStrategistAddr(address(0x0)); + + assertEq(rewards.strategistAddr(), address(0x0), "Storage not updated"); + } + + function testSetStrategistAddrPermission() public { + // Should allow Governor to change + vm.prank(governor); + rewards.setStrategistAddr(address(0xdead)); + + assertEq(rewards.strategistAddr(), address(0xdead), "Storage not updated"); + + // Should not allow anyone else to change + vm.prank(alice); + vm.expectRevert("Caller is not the Governor"); + rewards.setStrategistAddr(address(0xdead)); + + vm.prank(strategist); + vm.expectRevert("Caller is not the Governor"); + rewards.setStrategistAddr(address(0xdead)); + } +} From 8e45acc7608ab3d87805c07a702bc6f8307a5795 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:19:41 +0400 Subject: [PATCH 02/16] Make collectRewards only callable by RewardsTarget --- contracts/OGNRewardsSource.sol | 16 +++------------- tests/staking/OGNRewardsSource.t.sol | 28 ++++++++++++---------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/contracts/OGNRewardsSource.sol b/contracts/OGNRewardsSource.sol index 3985e94c..e7243600 100644 --- a/contracts/OGNRewardsSource.sol +++ b/contracts/OGNRewardsSource.sol @@ -7,7 +7,6 @@ import "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/IERC20.s contract OGNRewardsSource is Governable, Initializable { error UnauthorizedCaller(); - error RewardsTargetNotSet(); error InvalidRewardRate(); event StrategistUpdated(address _address); @@ -63,16 +62,10 @@ contract OGNRewardsSource is Governable, Initializable { /// @dev Collect pending rewards /// @return rewardAmount Amount of reward collected - function collectRewards() external returns (uint256) { - return _collectRewards(); - } - - /// @dev Collect pending rewards - /// @return rewardAmount Amount of reward collected - function _collectRewards() internal returns (uint256 rewardAmount) { + function collectRewards() external returns (uint256 rewardAmount) { address _target = rewardsTarget; - if (_target == address(0)) { - revert RewardsTargetNotSet(); + if (_target != msg.sender) { + revert UnauthorizedCaller(); } // Compute pending rewards @@ -145,9 +138,6 @@ contract OGNRewardsSource is Governable, Initializable { revert InvalidRewardRate(); } - // Collect any pending rewards at current rate - _collectRewards(); - // Update storage RewardConfig storage _config = rewardConfig; emit RewardsPerSecondChanged(_rewardsPerSecond, _config.rewardsPerSecond); diff --git a/tests/staking/OGNRewardsSource.t.sol b/tests/staking/OGNRewardsSource.t.sol index 4635263c..90a3673c 100644 --- a/tests/staking/OGNRewardsSource.t.sol +++ b/tests/staking/OGNRewardsSource.t.sol @@ -46,6 +46,7 @@ contract OGNRewardsSourceTest is Test { vm.warp(block.number + 100); // Should allow collecting rewards + vm.prank(staking); rewards.collectRewards(); assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); @@ -53,27 +54,21 @@ contract OGNRewardsSourceTest is Test { assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking"); } - function testCollectBeforeChangeRate() public { - // Accumulate some rewards + function testCollectPermission() public { + // Time travel vm.warp(block.number + 100); - // Should allow Strategist to change - vm.prank(strategist); - rewards.setRewardsPerSecond(1.25 ether); - - // Should've collected reward before change - assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking"); - } + // Should allow rewardsTarget to collect rewards + vm.prank(staking); + rewards.collectRewards(); - function testRevertWhenTargetNotSet() public { - // Disable rewards + // Should not allow anyone else to collect rewards vm.prank(governor); - rewards.setRewardsTarget(address(0)); - - // Time travel - vm.warp(block.number + 100); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); + rewards.collectRewards(); - vm.expectRevert(bytes4(keccak256("RewardsTargetNotSet()"))); + vm.prank(alice); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); rewards.collectRewards(); } @@ -86,6 +81,7 @@ contract OGNRewardsSourceTest is Test { vm.warp(block.number + 100); // Should allow collecting rewards + vm.prank(staking); rewards.collectRewards(); // Shouldn't have any change From ef72063340b0e18b4be83cc8d4c6528e4d858a72 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Wed, 17 Apr 2024 15:51:49 -0400 Subject: [PATCH 03/16] Draft xOGN staking contract --- contracts/ExponentialStaking.sol | 267 +++++++++++++ tests/staking/ExponentialStaking.t.sol | 523 +++++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 contracts/ExponentialStaking.sol create mode 100644 tests/staking/ExponentialStaking.t.sol diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol new file mode 100644 index 00000000..4ade9d69 --- /dev/null +++ b/contracts/ExponentialStaking.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import {ERC20Votes} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import {ERC20Permit} from + "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; +import {ERC20} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/ERC20.sol"; +import {PRBMathUD60x18} from "paulrberg/prb-math@2.5.0/contracts/PRBMathUD60x18.sol"; +import {RewardsSource} from "./RewardsSource.sol"; + +/// @title ExponentialStaking +/// @author Daniel Von Fange +/// @notice Provides staking, vote power history, vote delegation, and rewards +/// distribution. +/// +/// The balance received for staking (and thus the voting power and rewards +/// distribution) goes up exponentially by the end of the staked period. +contract ExponentialStaking is ERC20Votes { + uint256 public immutable epoch; // timestamp + ERC20 public immutable asset; // Must not allow reentrancy + RewardsSource public immutable rewardsSource; + uint256 public immutable minStakeDuration; // in seconds + uint256 public constant maxStakeDuration = 365 days; + uint256 constant YEAR_BASE = 14e17; + int256 constant NEW_STAKE = -1; + + // 2. Staking and Lockup Storage + struct Lockup { + uint128 amount; + uint128 end; + uint256 points; + } + + mapping(address => Lockup[]) public lockups; + + // 3. Reward Storage + mapping(address => uint256) public rewardDebtPerShare; + uint256 public accRewardPerShare; + + // Events + event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); + event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); + event Reward(address indexed user, uint256 amount); + + // Core ERC20 Functions + + constructor(address asset_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_) + ERC20("", "") + ERC20Permit("xOGN") + { + asset = ERC20(asset_); + epoch = epoch_; + minStakeDuration = minStakeDuration_; + rewardsSource = RewardsSource(rewardsSource_); + } + + function name() public pure override returns (string memory) { + return "Staked OGN"; + } + + function symbol() public pure override returns (string memory) { + return "xOGN"; + } + + function transfer(address, uint256) public override returns (bool) { + revert("Staking: Transfers disabled"); + } + + function transferFrom(address, address, uint256) public override returns (bool) { + revert("Staking: Transfers disabled"); + } + + // Staking Functions + + /// @notice Stake asset to an address that may not be the same as the + /// sender of the funds. This can be used to give staked funds to someone + /// else. + /// + /// If staking before the start of staking (epoch), then the lockup start + /// and end dates are shifted forward so that the lockup starts at the + /// epoch. + /// + /// Any rewards previously earned will be paid out or rolled into the stake. + /// + /// @param amountIn asset to lockup in the stake + /// @param duration in seconds for the stake + /// @param to address to receive ownership of the stake + /// @param stakeRewards should pending user rewards be added to the stake + /// @param lockupId previous stake to extend / add funds to. -1 to create a new stake. + function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external { + require(to != address(0), "Staking: To the zero address"); + require(duration >= minStakeDuration, "Staking: Too short"); + require(duration <= maxStakeDuration, "Staking: Too long"); + + uint256 newAmount = amountIn; + uint256 oldPoints = 0; + uint256 oldEnd = 0; + Lockup memory lockup; + + // Allow gifts, but not control of other's accounts + if (to != msg.sender) { + require(stakeRewards == false, "Staking: Self only"); + require(lockupId == NEW_STAKE, "Staking: Self only"); + } + + // Collect funds from user + if (amountIn > 0) { + // Important that `msg.sender` aways pays, not the `to` address. + asset.transferFrom(msg.sender, address(this), amountIn); + // amountIn already added into newAmount during initialization + } + + // Collect funds from old stake (optional) + if (lockupId != NEW_STAKE) { + lockup = lockups[to][uint256(lockupId)]; + uint256 oldAmount = lockup.amount; + oldEnd = lockup.end; + oldPoints = lockup.points; + require(oldAmount > 1, "Staking: Already closed stake"); + emit Unstake(to, uint256(lockupId), oldAmount, oldEnd, oldPoints); + newAmount += oldAmount; + } + + // Collect funds from rewards (optional) + newAmount += _collectRewards(to, stakeRewards); + + // Caculate Points and lockup + require(newAmount > 0, "Staking: Not enough"); + require(newAmount <= type(uint128).max, "Staking: Too much"); + (uint256 newPoints, uint256 newEnd) = previewPoints(newAmount, duration); + require(newPoints + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); + lockup.end = uint128(newEnd); + lockup.amount = uint128(newAmount); // max checked in require above + lockup.points = newPoints; + + // Update or create lockup + if (lockupId != NEW_STAKE) { + require(newEnd > oldEnd, "Staking: New lockup must be longer"); + lockups[to][uint256(lockupId)] = lockup; + } else { + lockups[to].push(lockup); + uint256 numLockups = lockups[to].length; + // Delegate voting power to the receiver, if unregistered and first stake + if (numLockups == 1 && delegates(to) == address(0)) { + _delegate(to, to); + } + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + } + _mint(to, newPoints - oldPoints); + emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); + } + + /// @notice Collect staked asset for a lockup and any earned rewards. + /// @param lockupId the id of the lockup to unstake + function unstake(uint256 lockupId) external { + Lockup memory lockup = lockups[msg.sender][lockupId]; + uint256 amount = lockup.amount; + uint256 end = lockup.end; + uint256 points = lockup.points; + require(end != 0, "Staking: Already unstaked this lockup"); + _collectRewards(msg.sender, false); + + uint256 withdrawAmount = previewWithdraw(amount, end); + uint256 penalty = amount - withdrawAmount; + + delete lockups[msg.sender][lockupId]; // Keeps empty in array, so indexes are stable + _burn(msg.sender, points); + if (penalty > 0) { + asset.transfer(address(rewardsSource), penalty); + } + asset.transfer(msg.sender, withdrawAmount); + emit Unstake(msg.sender, lockupId, withdrawAmount, end, points); + } + + // 3. Reward functions + + /// @notice Collect all earned asset rewards. + function collectRewards() external { + _collectRewards(msg.sender, false); + } + + /// @dev Internal function to handle rewards accounting. + /// + /// 1. Collect new rewards for everyone + /// 2. Calculate this user's rewards and accounting + /// 3. Distribute this user's rewards + /// + /// This function *must* be called before any user balance changes. + /// + /// This will always update the user's rewardDebtPerShare to match + /// accRewardPerShare, which is essential to the accounting. + /// + /// @param user to collect rewards for + /// @param shouldRetainRewards if true user's rewards kept in this contract rather than sent + /// @return retainedRewards amount of rewards not sent to user + function _collectRewards(address user, bool shouldRetainRewards) internal returns (uint256) { + uint256 supply = totalSupply(); + if (supply > 0) { + uint256 preBalance = asset.balanceOf(address(this)); + try rewardsSource.collectRewards() {} + catch { + // Governance staking should continue, even if rewards fail + } + uint256 collected = asset.balanceOf(address(this)) - preBalance; + accRewardPerShare += (collected * 1e12) / supply; + } + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; + uint256 netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + rewardDebtPerShare[user] = accRewardPerShare; + if (netRewards == 0) { + return 0; + } + emit Reward(user, netRewards); + if (shouldRetainRewards) { + return netRewards; + } else { + asset.transfer(user, netRewards); + } + } + + /// @notice Preview the number of points that would be returned for the + /// given amount and duration. + /// + /// @param amount asset to be staked + /// @param duration number of seconds to stake for + /// @return points staking points that would be returned + /// @return end staking period end date + function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { + require(duration <= 1461 days, "Staking: Too long"); + uint256 start = block.timestamp > epoch ? block.timestamp : epoch; + uint256 end = start + duration; + uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; + uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); + return ((amount * multiplier) / 1e18, end); + } + + /// @notice Preview the amount of asset a user would receive if they collected + /// rewards at this time. + /// + /// @param user to preview rewards for + /// @return asset rewards amount + function previewRewards(address user) external view returns (uint256) { + uint256 supply = totalSupply(); + if (supply == 0) { + return 0; // No one has any points to even get rewards + } + uint256 _accRewardPerShare = accRewardPerShare; + _accRewardPerShare += (rewardsSource.previewRewards() * 1e12) / supply; + uint256 netRewardsPerShare = _accRewardPerShare - rewardDebtPerShare[user]; + return (balanceOf(user) * netRewardsPerShare) / 1e12; + } + + /// @notice Preview the amount that a user would receive if they withdraw now. + /// This amount is after any early withdraw fees are removed for early withdraws. + /// @param amount staked asset amount to be withdrawn + /// @param end stake end date to be withdrawn from. + /// @return withdrawAmount amount of assets that the user will receive from withdraw + function previewWithdraw(uint256 amount, uint256 end) public view returns (uint256) { + if (block.timestamp >= end) { + return amount; + } + uint256 fullDuration = end - block.timestamp; + (uint256 fullPoints,) = previewPoints(1e18, fullDuration); + (uint256 currentPoints,) = previewPoints(1e36, 0); // 1e36 saves a later multiplication + return amount * ((currentPoints / fullPoints)) / 1e18; + } +} diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol new file mode 100644 index 00000000..470ef7dd --- /dev/null +++ b/tests/staking/ExponentialStaking.t.sol @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.10; + +import "forge-std/Test.sol"; +import "contracts/upgrades/RewardsSourceProxy.sol"; +import "contracts/upgrades/OgvStakingProxy.sol"; +import "contracts/ExponentialStaking.sol"; +import "contracts/RewardsSource.sol"; +import "contracts/tests/MockOgv.sol"; + +contract exponentialStakingTest is Test { + MockOGV ogn; + ExponentialStaking staking; + RewardsSource source; + + address alice = address(0x42); + address bob = address(0x43); + address team = address(0x44); + + uint256 constant EPOCH = 1 days; + uint256 constant MIN_STAKE_DURATION = 7 days; + int256 constant NEW_STAKE = -1; + + function setUp() public { + vm.startPrank(team); + ogn = new MockOGV(); + source = new RewardsSource(address(ogn)); + + RewardsSourceProxy rewardsProxy = new RewardsSourceProxy(); + rewardsProxy.initialize(address(source), team, ""); + source = RewardsSource(address(rewardsProxy)); + + staking = new ExponentialStaking(address(ogn), EPOCH, MIN_STAKE_DURATION, address(source)); + OgvStakingProxy stakingProxy = new OgvStakingProxy(); + stakingProxy.initialize(address(staking), team, ""); + staking = ExponentialStaking(address(stakingProxy)); + + source.setRewardsTarget(address(staking)); + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = 1; + slopes[0].ratePerDay = 0; + source.setInflation(slopes); // Add from start + assertGt(source.lastRewardTime(), 0); + vm.stopPrank(); + + ogn.mint(alice, 1000 ether); + ogn.mint(bob, 1000 ether); + ogn.mint(team, 100000000 ether); + + vm.prank(alice); + ogn.approve(address(staking), 1e70); + vm.prank(bob); + ogn.approve(address(staking), 1e70); + vm.prank(team); + ogn.approve(address(source), 1e70); + } + + function testStakeUnstake() public { + vm.startPrank(alice); + (uint256 previewPoints, uint256 previewEnd) = staking.previewPoints(10 ether, 10 days); + + uint256 beforeOgv = ogn.balanceOf(alice); + uint256 beforexOGN = ogn.balanceOf(address(staking)); + + staking.stake(10 ether, 10 days, alice, false, NEW_STAKE); + + assertEq(ogn.balanceOf(alice), beforeOgv - 10 ether); + assertEq(ogn.balanceOf(address(staking)), beforexOGN + 10 ether); + assertEq(staking.balanceOf(alice), previewPoints); + (uint128 lockupAmount, uint128 lockupEnd, uint256 lockupPoints) = staking.lockups(alice, 0); + assertEq(lockupAmount, 10 ether); + assertEq(lockupEnd, EPOCH + 10 days); + assertEq(lockupEnd, previewEnd); + assertEq(lockupPoints, previewPoints); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); + + vm.warp(31 days); + staking.unstake(0); + + assertEq(ogn.balanceOf(alice), beforeOgv); + assertEq(ogn.balanceOf(address(staking)), 0); + (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); + assertEq(lockupAmount, 0); + assertEq(lockupEnd, 0); + assertEq(lockupPoints, 0); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); + } + + function testMatchedDurations() public { + vm.prank(alice); + staking.stake(10 ether, 100 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 90 days); + vm.prank(bob); + staking.stake(10 ether, 10 days, bob, false, NEW_STAKE); + + // Now both have 10 OGV staked for 10 days remaining + // which should mean that they have the same number of points + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + } + + function testPreStaking() public { + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH); + vm.prank(bob); + staking.stake(100 ether, 100 days, bob, false, NEW_STAKE); + + // Both should have the same points + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + } + + function testZeroStake() public { + vm.prank(alice); + vm.expectRevert("Staking: Not enough"); + staking.stake(0 ether, 100 days, alice, false, NEW_STAKE); + } + + function testStakeTooMuch() public { + ogn.mint(alice, 1e70); + vm.prank(alice); + vm.expectRevert("Staking: Too much"); + staking.stake(1e70, 100 days, alice, false, NEW_STAKE); + } + + function testStakeTooLong() public { + vm.prank(alice); + vm.expectRevert("Staking: Too long"); + staking.stake(1 ether, 1700 days, alice, false, NEW_STAKE); + } + + function testStakeTooShort() public { + vm.prank(alice); + vm.expectRevert("Staking: Too short"); + staking.stake(1 ether, 1 days - 60, alice, false, NEW_STAKE); + } + + function testExtend() public { + vm.warp(EPOCH - 5); + + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 10 days, bob, false, NEW_STAKE); + staking.stake(0, 100 days, bob, false, 0); + + // Both are now locked up for the same amount of time, + // and should have the same points. + assertEq(staking.balanceOf(alice), staking.balanceOf(bob), "same balance"); + + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); + } + + function testDoubleExtend() public { + vm.warp(EPOCH + 600 days); + + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 10 days, bob, false, NEW_STAKE); + staking.stake(0, 50 days, bob, false, 0); + staking.stake(0, 100 days, bob, false, 0); + + // Both are now locked up for the same amount of time, + // and should have the same points. + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + } + + function testShortExtendFail() public { + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 11 days, bob, false, NEW_STAKE); + vm.expectRevert("Staking: New lockup must be longer"); + staking.stake(1 ether, 8 days, bob, false, 0); + } + + function testExtendWithAddtionalFunds() external { + vm.prank(alice); + staking.stake(100 ether, 90 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, 0); + + vm.prank(bob); + staking.stake(200 ether, 100 days, bob, false, NEW_STAKE); + + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + } + + function testExtendWithRewards() external { + vm.prank(alice); + staking.stake(100 ether, 90 days, alice, false, NEW_STAKE); + ogn.mint(address(source), 100 ether); + vm.warp(EPOCH - 1); + vm.prank(alice); + staking.stake(0 ether, 100 days, alice, true, 0); + + vm.prank(bob); + staking.stake(200 ether, 100 days, bob, false, NEW_STAKE); + + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + _assertApproxEqualAliceBob(); + } + + function testDoubleStake() external { + vm.startPrank(alice); + + uint256 beforeOgv = ogn.balanceOf(alice); + staking.stake(3 ether, 10 days, alice, false, NEW_STAKE); + uint256 midOgv = ogn.balanceOf(alice); + uint256 midPoints = staking.balanceOf(alice); + staking.stake(5 ether, 40 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 50 days); + staking.unstake(1); + + assertEq(midPoints, staking.balanceOf(alice)); + assertEq(midOgv, ogn.balanceOf(alice)); + + staking.unstake(0); + assertEq(0, staking.balanceOf(alice)); // No points, since all unstaked + assertEq(beforeOgv, ogn.balanceOf(alice)); // All OGV back + } + + function testCollectRewards() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 4 ether; + slopes[1].start = uint64(EPOCH + 2 days); + slopes[1].ratePerDay = 2 ether; + slopes[2].start = uint64(EPOCH + 7 days); + slopes[2].ratePerDay = 1 ether; + vm.prank(team); + source.setInflation(slopes); // Add from start + + vm.startPrank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 2 days); + uint256 beforeOgv = ogn.balanceOf(alice); + uint256 preview = staking.previewRewards(alice); + staking.collectRewards(); + uint256 afterOgv = ogn.balanceOf(alice); + + uint256 collectedRewards = afterOgv - beforeOgv; + assertApproxEqAbs(collectedRewards, 8 ether, 1e8, "actual amount should be correct"); + assertEq(collectedRewards, preview, "preview should match actual"); + assertApproxEqAbs(preview, 8 ether, 1e8, "preview amount should be correct"); + } + + function testCollectedRewardsJumpInOut() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + + // One day later + vm.warp(EPOCH + 1 days); + vm.prank(alice); + staking.collectRewards(); // Alice collects + + vm.prank(bob); + staking.stake(1 ether, 9 days, bob, false, NEW_STAKE); // Bob stakes + + vm.warp(EPOCH + 2 days); // Alice and bob should split rewards evenly + uint256 aliceBefore = ogn.balanceOf(alice); + uint256 bobBefore = ogn.balanceOf(bob); + vm.prank(alice); + staking.collectRewards(); // Alice collects + vm.prank(bob); + staking.collectRewards(); // Bob collects + assertEq(ogn.balanceOf(alice) - aliceBefore, ogn.balanceOf(bob) - bobBefore); + } + + function testMultipleUnstake() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.startPrank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.warp(EPOCH + 11 days); + staking.unstake(0); + vm.expectRevert("Staking: Already unstaked this lockup"); + staking.unstake(0); + } + + function testEarlyUnstake() public { + vm.startPrank(alice); + vm.warp(EPOCH); + staking.stake(1 ether, 200 days, alice, false, NEW_STAKE); + + // console.log("----"); + // for(uint256 i = 0; i < 721; i++){ + // console.log(i, staking.previewWithdraw(1e18, EPOCH + i * 1 days)); + // } + // console.log("----"); + + vm.warp(EPOCH + 100 days); + uint256 before = ogn.balanceOf(alice); + staking.unstake(0); + uint256 returnAmount = ogn.balanceOf(alice) - before; + assertEq(returnAmount, 911937178579591520); + } + + function testCollectRewardsOnExpand() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 10 days, bob, false, NEW_STAKE); + + vm.warp(EPOCH + 6 days); + + vm.prank(bob); + staking.collectRewards(); + vm.prank(alice); + staking.stake(0, 10 days, alice, false, 0); + + assertEq(ogn.balanceOf(alice), ogn.balanceOf(bob)); + } + + function testNoSupplyShortCircuts() public { + uint256 beforeAlice = ogn.balanceOf(alice); + + vm.prank(alice); + staking.previewRewards(alice); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(alice); + staking.collectRewards(); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(bob); + staking.stake(1 ether, 9 days, bob, false, NEW_STAKE); + + vm.prank(alice); + staking.previewRewards(alice); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(alice); + staking.collectRewards(); + assertEq(ogn.balanceOf(alice), beforeAlice); + } + + function testMultipleStakesSameBlock() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 4 ether; + slopes[1].start = uint64(EPOCH + 2 days); + slopes[1].ratePerDay = 2 ether; + slopes[2].start = uint64(EPOCH + 7 days); + slopes[2].ratePerDay = 1 ether; + vm.prank(team); + source.setInflation(slopes); // Add from start + + vm.prank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 9 days); + + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 90 days, bob, false, NEW_STAKE); + vm.prank(alice); + staking.stake(1 ether, 180 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 240 days, bob, false, NEW_STAKE); + vm.prank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.collectRewards(); + vm.prank(alice); + staking.collectRewards(); + } + + function testZeroSupplyRewardDebtPerShare() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 10 days, bob, false, NEW_STAKE); + + // Alice will unstake, setting her rewardDebtPerShare + vm.warp(EPOCH + 10 days); + vm.prank(alice); + staking.unstake(0); + + // Bob unstakes, setting the total supply to zero + vm.warp(EPOCH + 20 days); + vm.prank(bob); + staking.unstake(0); + + // Alice stakes. + // Even with the total supply being zero, it is important that + // Alice's rewardDebtPerShare per share be set to match the accRewardPerShare + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + + // Alice unstakes later. + // If rewardDebtPerShare was wrong, this will fail because she will + // try to collect more OGV than the contract has + vm.warp(EPOCH + 30 days); + vm.prank(alice); + staking.unstake(1); + } + + function testFuzzCanAlwaysWithdraw(uint96 amountA, uint96 amountB, uint64 durationA, uint64 durationB, uint64 start) + public + { + uint256 HUNDRED_YEARS = 100 * 366 days; + uint256 LAST_START = HUNDRED_YEARS - 366 days; + vm.warp(start % LAST_START); + + durationA = durationA % uint64(365 days); + durationB = durationB % uint64(365 days); + if (durationA < 7 days) { + durationA = 7 days; + } + if (durationB < 7 days) { + durationB = 7 days; + } + if (amountA < 1) { + amountA = 1; + } + if (amountB < 1) { + amountB = 1; + } + + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + ogn.mint(alice, amountA); + vm.prank(alice); + ogn.approve(address(staking), amountA); + vm.prank(alice); + staking.stake(amountA, durationA, alice, false, NEW_STAKE); + + vm.prank(bob); + ogn.mint(bob, amountB); + vm.prank(bob); + ogn.approve(address(staking), amountB); + vm.prank(bob); + staking.stake(amountB, durationB, bob, false, NEW_STAKE); + + vm.warp(HUNDRED_YEARS); + vm.prank(alice); + staking.unstake(0); + vm.prank(bob); + staking.unstake(0); + } + + function testFuzzSemiSanePowerFunction(uint256 start) public { + uint256 HUNDRED_YEARS = 100 * 366 days; + start = start % HUNDRED_YEARS; + vm.warp(start); + vm.prank(bob); + staking.stake(1e18, 10 days, bob, false, NEW_STAKE); + uint256 y = (356 days + start + 10 days) / 365 days; + uint256 maxPoints = 2 ** y * 1e18; + assertLt(staking.balanceOf(bob), maxPoints); + } + + function _assertApproxEqualAliceBob() internal { + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertLt(aliceAmount, bobAmount * 100001 / 100000, "same amount"); + assertLt(aliceEnd, bobEnd * 100001 / 100000, "same end"); + assertLt(alicePoints, bobPoints * 100001 / 100000, "same points"); + + assertGt(aliceAmount, bobAmount * 99999 / 100000, "same amount"); + assertGt(aliceEnd, bobEnd * 99999 / 100000, "same end"); + assertGt(alicePoints, bobPoints * 99999 / 100000, "same points"); + } +} From 1126cf2064a2ad096a6b445d39b6746b6e274081 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 18 Apr 2024 09:16:37 -0400 Subject: [PATCH 04/16] Correct maxStakeDuration --- contracts/ExponentialStaking.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 4ade9d69..814381fd 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -90,7 +90,7 @@ contract ExponentialStaking is ERC20Votes { function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external { require(to != address(0), "Staking: To the zero address"); require(duration >= minStakeDuration, "Staking: Too short"); - require(duration <= maxStakeDuration, "Staking: Too long"); + // Too long checked in preview points uint256 newAmount = amountIn; uint256 oldPoints = 0; @@ -226,7 +226,7 @@ contract ExponentialStaking is ERC20Votes { /// @return points staking points that would be returned /// @return end staking period end date function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { - require(duration <= 1461 days, "Staking: Too long"); + require(duration <= maxStakeDuration, "Staking: Too long"); uint256 start = block.timestamp > epoch ? block.timestamp : epoch; uint256 end = start + duration; uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; From cd1bfdab2af8e6bf73f39c150d18d269dbb053ee Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 18 Apr 2024 09:18:32 -0400 Subject: [PATCH 05/16] Add penalty event --- contracts/ExponentialStaking.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 814381fd..bce03f47 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -41,6 +41,7 @@ contract ExponentialStaking is ERC20Votes { event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Reward(address indexed user, uint256 amount); + event Penalty(address indexed user, uint256 amount); // Core ERC20 Functions @@ -167,6 +168,7 @@ contract ExponentialStaking is ERC20Votes { _burn(msg.sender, points); if (penalty > 0) { asset.transfer(address(rewardsSource), penalty); + emit Penalty(msg.sender, penalty); } asset.transfer(msg.sender, withdrawAmount); emit Unstake(msg.sender, lockupId, withdrawAmount, end, points); From ff307623f03ae3bd3f34372e10c5404b71713e57 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:24:38 +0530 Subject: [PATCH 06/16] Change names --- ...ewardsSource.sol => FixedRateRewardsSource.sol} | 14 +++++++------- ...ceProxy.sol => FixedRateRewardsSourceProxy.sol} | 2 +- tests/staking/OGNRewardsSource.t.sol | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) rename contracts/{OGNRewardsSource.sol => FixedRateRewardsSource.sol} (92%) rename contracts/upgrades/{OGNRewardsSourceProxy.sol => FixedRateRewardsSourceProxy.sol} (66%) diff --git a/contracts/OGNRewardsSource.sol b/contracts/FixedRateRewardsSource.sol similarity index 92% rename from contracts/OGNRewardsSource.sol rename to contracts/FixedRateRewardsSource.sol index e7243600..e07d2c32 100644 --- a/contracts/OGNRewardsSource.sol +++ b/contracts/FixedRateRewardsSource.sol @@ -5,7 +5,7 @@ import {Governable} from "./Governable.sol"; import {Initializable} from "./upgrades/Initializable.sol"; import "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/IERC20.sol"; -contract OGNRewardsSource is Governable, Initializable { +contract FixedRateRewardsSource is Governable, Initializable { error UnauthorizedCaller(); error InvalidRewardRate(); @@ -14,7 +14,7 @@ contract OGNRewardsSource is Governable, Initializable { event RewardsPerSecondChanged(uint256 newRPS, uint256 oldRPS); event RewardCollected(uint256 amountCollected); - address public immutable ogn; + address public immutable rewardToken; address public strategistAddr; @@ -39,8 +39,8 @@ contract OGNRewardsSource is Governable, Initializable { _; } - constructor(address _ogn) { - ogn = _ogn; + constructor(address _rewardToken) { + rewardToken = _rewardToken; } /// @dev Initialize the proxy implementation @@ -82,7 +82,7 @@ contract OGNRewardsSource is Governable, Initializable { // Intentionally skipping balance check to save some gas // since `transfer` anyway would fail in case of low balance - IERC20(ogn).transfer(_target, rewardAmount); + IERC20(rewardToken).transfer(_target, rewardAmount); } } @@ -126,13 +126,13 @@ contract OGNRewardsSource is Governable, Initializable { } /// @dev Set the rate of reward emission - /// @param _rewardsPerSecond Amount of OGN to distribute per second + /// @param _rewardsPerSecond Amount of rewardToken to distribute per second function setRewardsPerSecond(uint256 _rewardsPerSecond) external onlyGovernorOrStrategist { _setRewardsPerSecond(_rewardsPerSecond); } /// @dev Set the rate of reward emission - /// @param _rewardsPerSecond Amount of OGN to distribute per second + /// @param _rewardsPerSecond Amount of rewardToken to distribute per second function _setRewardsPerSecond(uint256 _rewardsPerSecond) internal { if (_rewardsPerSecond > type(uint192).max) { revert InvalidRewardRate(); diff --git a/contracts/upgrades/OGNRewardsSourceProxy.sol b/contracts/upgrades/FixedRateRewardsSourceProxy.sol similarity index 66% rename from contracts/upgrades/OGNRewardsSourceProxy.sol rename to contracts/upgrades/FixedRateRewardsSourceProxy.sol index b5b74297..ac48b516 100644 --- a/contracts/upgrades/OGNRewardsSourceProxy.sol +++ b/contracts/upgrades/FixedRateRewardsSourceProxy.sol @@ -4,4 +4,4 @@ pragma solidity 0.8.10; import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol"; -contract OGNRewardsSourceProxy is InitializeGovernedUpgradeabilityProxy {} +contract FixedRateRewardsSourceProxy is InitializeGovernedUpgradeabilityProxy {} diff --git a/tests/staking/OGNRewardsSource.t.sol b/tests/staking/OGNRewardsSource.t.sol index 90a3673c..91373da9 100644 --- a/tests/staking/OGNRewardsSource.t.sol +++ b/tests/staking/OGNRewardsSource.t.sol @@ -1,11 +1,11 @@ import "forge-std/Test.sol"; -import "contracts/upgrades/OGNRewardsSourceProxy.sol"; -import "contracts/OGNRewardsSource.sol"; +import "contracts/upgrades/FixedRateRewardsSourceProxy.sol"; +import "contracts/FixedRateRewardsSource.sol"; import "contracts/tests/MockOGN.sol"; -contract OGNRewardsSourceTest is Test { +contract FixedRateRewardsSourceTest is Test { MockOGN ogn; - OGNRewardsSource rewards; + FixedRateRewardsSource rewards; address staking = address(0x42); address governor = address(0x43); @@ -15,12 +15,12 @@ contract OGNRewardsSourceTest is Test { function setUp() public { vm.startPrank(governor); ogn = new MockOGN(); - rewards = new OGNRewardsSource(address(ogn)); + rewards = new FixedRateRewardsSource(address(ogn)); // Setup Rewards Proxy - OGNRewardsSourceProxy rewardsProxy = new OGNRewardsSourceProxy(); + FixedRateRewardsSourceProxy rewardsProxy = new FixedRateRewardsSourceProxy(); rewardsProxy.initialize(address(rewards), governor, ""); - rewards = OGNRewardsSource(address(rewardsProxy)); + rewards = FixedRateRewardsSource(address(rewardsProxy)); // Configure Rewards rewards.initialize(strategist, staking, uint192(100 ether)); // 100 OGN per second From 3f51c2d70308499b4981396bbae19bb4920a6348 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:32:38 +0530 Subject: [PATCH 07/16] Fix lockup ID --- contracts/ExponentialStaking.sol | 8 +++++--- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index bce03f47..89573994 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -139,13 +139,15 @@ contract ExponentialStaking is ERC20Votes { require(newEnd > oldEnd, "Staking: New lockup must be longer"); lockups[to][uint256(lockupId)] = lockup; } else { + lockupId = lockups[to].length; + require(lockupId < uint256(type(int256).max), "Staking: Too many lockups"); + lockups[to].push(lockup); - uint256 numLockups = lockups[to].length; + // Delegate voting power to the receiver, if unregistered and first stake - if (numLockups == 1 && delegates(to) == address(0)) { + if (lockupId == 0 && delegates(to) == address(0)) { _delegate(to, to); } - require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 470ef7dd..7a8ca722 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -6,7 +6,7 @@ import "contracts/upgrades/RewardsSourceProxy.sol"; import "contracts/upgrades/OgvStakingProxy.sol"; import "contracts/ExponentialStaking.sol"; import "contracts/RewardsSource.sol"; -import "contracts/tests/MockOgv.sol"; +import "contracts/tests/MockOGV.sol"; contract exponentialStakingTest is Test { MockOGV ogn; From c02d6b56053394ea277b3164eaede64b675aed88 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:39:04 +0530 Subject: [PATCH 08/16] Revert change and cast properly --- contracts/ExponentialStaking.sol | 9 ++++----- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 89573994..33194e05 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -139,15 +139,14 @@ contract ExponentialStaking is ERC20Votes { require(newEnd > oldEnd, "Staking: New lockup must be longer"); lockups[to][uint256(lockupId)] = lockup; } else { - lockupId = lockups[to].length; - require(lockupId < uint256(type(int256).max), "Staking: Too many lockups"); - lockups[to].push(lockup); - + uint256 numLockups = lockups[to].length; // Delegate voting power to the receiver, if unregistered and first stake - if (lockupId == 0 && delegates(to) == address(0)) { + if (numLockups == 1 && delegates(to) == address(0)) { _delegate(to, to); } + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + lockupId = int256(numLockups - 1); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 7a8ca722..4bbb2be0 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -8,7 +8,7 @@ import "contracts/ExponentialStaking.sol"; import "contracts/RewardsSource.sol"; import "contracts/tests/MockOGV.sol"; -contract exponentialStakingTest is Test { +contract ExponentialStakingTest is Test { MockOGV ogn; ExponentialStaking staking; RewardsSource source; From 7e0daae291d3050359bbf1cfa20e164abdba3203 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Mon, 22 Apr 2024 17:24:44 +0530 Subject: [PATCH 09/16] Gas opts --- contracts/FixedRateRewardsSource.sol | 18 ++++++------------ ...urce.t.sol => FixedRateRewardsSource.t.sol} | 0 2 files changed, 6 insertions(+), 12 deletions(-) rename tests/staking/{OGNRewardsSource.t.sol => FixedRateRewardsSource.t.sol} (100%) diff --git a/contracts/FixedRateRewardsSource.sol b/contracts/FixedRateRewardsSource.sol index e07d2c32..8ffd19c8 100644 --- a/contracts/FixedRateRewardsSource.sol +++ b/contracts/FixedRateRewardsSource.sol @@ -69,11 +69,10 @@ contract FixedRateRewardsSource is Governable, Initializable { } // Compute pending rewards - RewardConfig storage _config = rewardConfig; - rewardAmount = _previewRewards(_config); + rewardAmount = previewRewards(); // Update timestamp - _config.lastCollect = uint64(block.timestamp); + rewardConfig.lastCollect = uint64(block.timestamp); if (rewardAmount > 0) { // Should not revert if there's no reward to transfer. @@ -88,15 +87,10 @@ contract FixedRateRewardsSource is Governable, Initializable { /// @dev Compute pending rewards since last collect /// @return rewardAmount Amount of reward that'll be distributed if collected now - function previewRewards() external view returns (uint256) { - return _previewRewards(rewardConfig); - } - - /// @dev Compute pending rewards since last collect - /// @param _rewardConfig RewardConfig - /// @return rewardAmount Amount of reward that'll be distributed if collected now - function _previewRewards(RewardConfig memory _rewardConfig) internal view returns (uint256) { - return (block.timestamp - _rewardConfig.lastCollect) * rewardConfig.rewardsPerSecond; + function previewRewards() public view returns (uint256) { + RewardConfig memory _config = rewardConfig; + return (block.timestamp - _config.lastCollect) * _config.rewardsPerSecond; + // return _previewRewards(rewardConfig); } /// @dev Set address of the strategist diff --git a/tests/staking/OGNRewardsSource.t.sol b/tests/staking/FixedRateRewardsSource.t.sol similarity index 100% rename from tests/staking/OGNRewardsSource.t.sol rename to tests/staking/FixedRateRewardsSource.t.sol From db52be36047f77fab609d280a4abe0235e3a668f Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 24 Apr 2024 20:49:29 +0530 Subject: [PATCH 10/16] Remove casting --- contracts/FixedRateRewardsSource.sol | 12 ++++-------- tests/staking/FixedRateRewardsSource.t.sol | 6 ------ 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/contracts/FixedRateRewardsSource.sol b/contracts/FixedRateRewardsSource.sol index 8ffd19c8..2c51f4d4 100644 --- a/contracts/FixedRateRewardsSource.sol +++ b/contracts/FixedRateRewardsSource.sol @@ -47,7 +47,7 @@ contract FixedRateRewardsSource is Governable, Initializable { /// @param _strategistAddr Address of the Strategist /// @param _rewardsTarget Address that receives rewards /// @param _rewardsPerSecond Rate of reward emission - function initialize(address _strategistAddr, address _rewardsTarget, uint256 _rewardsPerSecond) + function initialize(address _strategistAddr, address _rewardsTarget, uint192 _rewardsPerSecond) external initializer { @@ -121,20 +121,16 @@ contract FixedRateRewardsSource is Governable, Initializable { /// @dev Set the rate of reward emission /// @param _rewardsPerSecond Amount of rewardToken to distribute per second - function setRewardsPerSecond(uint256 _rewardsPerSecond) external onlyGovernorOrStrategist { + function setRewardsPerSecond(uint192 _rewardsPerSecond) external onlyGovernorOrStrategist { _setRewardsPerSecond(_rewardsPerSecond); } /// @dev Set the rate of reward emission /// @param _rewardsPerSecond Amount of rewardToken to distribute per second - function _setRewardsPerSecond(uint256 _rewardsPerSecond) internal { - if (_rewardsPerSecond > type(uint192).max) { - revert InvalidRewardRate(); - } - + function _setRewardsPerSecond(uint192 _rewardsPerSecond) internal { // Update storage RewardConfig storage _config = rewardConfig; emit RewardsPerSecondChanged(_rewardsPerSecond, _config.rewardsPerSecond); - _config.rewardsPerSecond = uint192(_rewardsPerSecond); + _config.rewardsPerSecond = _rewardsPerSecond; } } diff --git a/tests/staking/FixedRateRewardsSource.t.sol b/tests/staking/FixedRateRewardsSource.t.sol index 91373da9..fce7c11b 100644 --- a/tests/staking/FixedRateRewardsSource.t.sol +++ b/tests/staking/FixedRateRewardsSource.t.sol @@ -100,12 +100,6 @@ contract FixedRateRewardsSourceTest is Test { assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); } - function testInvalidRewardRate() public { - vm.prank(strategist); - vm.expectRevert(bytes4(keccak256("InvalidRewardRate()"))); - rewards.setRewardsPerSecond(type(uint256).max); - } - function testRewardRatePermission() public { // Should allow Strategist to change vm.prank(strategist); From 820fccf79076e35a8edd20805790a62c52e9b815 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:53:41 +0530 Subject: [PATCH 11/16] Add `getLockupsCount` method (#411) --- contracts/ExponentialStaking.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 33194e05..63fbecff 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -267,4 +267,12 @@ contract ExponentialStaking is ERC20Votes { (uint256 currentPoints,) = previewPoints(1e36, 0); // 1e36 saves a later multiplication return amount * ((currentPoints / fullPoints)) / 1e18; } + + /// @notice Returns the total number of lockups the user has + /// created so far (including expired & unstaked ones) + /// @param user Address + /// @return asset Number of lockups the user has had + function getLockupsCount(address user) external view returns (uint256) { + return lockups[user].length; + } } From 37843333f8a98e0feeb0fd6b6ebda6145a243fe0 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Wed, 24 Apr 2024 12:42:29 -0400 Subject: [PATCH 12/16] Allow non-duration change amount increase staking extends --- contracts/ExponentialStaking.sol | 5 +++-- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 63fbecff..dd765b7a 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -16,7 +16,7 @@ import {RewardsSource} from "./RewardsSource.sol"; /// The balance received for staking (and thus the voting power and rewards /// distribution) goes up exponentially by the end of the staked period. contract ExponentialStaking is ERC20Votes { - uint256 public immutable epoch; // timestamp + uint256 public immutable epoch; // Start of staking program - timestamp ERC20 public immutable asset; // Must not allow reentrancy RewardsSource public immutable rewardsSource; uint256 public immutable minStakeDuration; // in seconds @@ -136,7 +136,8 @@ contract ExponentialStaking is ERC20Votes { // Update or create lockup if (lockupId != NEW_STAKE) { - require(newEnd > oldEnd, "Staking: New lockup must be longer"); + require(newEnd >= oldEnd, "Staking: New lockup must not be shorter"); + require(newPoints > oldPoints, "Staking: Must have increased amount or duration"); lockups[to][uint256(lockupId)] = lockup; } else { lockups[to].push(lockup); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 4bbb2be0..83b4adf1 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -186,7 +186,7 @@ contract ExponentialStakingTest is Test { vm.startPrank(bob); staking.stake(100 ether, 11 days, bob, false, NEW_STAKE); - vm.expectRevert("Staking: New lockup must be longer"); + vm.expectRevert("Staking: New lockup must not be shorter"); staking.stake(1 ether, 8 days, bob, false, 0); } From 1864b688bbe8c7fc597c014c07928d4df7a2911f Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 25 Apr 2024 11:31:52 -0400 Subject: [PATCH 13/16] Add tests, add move lockupid code --- contracts/ExponentialStaking.sol | 6 +-- tests/staking/ExponentialStaking.t.sol | 66 +++++++++++++++++++++----- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index dd765b7a..e43ea552 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -142,12 +142,12 @@ contract ExponentialStaking is ERC20Votes { } else { lockups[to].push(lockup); uint256 numLockups = lockups[to].length; + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + lockupId = int256(numLockups - 1); // Delegate voting power to the receiver, if unregistered and first stake if (numLockups == 1 && delegates(to) == address(0)) { _delegate(to, to); } - require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); - lockupId = int256(numLockups - 1); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); @@ -273,7 +273,7 @@ contract ExponentialStaking is ERC20Votes { /// created so far (including expired & unstaked ones) /// @param user Address /// @return asset Number of lockups the user has had - function getLockupsCount(address user) external view returns (uint256) { + function lockupsCount(address user) external view returns (uint256) { return lockups[user].length; } } diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 83b4adf1..f4b608b0 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -61,9 +61,11 @@ contract ExponentialStakingTest is Test { uint256 beforeOgv = ogn.balanceOf(alice); uint256 beforexOGN = ogn.balanceOf(address(staking)); + assertEq(staking.lockupsCount(alice), 0); staking.stake(10 ether, 10 days, alice, false, NEW_STAKE); + assertEq(staking.lockupsCount(alice), 1); assertEq(ogn.balanceOf(alice), beforeOgv - 10 ether); assertEq(ogn.balanceOf(address(staking)), beforexOGN + 10 ether); assertEq(staking.balanceOf(alice), previewPoints); @@ -77,6 +79,7 @@ contract ExponentialStakingTest is Test { vm.warp(31 days); staking.unstake(0); + assertEq(staking.lockupsCount(alice), 1); assertEq(ogn.balanceOf(alice), beforeOgv); assertEq(ogn.balanceOf(address(staking)), 0); (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); @@ -158,6 +161,39 @@ contract ExponentialStakingTest is Test { assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); } + function testExtendOnOtherUser() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + + vm.expectRevert("Staking: Self only"); + vm.prank(bob); + staking.stake(1 ether, 60 days, alice, false, 0); + + vm.expectRevert("Staking: Self only"); + vm.prank(bob); + staking.stake(1 ether, 60 days, alice, true, NEW_STAKE); + } + + function testExtendOnClosed() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.unstake(0); + + vm.expectRevert("Staking: Already closed stake"); + vm.prank(alice); + staking.stake(1 ether, 80 days, alice, false, 0); + } + + function testExtendNoChange() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + + vm.expectRevert("Staking: Must have increased amount or duration"); + vm.prank(alice); + staking.stake(0, 60 days, alice, false, 0); + } + function testDoubleExtend() public { vm.warp(EPOCH + 600 days); @@ -300,13 +336,6 @@ contract ExponentialStakingTest is Test { } function testMultipleUnstake() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - - vm.prank(team); - source.setInflation(slopes); - vm.startPrank(alice); staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); vm.warp(EPOCH + 11 days); @@ -315,22 +344,29 @@ contract ExponentialStakingTest is Test { staking.unstake(0); } + function testUnstakeNeverStaked() public { + vm.startPrank(alice); + vm.expectRevert(); + staking.unstake(0); + } + function testEarlyUnstake() public { vm.startPrank(alice); vm.warp(EPOCH); staking.stake(1 ether, 200 days, alice, false, NEW_STAKE); - // console.log("----"); - // for(uint256 i = 0; i < 721; i++){ - // console.log(i, staking.previewWithdraw(1e18, EPOCH + i * 1 days)); - // } - // console.log("----"); - vm.warp(EPOCH + 100 days); uint256 before = ogn.balanceOf(alice); + uint256 beforeCollected = ogn.balanceOf(address(source)); + uint256 expectedWithdraw = staking.previewWithdraw(1 ether, EPOCH + 200 days); + staking.unstake(0); + uint256 returnAmount = ogn.balanceOf(alice) - before; assertEq(returnAmount, 911937178579591520); + assertEq(expectedWithdraw, returnAmount); + uint256 penaltyCollected = ogn.balanceOf(address(source)) - beforeCollected; + assertEq(penaltyCollected, 1 ether - 911937178579591520); } function testCollectRewardsOnExpand() public { @@ -479,8 +515,12 @@ contract ExponentialStakingTest is Test { ogn.mint(alice, amountA); vm.prank(alice); ogn.approve(address(staking), amountA); + assertEq(staking.balanceOf(alice), 0); + // preview check + (uint256 expectedPoints,) = staking.previewPoints(amountA, durationA); vm.prank(alice); staking.stake(amountA, durationA, alice, false, NEW_STAKE); + assertEq(staking.balanceOf(alice), expectedPoints); vm.prank(bob); ogn.mint(bob, amountB); From 3c3b7993b40b5c21525722fda459b983251b65bd Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sat, 27 Apr 2024 01:38:26 +0530 Subject: [PATCH 14/16] Add Migrator (#410) * Add Migrator contract * Fix some tests * Code review changes * Update OgvStaking tests * Disable delegation tests * Allow just unstakes * Fix comment * More cleanup * Fix brownie tests --- contracts/Migrator.sol | 228 ++++++++++ contracts/OgvStaking.sol | 207 +++++---- contracts/tests/MockOGN.sol | 14 + contracts/tests/MockOGV.sol | 8 + contracts/tests/MockOGVStaking.sol | 59 +++ contracts/tests/MockRewardsSource.sol | 14 + contracts/upgrades/MigratorProxy.sol | 7 + scripts/deploy_staking.py | 8 +- tests/distribution/test_mandatory_lockup.py | 15 +- tests/distribution/test_optional_lockup.py | 17 +- tests/fixtures.py | 4 +- tests/governance/test_vote.py | 4 +- tests/staking/DelegationTest.t.sol | 460 +++++++++---------- tests/staking/Migrator.t.sol | 413 +++++++++++++++++ tests/staking/OgvStaking.t.sol | 472 ++++++-------------- 15 files changed, 1239 insertions(+), 691 deletions(-) create mode 100644 contracts/Migrator.sol create mode 100644 contracts/tests/MockOGVStaking.sol create mode 100644 contracts/tests/MockRewardsSource.sol create mode 100644 contracts/upgrades/MigratorProxy.sol create mode 100644 tests/staking/Migrator.t.sol diff --git a/contracts/Migrator.sol b/contracts/Migrator.sol new file mode 100644 index 00000000..c098717a --- /dev/null +++ b/contracts/Migrator.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "./Governable.sol"; + +interface IStaking { + function delegates(address staker) external view returns (address); + + // From OGVStaking.sol + function unstakeFrom(address staker, uint256[] memory lockupIds) external returns (uint256, uint256); + + // From ExponentialStaking.sol + function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external; +} + +contract Migrator is Governable { + ERC20Burnable public immutable ogv; + ERC20Burnable public immutable ogn; + + IStaking public immutable ogvStaking; + IStaking public immutable ognStaking; + + // Fixed conversion rate + uint256 public constant CONVERSION_RATE = 0.09137 ether; + + uint256 public endTime; + + event TokenExchanged(uint256 ogvAmountIn, uint256 ognAmountOut); + event Decommissioned(); + event LockupsMigrated(address indexed user, uint256[] ogvLockupIds, uint256 newStakeAmount, uint256 newDuration); + + error MigrationAlreadyStarted(); + error MigrationIsInactive(); + error MigrationNotComplete(); + error ContractInsolvent(uint256 expectedOGN, uint256 availableOGN); + error LockupIdsRequired(); + error InvalidStakeAmount(); + + constructor(address _ogv, address _ogn, address _ogvStaking, address _ognStaking) { + ogv = ERC20Burnable(_ogv); + ogn = ERC20Burnable(_ogn); + ogvStaking = IStaking(_ogvStaking); + ognStaking = IStaking(_ognStaking); + } + + /** + * @notice Solvency Checks + * + * This ensures that the contract always has enough OGN to + * continue with the migration. + * However, it doesn't revert if the difference is in favour + * of the contract (i.e. has more OGN than expected). + */ + modifier isSolvent() { + _; + + uint256 availableOGN = ogn.balanceOf(address(this)); + uint256 maxOGNNeeded = (ogv.totalSupply() * CONVERSION_RATE) / 1 ether; + + if (availableOGN < maxOGNNeeded) { + revert ContractInsolvent(maxOGNNeeded, availableOGN); + } + } + + /** + * @notice Starts the migration and sets it to end after + * 365 days. Also, approves xOGN to transfer OGN + * held in this contract. Can be invoked only once + */ + function start() external onlyGovernor isSolvent { + if (endTime != 0) { + revert MigrationAlreadyStarted(); + } + + // Max approve + ogn.approve(address(ognStaking), type(uint256).max); + + endTime = block.timestamp + 365 days; + } + + /** + * @notice Decommissions the contract. Can be called only + * after a year since `start()` was invoked. Burns + * all OGN in the contract by transferring them to + * to address(0xdead). + */ + function decommission() external { + // Only after a year of staking + if (endTime == 0 || isMigrationActive()) { + revert MigrationNotComplete(); + } + + emit Decommissioned(); + + uint256 ognBalance = ogn.balanceOf(address(this)); + if (ognBalance > 0) { + // OGN doesn't allow burning of tokens. Has `onlyOwner` + // modifier on `burn` and `burnFrom` methods. Also, + // `transfer` has a address(0) check. So, this transfers + // everything to address(0xdead). The `owner` multisig of + // OGN token can call `burnFrom(address(0xdead))` later. + + ogn.transfer(address(0xdead), ognBalance); + } + } + + /** + * @notice Computes the amount of OGN needed for migration + * and if the contract has more OGN than that, it + * transfers it back to the treasury. + * @param treasury Address that receives excess OGN + */ + function transferExcessTokens(address treasury) external onlyGovernor isSolvent { + uint256 availableOGN = ogn.balanceOf(address(this)); + uint256 totalOGV = ogv.totalSupply() - ogv.balanceOf(address(this)); + uint256 maxOGNNeeded = (totalOGV * CONVERSION_RATE) / 1 ether; + + if (availableOGN > maxOGNNeeded) { + ogn.transfer(treasury, availableOGN - maxOGNNeeded); + } + } + + /** + * @notice Returns the active status of the migration. + * @return True if migration has started and has not ended yet. + */ + function isMigrationActive() public view returns (bool) { + return endTime > 0 && block.timestamp < endTime; + } + + /** + * @notice Migrates the specified amount of OGV to OGN. + * Does not check if migration is active since + * that's okay (until we decommission). + * @param ogvAmount Amount of OGV to migrate + * @return ognReceived OGN Received + */ + function migrate(uint256 ogvAmount) external isSolvent returns (uint256 ognReceived) { + return _migrate(ogvAmount, msg.sender); + } + + /** + * @notice Migrates OGV stakes to OGN. Can also include unstaked OGN & OGV + * balances from the user's wallet (if specified). + * Does not check if migration is active since that's okay (until + * we decommission the contract). + * @param lockupIds OGV Lockup IDs to be migrated + * @param ogvAmountFromWallet Extra OGV balance from user's wallet to migrate & stake + * @param ognAmountFromWallet Extra OGN balance from user's wallet to stake + * @param migrateRewards If true, Migrate & Stake received rewards + * @param newStakeAmount Max amount of OGN (from wallet+unstake) to stake + * @param newStakeDuration Duration of the new stake + */ + function migrate( + uint256[] calldata lockupIds, + uint256 ogvAmountFromWallet, + uint256 ognAmountFromWallet, + bool migrateRewards, + uint256 newStakeAmount, + uint256 newStakeDuration + ) external isSolvent { + if (lockupIds.length == 0) { + revert LockupIdsRequired(); + } + + // Unstake + (uint256 ogvAmountUnlocked, uint256 rewardsCollected) = ogvStaking.unstakeFrom(msg.sender, lockupIds); + + if (migrateRewards) { + // Include rewards if needed + ogvAmountFromWallet += rewardsCollected; + } + + ogvAmountFromWallet += ogvAmountUnlocked; + + if (ognAmountFromWallet > 0) { + // Transfer in additional OGN to stake from user's wallet + ogn.transferFrom(msg.sender, address(this), ognAmountFromWallet); + } + + // Migrate OGV to OGN and include that along with existing balance + ognAmountFromWallet += _migrate(ogvAmountFromWallet, address(this)); + + if (ognAmountFromWallet < newStakeAmount) { + revert InvalidStakeAmount(); + } + + uint256 ognToWallet = ognAmountFromWallet - newStakeAmount; + if (ognToWallet > 0) { + ogn.transfer(msg.sender, ognToWallet); + } + + if (newStakeAmount > 0) { + // Stake it + ognStaking.stake( + newStakeAmount, + newStakeDuration, + msg.sender, + false, + -1 // New stake + ); + } + + emit LockupsMigrated(msg.sender, lockupIds, newStakeAmount, newStakeDuration); + } + + /** + * @notice Migrates caller's OGV to OGN and sends it to the `receiver` + * @return ognReceived OGN Received + */ + function _migrate(uint256 ogvAmount, address receiver) internal returns (uint256 ognReceived) { + ognReceived = (ogvAmount * CONVERSION_RATE) / 1 ether; + + emit TokenExchanged(ogvAmount, ognReceived); + + ogv.burnFrom(msg.sender, ogvAmount); + + if (receiver != address(this)) { + // When migrating stakes, the contract would directly + // stake the balance on behalf of the user. So there's + // no need to transfer to self. Transfering to user and then + // back to this contract would only increase gas cost (and + // an additional tx for the user). + ogn.transfer(receiver, ognReceived); + } + } +} diff --git a/contracts/OgvStaking.sol b/contracts/OgvStaking.sol index 4f06b815..f467b5f9 100644 --- a/contracts/OgvStaking.sol +++ b/contracts/OgvStaking.sol @@ -44,14 +44,22 @@ contract OgvStaking is ERC20Votes { // unless the user calls `delegate()` method. mapping(address => bool) public hasDelegationSet; + // Migrator contract address + address public immutable migratorAddr; + // Events event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Reward(address indexed user, uint256 amount); - // 1. Core Functions + // Errors + error NotMigrator(); + error StakingDisabled(); + error NoLockupsToUnstake(); + error AlreadyUnstaked(uint256 lockupId); - constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_) + // 1. Core Functions + constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_, address migrator_) ERC20("", "") ERC20Permit("veOGV") { @@ -59,6 +67,7 @@ contract OgvStaking is ERC20Votes { epoch = epoch_; minStakeDuration = minStakeDuration_; rewardsSource = RewardsSource(rewardsSource_); + migratorAddr = migrator_; } function name() public pure override returns (string memory) { @@ -77,6 +86,14 @@ contract OgvStaking is ERC20Votes { revert("Staking: Transfers disabled"); } + modifier onlyMigrator() { + if (migratorAddr != msg.sender) { + revert NotMigrator(); + } + + _; + } + // 2. Staking and Lockup Functions /// @notice Stake OGV to an address that may not be the same as the @@ -93,7 +110,7 @@ contract OgvStaking is ERC20Votes { /// @param duration in seconds for the stake /// @param to address to receive ownership of the stake function stake(uint256 amount, uint256 duration, address to) external { - _stake(amount, duration, to); + revert StakingDisabled(); } /// @notice Stake OGV @@ -108,54 +125,83 @@ contract OgvStaking is ERC20Votes { /// @param amount OGV to lockup in the stake /// @param duration in seconds for the stake function stake(uint256 amount, uint256 duration) external { - _stake(amount, duration, msg.sender); + revert StakingDisabled(); } - /// @dev Internal method used for public staking - /// @param amount OGV to lockup in the stake - /// @param duration in seconds for the stake - /// @param to address to receive ownership of the stake - function _stake(uint256 amount, uint256 duration, address to) internal { - require(to != address(0), "Staking: To the zero address"); - require(amount <= type(uint128).max, "Staking: Too much"); - require(amount > 0, "Staking: Not enough"); - - // duration checked inside previewPoints - (uint256 points, uint256 end) = previewPoints(amount, duration); - require(points + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); - _collectRewards(to); - lockups[to].push( - Lockup({ - amount: uint128(amount), // max checked in require above - end: uint128(end), - points: points - }) - ); - _mint(to, points); - ogv.transferFrom(msg.sender, address(this), amount); // Important that it's sender - - if (!hasDelegationSet[to] && delegates(to) == address(0)) { - // Delegate voting power to the receiver, if unregistered - _delegate(to, to); - } + /// @notice Collect staked OGV for a lockup and any earned rewards. + /// @param lockupId the id of the lockup to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstake(uint256 lockupId) external returns (uint256 unstakedAmount, uint256 rewardCollected) { + uint256[] memory lockupIds = new uint256[](1); + lockupIds[0] = lockupId; + return _unstake(msg.sender, lockupIds); + } - emit Stake(to, lockups[to].length - 1, amount, end, points); + /// @notice Unstake multiple lockups at once. + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstake(uint256[] memory lockupIds) external returns (uint256 unstakedAmount, uint256 rewardCollected) { + return _unstake(msg.sender, lockupIds); } - /// @notice Collect staked OGV for a lockup and any earned rewards. - /// @param lockupId the id of the lockup to unstake - function unstake(uint256 lockupId) external { - Lockup memory lockup = lockups[msg.sender][lockupId]; - uint256 amount = lockup.amount; - uint256 end = lockup.end; - uint256 points = lockup.points; - require(block.timestamp >= end, "Staking: End of lockup not reached"); - require(end != 0, "Staking: Already unstaked this lockup"); - _collectRewards(msg.sender); - delete lockups[msg.sender][lockupId]; // Keeps empty in array, so indexes are stable - _burn(msg.sender, points); - ogv.transfer(msg.sender, amount); - emit Unstake(msg.sender, lockupId, amount, end, points); + /// @notice Unstakes lockups of an user. + /// Can only be called by the Migrator. + /// @param staker Address of the user + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstakeFrom(address staker, uint256[] memory lockupIds) + external + onlyMigrator + returns (uint256 unstakedAmount, uint256 rewardCollected) + { + return _unstake(staker, lockupIds); + } + + /// @notice Unstakes lockups of an user. + /// @param staker Address of the user + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function _unstake(address staker, uint256[] memory lockupIds) + internal + returns (uint256 unstakedAmount, uint256 rewardCollected) + { + if (lockupIds.length == 0) { + revert NoLockupsToUnstake(); + } + + // Collect rewards + rewardCollected = _collectRewards(staker); + + uint256 unstakedPoints = 0; + + for (uint256 i = 0; i < lockupIds.length; ++i) { + uint256 lockupId = lockupIds[i]; + Lockup memory lockup = lockups[staker][lockupId]; + uint256 amount = lockup.amount; + uint256 end = lockup.end; + uint256 points = lockup.points; + + unstakedAmount += amount; + unstakedPoints += points; + + // Make sure it isn't unstaked already + if (end == 0) { + revert AlreadyUnstaked(lockupId); + } + + delete lockups[staker][lockupId]; // Keeps empty in array, so indexes are stable + + emit Unstake(staker, lockupId, amount, end, points); + } + + // Transfer unstaked OGV + ogv.transfer(staker, unstakedAmount); + // ... and burn veOGV + _burn(staker, unstakedPoints); } /// @notice Extend a stake lockup for additional points. @@ -172,24 +218,7 @@ contract OgvStaking is ERC20Votes { /// @param lockupId the id of the old lockup to extend /// @param duration number of seconds from now to stake for function extend(uint256 lockupId, uint256 duration) external { - // duration checked inside previewPoints - _collectRewards(msg.sender); - Lockup memory lockup = lockups[msg.sender][lockupId]; - uint256 oldAmount = lockup.amount; - uint256 oldEnd = lockup.end; - uint256 oldPoints = lockup.points; - (uint256 newPoints, uint256 newEnd) = previewPoints(oldAmount, duration); - require(newEnd > oldEnd, "Staking: New lockup must be longer"); - lockup.end = uint128(newEnd); - lockup.points = newPoints; - lockups[msg.sender][lockupId] = lockup; - _mint(msg.sender, newPoints - oldPoints); - if (!hasDelegationSet[msg.sender] && delegates(msg.sender) == address(0)) { - // Delegate voting power to the receiver, if unregistered - _delegate(msg.sender, msg.sender); - } - emit Unstake(msg.sender, lockupId, oldAmount, oldEnd, oldPoints); - emit Stake(msg.sender, lockupId, oldAmount, newEnd, newPoints); + revert StakingDisabled(); } /// @notice Preview the number of points that would be returned for the @@ -200,20 +229,15 @@ contract OgvStaking is ERC20Votes { /// @return points staking points that would be returned /// @return end staking period end date function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { - require(duration >= minStakeDuration, "Staking: Too short"); - require(duration <= 1461 days, "Staking: Too long"); - uint256 start = block.timestamp > epoch ? block.timestamp : epoch; - uint256 end = start + duration; - uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; - uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); - return ((amount * multiplier) / 1e18, end); + revert StakingDisabled(); } // 3. Reward functions /// @notice Collect all earned OGV rewards. - function collectRewards() external { - _collectRewards(msg.sender); + /// @return rewardCollected OGV reward amount collected + function collectRewards() external returns (uint256 rewardCollected) { + return _collectRewards(msg.sender); } /// @notice Shows the amount of OGV a user would receive if they collected @@ -222,45 +246,40 @@ contract OgvStaking is ERC20Votes { /// @param user to preview rewards for /// @return OGV rewards amount function previewRewards(address user) external view returns (uint256) { - uint256 supply = totalSupply(); - if (supply == 0) { + if (totalSupply() == 0) { return 0; // No one has any points to even get rewards } - uint256 _accRewardPerShare = accRewardPerShare; - _accRewardPerShare += (rewardsSource.previewRewards() * 1e12) / supply; - uint256 netRewardsPerShare = _accRewardPerShare - rewardDebtPerShare[user]; + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; return (balanceOf(user) * netRewardsPerShare) / 1e12; } /// @dev Internal function to handle rewards accounting. /// - /// 1. Collect new rewards for everyone - /// 2. Calculate this user's rewards and accounting - /// 3. Distribute this user's rewards + /// 1. Calculate this user's rewards and accounting + /// 2. Distribute this user's rewards, if any /// /// This function *must* be called before any user balance changes. /// /// This will always update the user's rewardDebtPerShare to match - /// accRewardPerShare, which is essential to the accounting. + /// accRewardPerShare, which is essential to the accounting. This + /// wouldn't allow user to claim rewards twice /// /// @param user to collect rewards for - function _collectRewards(address user) internal { - uint256 supply = totalSupply(); - if (supply > 0) { - uint256 preBalance = ogv.balanceOf(address(this)); - try rewardsSource.collectRewards() {} - catch { - // Governance staking should continue, even if rewards fail - } - uint256 collected = ogv.balanceOf(address(this)) - preBalance; - accRewardPerShare += (collected * 1e12) / supply; + /// @param netRewards Net reward collected for user + function _collectRewards(address user) internal returns (uint256 netRewards) { + if (totalSupply() == 0) { + return 0; // No one has any points to even get rewards } + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; - uint256 netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + rewardDebtPerShare[user] = accRewardPerShare; + if (netRewards == 0) { - return; + return 0; } + ogv.transfer(user, netRewards); emit Reward(user, netRewards); } diff --git a/contracts/tests/MockOGN.sol b/contracts/tests/MockOGN.sol index 952d3ea9..1e2cd9c9 100644 --- a/contracts/tests/MockOGN.sol +++ b/contracts/tests/MockOGN.sol @@ -4,9 +4,23 @@ pragma solidity 0.8.10; import {ERC20} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/ERC20.sol"; contract MockOGN is ERC20 { + uint256 nextTransferAmount; + constructor() ERC20("OGN", "OGN") {} function mint(address to, uint256 amount) external { _mint(to, amount); } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (nextTransferAmount > 0) { + amount = nextTransferAmount; + } + + _transfer(msg.sender, to, amount); + } + + function setNetTransferAmount(uint256 amount) external { + nextTransferAmount = amount; + } } diff --git a/contracts/tests/MockOGV.sol b/contracts/tests/MockOGV.sol index 074c9f5a..750462b1 100644 --- a/contracts/tests/MockOGV.sol +++ b/contracts/tests/MockOGV.sol @@ -9,4 +9,12 @@ contract MockOGV is ERC20 { function mint(address to, uint256 amount) external { _mint(to, amount); } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } + + function burnFrom(address owner, uint256 amount) external { + _burn(owner, amount); + } } diff --git a/contracts/tests/MockOGVStaking.sol b/contracts/tests/MockOGVStaking.sol new file mode 100644 index 00000000..a5292beb --- /dev/null +++ b/contracts/tests/MockOGVStaking.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "../OgvStaking.sol"; + +struct Lockup { + uint128 amount; + uint128 end; + uint256 points; +} + +contract MockOGVStaking is OgvStaking { + constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_, address migrator_) + OgvStaking(ogv_, epoch_, minStakeDuration_, rewardsSource_, migrator_) + {} + + function _previewPoints(uint256 amount, uint256 duration) internal view returns (uint256, uint256) { + require(duration >= minStakeDuration, "Staking: Too short"); + require(duration <= 1461 days, "Staking: Too long"); + uint256 start = block.timestamp > epoch ? block.timestamp : epoch; + uint256 end = start + duration; + uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; + uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); + return ((amount * multiplier) / 1e18, end); + } + + function mockStake(uint256 amountIn, uint256 duration) external { + mockStake(amountIn, duration, msg.sender); + } + + function mockStake(uint256 amountIn, uint256 duration, address to) public { + Lockup memory lockup; + + ogv.transferFrom(msg.sender, address(this), amountIn); + + (uint256 points, uint256 end) = _previewPoints(amountIn, duration); + require(points + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); + + lockup.end = uint128(end); + lockup.amount = uint128(amountIn); + lockup.points = points; + + uint256 lockupId = lockups[to].length; + + lockups[to].push(lockup); + + _mint(to, points); + emit Stake(to, uint256(lockupId), amountIn, end, points); + + if (!hasDelegationSet[to]) { + hasDelegationSet[to] = true; + super._delegate(to, to); + } + } + + function setRewardShare(uint256 _accRewardPerShare) external { + accRewardPerShare = _accRewardPerShare; + } +} diff --git a/contracts/tests/MockRewardsSource.sol b/contracts/tests/MockRewardsSource.sol new file mode 100644 index 00000000..2c6b7f38 --- /dev/null +++ b/contracts/tests/MockRewardsSource.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +contract MockRewardsSource { + constructor() {} + + function previewRewards() external view returns (uint256) { + return 0; + } + + function collectRewards() external returns (uint256) { + return 0; + } +} diff --git a/contracts/upgrades/MigratorProxy.sol b/contracts/upgrades/MigratorProxy.sol new file mode 100644 index 00000000..cef75877 --- /dev/null +++ b/contracts/upgrades/MigratorProxy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.10; + +import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol"; + +contract MigratorProxy is InitializeGovernedUpgradeabilityProxy {} diff --git a/scripts/deploy_staking.py b/scripts/deploy_staking.py index 665417c0..983e16c0 100644 --- a/scripts/deploy_staking.py +++ b/scripts/deploy_staking.py @@ -1,8 +1,12 @@ from brownie import * -def main(token_address, epoch, rewards_address): +def main(token_address, epoch, rewards_address, mock=False): min_staking = 7 * 24 * 60 * 60 - staking_impl = OgvStaking.deploy(token_address, epoch, min_staking, rewards_address) + if mock: + staking_impl = MockOGVStaking.deploy(token_address, epoch, min_staking, rewards_address, "0x0000000000000000000000000000000000000011") + return Contract.from_abi("MockOGVStaking", staking_impl.address, staking_impl.abi) + + staking_impl = OgvStaking.deploy(token_address, epoch, min_staking, rewards_address, "0x0000000000000000000000000000000000000011") # @TODO Proxy for staking implementation contract return Contract.from_abi("OgvStaking", staking_impl.address, staking_impl.abi) diff --git a/tests/distribution/test_mandatory_lockup.py b/tests/distribution/test_mandatory_lockup.py index f20d3600..eb27d655 100644 --- a/tests/distribution/test_mandatory_lockup.py +++ b/tests/distribution/test_mandatory_lockup.py @@ -1,3 +1,4 @@ +import pytest from brownie import * import brownie from ..helpers import WEEK, DAY @@ -9,7 +10,7 @@ 0xCB8BD9CA540F4B1C63F13D7DDFEC54AB24715F49F9A3640C1CCF9F548A896554, ] - +@pytest.mark.skip() def test_claim(mandatory_lockup_distributor, token, staking): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -30,7 +31,7 @@ def test_claim(mandatory_lockup_distributor, token, staking): assert lockup_four[0] == amount / 4 assert lockup_four[1] == tx.timestamp + 48 * 2629800 - +@pytest.mark.skip() def test_can_not_claim(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 @@ -40,7 +41,7 @@ def test_can_not_claim(mandatory_lockup_distributor, token): with brownie.reverts("Can no longer claim. Claim period expired"): mandatory_lockup_distributor.claim(1, amount, merkle_proof) - +@pytest.mark.skip() def test_burn_remaining_amount(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -51,7 +52,7 @@ def test_burn_remaining_amount(mandatory_lockup_distributor, token): mandatory_lockup_distributor.burnRemainingOGV() assert token.balanceOf(mandatory_lockup_distributor) == 0 - +@pytest.mark.skip() def test_can_not_burn_remaining_amount(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -62,7 +63,7 @@ def test_can_not_burn_remaining_amount(mandatory_lockup_distributor, token): with brownie.reverts("Can not yet burn the remaining OGV"): mandatory_lockup_distributor.burnRemainingOGV() - +@pytest.mark.skip() def test_valid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -71,7 +72,7 @@ def test_valid_proof(mandatory_lockup_distributor, token): 1, amount, accounts.default, merkle_proof ) - +@pytest.mark.skip() def test_invalid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -84,7 +85,7 @@ def test_invalid_proof(mandatory_lockup_distributor, token): 1, amount, accounts.default, false_merkle_proof ) - +@pytest.mark.skip() def test_cannot_claim_with_invalid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup diff --git a/tests/distribution/test_optional_lockup.py b/tests/distribution/test_optional_lockup.py index b016c6ce..c6d4ec1b 100644 --- a/tests/distribution/test_optional_lockup.py +++ b/tests/distribution/test_optional_lockup.py @@ -1,3 +1,4 @@ +import pytest from brownie import * import brownie from ..helpers import WEEK @@ -14,7 +15,7 @@ 0xCB8BD9CA540F4B1C63F13D7DDFEC54AB24715F49F9A3640C1CCF9F548A896554, ] - +@pytest.mark.skip() def test_no_lockup_duration(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -24,7 +25,7 @@ def test_no_lockup_duration(optional_lockup_distributor, token): # Should have gotten amount transferred back to the contract. assert token.balanceOf(accounts.default) == before_balance + amount - +@pytest.mark.skip() def test_claim_with_lockup_duration(optional_lockup_distributor, token, staking): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -34,7 +35,7 @@ def test_claim_with_lockup_duration(optional_lockup_distributor, token, staking) chain.mine() assert staking.lockups(accounts.default, 0)[0] == amount - +@pytest.mark.skip() def test_can_not_claim(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -43,7 +44,7 @@ def test_can_not_claim(optional_lockup_distributor, token): with brownie.reverts("Can no longer claim. Claim period expired"): optional_lockup_distributor.claim(1, amount, merkle_proof, WEEK) - +@pytest.mark.skip() def test_burn_remaining_amount(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -54,7 +55,7 @@ def test_burn_remaining_amount(optional_lockup_distributor, token): optional_lockup_distributor.burnRemainingOGV() assert token.balanceOf(optional_lockup_distributor) == 0 - +@pytest.mark.skip() def test_can_not_burn_remaining_amount(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -65,7 +66,7 @@ def test_can_not_burn_remaining_amount(optional_lockup_distributor, token): with brownie.reverts("Can not yet burn the remaining OGV"): optional_lockup_distributor.burnRemainingOGV() - +@pytest.mark.skip() def test_valid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -74,7 +75,7 @@ def test_valid_proof(optional_lockup_distributor, token): 1, amount, accounts.default, merkle_proof ) - +@pytest.mark.skip() def test_invalid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -87,7 +88,7 @@ def test_invalid_proof(optional_lockup_distributor, token): 1, amount, accounts.default, false_merkle_proof ) - +@pytest.mark.skip() def test_cannot_claim_with_invalid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup diff --git a/tests/fixtures.py b/tests/fixtures.py index 5464afe7..f22cb87c 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -135,7 +135,7 @@ def rewards(token): @pytest.fixture def staking(token, rewards): - return run("deploy_staking", "main", (token.address, DAY, rewards.address)) + return run("deploy_staking", "main", (token.address, DAY, rewards.address, True)) @pytest.fixture def whale_voter(token, staking): @@ -143,7 +143,7 @@ def whale_voter(token, staking): voter = accounts[3] amount = int(1e9) * int(1e18) token.approve(staking.address, amount) # Uses coins from default address - staking.stake(amount, WEEK * 52 * 4, voter) + staking.mockStake(amount, WEEK * 52 * 4, voter) return voter @pytest.fixture diff --git a/tests/governance/test_vote.py b/tests/governance/test_vote.py index 95b6d8b7..c995aa88 100644 --- a/tests/governance/test_vote.py +++ b/tests/governance/test_vote.py @@ -81,8 +81,8 @@ def test_proposal_can_fail_vote( token.approve(staking.address, amount * 2, {"from": bob}) token.grantMinterRole(rewards.address, {"from": alice}) rewards.setRewardsTarget(staking.address, {"from": alice}) - staking.stake(amount, WEEK, alice, {"from": alice}) - staking.stake(amount * 2, WEEK, bob, {"from": bob}) + staking.mockStake(amount, WEEK, alice, {"from": alice}) + staking.mockStake(amount * 2, WEEK, bob, {"from": bob}) tx = governance.propose( [governance.address], [0], diff --git a/tests/staking/DelegationTest.t.sol b/tests/staking/DelegationTest.t.sol index a1f7a405..6bd773d6 100644 --- a/tests/staking/DelegationTest.t.sol +++ b/tests/staking/DelegationTest.t.sol @@ -2,246 +2,248 @@ pragma solidity 0.8.10; import "forge-std/Test.sol"; -import "contracts/OgvStaking.sol"; -import "contracts/RewardsSource.sol"; -import "contracts/tests/MockOGV.sol"; +// import "contracts/tests/MockOGVStaking.sol"; +// import "contracts/RewardsSource.sol"; +// import "contracts/tests/MockOGV.sol"; // // Sanity test of OpenZeppelin's voting and delegation. // contract DelegationTest is Test { - using stdStorage for StdStorage; + // using stdStorage for StdStorage; - MockOGV ogv; - OgvStaking staking; - RewardsSource source; + // MockOGV ogv; + // MockOGVStaking staking; + // RewardsSource source; - address oak = address(0x42); - address aspen = address(0x43); - address taz = address(0x44); - address alice = address(0x45); - address bob = address(0x46); - address attacker = address(0x47); - address team = address(0x50); + // address oak = address(0x42); + // address aspen = address(0x43); + // address taz = address(0x44); + // address alice = address(0x45); + // address bob = address(0x46); + // address attacker = address(0x47); + // address team = address(0x50); - uint256 constant EPOCH = 1 days; + // uint256 constant EPOCH = 1 days; - uint256 POINTS = 0; + // uint256 POINTS = 0; function setUp() public { - vm.startPrank(team); - ogv = new MockOGV(); - source = new RewardsSource(address(ogv)); - staking = new OgvStaking(address(ogv), EPOCH, 7 days, address(source)); - source.setRewardsTarget(address(staking)); - vm.stopPrank(); - - ogv.mint(oak, 1000 ether); - ogv.mint(aspen, 1000 ether); - ogv.mint(taz, 100000000 ether); - - vm.prank(oak); - ogv.approve(address(staking), 1e70); - vm.prank(aspen); - ogv.approve(address(staking), 1e70); - vm.prank(taz); - ogv.approve(address(staking), 1e70); - - vm.prank(oak); - staking.stake(1 ether, 100 days); - vm.prank(aspen); - staking.stake(2 ether, 100 days); - vm.prank(taz); - staking.stake(1 ether, 100 days, alice); // Stake for alice - - POINTS = staking.balanceOf(oak); + // vm.startPrank(team); + // ogv = new MockOGV(); + // source = new RewardsSource(address(ogv)); + // staking = new MockOGVStaking(address(ogv), EPOCH, 7 days, address(source), address(0)); + // source.setRewardsTarget(address(staking)); + // vm.stopPrank(); + + // ogv.mint(oak, 1000 ether); + // ogv.mint(aspen, 1000 ether); + // ogv.mint(taz, 100000000 ether); + + // vm.prank(oak); + // ogv.approve(address(staking), 1e70); + // vm.prank(aspen); + // ogv.approve(address(staking), 1e70); + // vm.prank(taz); + // ogv.approve(address(staking), 1e70); + + // vm.prank(oak); + // staking.mockStake(1 ether, 100 days); + // vm.prank(aspen); + // staking.mockStake(2 ether, 100 days); + // vm.prank(taz); + // staking.mockStake(1 ether, 100 days, alice); // Stake for alice + + // POINTS = staking.balanceOf(oak); } - function testAutoDelegateOnStake() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - - // Can opt out of voting after staking - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(oak), address(0)); - } - - function testAutoDelegateOnStakeToOthers() external { - vm.roll(1); - - // Alice should have voting power after taz stakes for her - assertEq(staking.getVotes(alice), POINTS, "can vote after staking"); - assertEq(staking.getVotes(taz), 0, "should not have voting power"); - assertEq(staking.getPastVotes(alice, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.getPastVotes(taz, block.number - 1), 0, "should not have voting power"); - assertEq(staking.delegates(alice), alice, "delegated to receiver after staking"); - assertEq(staking.delegates(taz), address(0), "should not have a delegatee set"); - - vm.roll(2); - - // Alice can opt out of voting after staking - vm.prank(alice); - staking.delegate(address(0)); - assertEq(staking.getVotes(alice), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(alice, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(alice), address(0)); - } - - function testDelegateOnExtendAfterRenounce() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - // Can renounce voting powers - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(oak), address(0)); - - vm.roll(3); - // Extend shouldn't change manual override - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), address(0), "should not change delegation on extend"); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - } - - function testDelegateOnExtendAfterTransfer() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - // Can move voting power - vm.prank(oak); - staking.delegate(alice); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.delegates(oak), alice); - - vm.roll(3); - // Extend shouldn't change manual override - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), alice, "should not change delegation on extend"); - } - - function testDelegateOnExtendForOlderStakes() external { - // For test purposes, undo auto-staking on user - vm.prank(oak); - staking.delegate(address(0)); - stdstore.target(address(staking)).sig(staking.hasDelegationSet.selector).with_key(oak).checked_write(false); - - vm.roll(1); - - // Cannot vote because test undid auto-staking - assertEq(staking.getVotes(oak), 0, "can not vote"); - assertEq(staking.delegates(oak), address(0), "no delegation"); - assertEq(staking.hasDelegationSet(oak), false, "no hasDelegationSet"); - - // Extend should auto-delegate - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), oak, "should auto delegate on extend"); - assertEq(staking.hasDelegationSet(oak), true, "should have hasDelegationSet"); - assertGt(staking.getVotes(oak), 1 * POINTS, "should have voting power after extend"); - } - - function testDelegate() external { - vm.roll(1); - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - vm.prank(oak); - staking.delegate(aspen); - assertEq(staking.delegates(aspen), aspen); - assertEq(staking.delegates(oak), aspen); - assertEq( - staking.getVotes(aspen), - // Voting power of self + oak - 3 * POINTS, - "can vote after delegation" - ); - assertEq(staking.getPastVotes(aspen, block.number - 1), 2 * POINTS, "can vote after staking"); - } - - function testRenounceVotingPower() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - // Can opt out of voting - vm.roll(2); - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.delegates(oak), address(0), "should renouce voting power"); - assertEq(staking.getVotes(oak), 0, "should not have voting power after renouncing"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS, "can vote before renouncing"); - } - - function testSkipAutoDelegateIfDelegated() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - // Delegate to someone else - vm.roll(2); - vm.prank(oak); - staking.delegate(bob); - assertEq(staking.delegates(oak), bob, "should set a delegate"); - assertEq(staking.getVotes(oak), 0, "should not have voting power"); - assertEq(staking.getVotes(bob), 1 * POINTS, "should have voting power after delegation"); - - // Stake some more - vm.roll(3); - vm.prank(oak); - staking.stake(1 ether, 100 days); - assertEq(staking.getVotes(oak), 0, "cannot vote after delegation"); - assertEq(staking.getVotes(bob), 2 * POINTS, "should have voting power after delegation"); - assertEq(staking.delegates(oak), bob, "no change in delegation after staking"); - } - - function testRenounceAttack() external { - // Alice can vote, because she is staked - assertEq(staking.getVotes(alice), 1 * POINTS, "can vote after staking"); - - // Alice renounces voting. - vm.prank(alice); - staking.delegate(address(0)); - - // Attacker attacks - vm.startPrank(attacker); - ogv.mint(attacker, 1 ether); - ogv.approve(address(staking), 1 ether); - staking.stake(1 ether, 100 days, alice); - vm.stopPrank(); - - vm.roll(2); - - // Alice should still have renounced voting - assertEq(staking.getVotes(alice), 0, "can't vot after renouncing"); - } + // Commenting out since stake & extend are disabled + + // function testAutoDelegateOnStake() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + + // // Can opt out of voting after staking + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(oak), address(0)); + // } + + // function testAutoDelegateOnStakeToOthers() external { + // vm.roll(1); + + // // Alice should have voting power after taz stakes for her + // assertEq(staking.getVotes(alice), POINTS, "can vote after staking"); + // assertEq(staking.getVotes(taz), 0, "should not have voting power"); + // assertEq(staking.getPastVotes(alice, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.getPastVotes(taz, block.number - 1), 0, "should not have voting power"); + // assertEq(staking.delegates(alice), alice, "delegated to receiver after staking"); + // assertEq(staking.delegates(taz), address(0), "should not have a delegatee set"); + + // vm.roll(2); + + // // Alice can opt out of voting after staking + // vm.prank(alice); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(alice), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(alice, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(alice), address(0)); + // } + + // function testDelegateOnExtendAfterRenounce() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // // Can renounce voting powers + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(oak), address(0)); + + // vm.roll(3); + // // Extend shouldn't change manual override + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), address(0), "should not change delegation on extend"); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // } + + // function testDelegateOnExtendAfterTransfer() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // // Can move voting power + // vm.prank(oak); + // staking.delegate(alice); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.delegates(oak), alice); + + // vm.roll(3); + // // Extend shouldn't change manual override + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), alice, "should not change delegation on extend"); + // } + + // function testDelegateOnExtendForOlderStakes() external { + // // For test purposes, undo auto-staking on user + // vm.prank(oak); + // staking.delegate(address(0)); + // stdstore.target(address(staking)).sig(staking.hasDelegationSet.selector).with_key(oak).checked_write(false); + + // vm.roll(1); + + // // Cannot vote because test undid auto-staking + // assertEq(staking.getVotes(oak), 0, "can not vote"); + // assertEq(staking.delegates(oak), address(0), "no delegation"); + // assertEq(staking.hasDelegationSet(oak), false, "no hasDelegationSet"); + + // // Extend should auto-delegate + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), oak, "should auto delegate on extend"); + // assertEq(staking.hasDelegationSet(oak), true, "should have hasDelegationSet"); + // assertGt(staking.getVotes(oak), 1 * POINTS, "should have voting power after extend"); + // } + + // function testDelegate() external { + // vm.roll(1); + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(aspen); + // assertEq(staking.delegates(aspen), aspen); + // assertEq(staking.delegates(oak), aspen); + // assertEq( + // staking.getVotes(aspen), + // // Voting power of self + oak + // 3 * POINTS, + // "can vote after delegation" + // ); + // assertEq(staking.getPastVotes(aspen, block.number - 1), 2 * POINTS, "can vote after staking"); + // } + + // function testRenounceVotingPower() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // // Can opt out of voting + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.delegates(oak), address(0), "should renouce voting power"); + // assertEq(staking.getVotes(oak), 0, "should not have voting power after renouncing"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS, "can vote before renouncing"); + // } + + // function testSkipAutoDelegateIfDelegated() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // // Delegate to someone else + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(bob); + // assertEq(staking.delegates(oak), bob, "should set a delegate"); + // assertEq(staking.getVotes(oak), 0, "should not have voting power"); + // assertEq(staking.getVotes(bob), 1 * POINTS, "should have voting power after delegation"); + + // // Stake some more + // vm.roll(3); + // vm.prank(oak); + // staking.stake(1 ether, 100 days); + // assertEq(staking.getVotes(oak), 0, "cannot vote after delegation"); + // assertEq(staking.getVotes(bob), 2 * POINTS, "should have voting power after delegation"); + // assertEq(staking.delegates(oak), bob, "no change in delegation after staking"); + // } + + // function testRenounceAttack() external { + // // Alice can vote, because she is staked + // assertEq(staking.getVotes(alice), 1 * POINTS, "can vote after staking"); + + // // Alice renounces voting. + // vm.prank(alice); + // staking.delegate(address(0)); + + // // Attacker attacks + // vm.startPrank(attacker); + // ogv.mint(attacker, 1 ether); + // ogv.approve(address(staking), 1 ether); + // staking.stake(1 ether, 100 days, alice); + // vm.stopPrank(); + + // vm.roll(2); + + // // Alice should still have renounced voting + // assertEq(staking.getVotes(alice), 0, "can't vot after renouncing"); + // } } diff --git a/tests/staking/Migrator.t.sol b/tests/staking/Migrator.t.sol new file mode 100644 index 00000000..e5156f21 --- /dev/null +++ b/tests/staking/Migrator.t.sol @@ -0,0 +1,413 @@ +import "forge-std/Test.sol"; + +import "contracts/Migrator.sol"; + +import "contracts/OgvStaking.sol"; +import "contracts/ExponentialStaking.sol"; + +import "contracts/upgrades/MigratorProxy.sol"; + +import "contracts/tests/MockOGN.sol"; +import "contracts/tests/MockRewardsSource.sol"; +import "contracts/tests/MockOGV.sol"; +import "contracts/tests/MockOGVStaking.sol"; + +contract MigratorTest is Test { + MockOGV ogv; + MockOGN ogn; + + Migrator migrator; + + ExponentialStaking ognStaking; + MockOGVStaking ogvStaking; + + MockRewardsSource source; + + address alice = address(0x42); + address bob = address(0x43); + address governor = address(0x44); + + uint256 constant EPOCH = 1 days; + uint256 constant MIN_STAKE_DURATION = 7 days; + int256 constant NEW_STAKE = -1; + + function setUp() public { + vm.startPrank(governor); + ogv = new MockOGV(); + ogn = new MockOGN(); + + source = new MockRewardsSource(); + + MigratorProxy mProxy = new MigratorProxy(); + + ognStaking = new ExponentialStaking(address(ogn), EPOCH, MIN_STAKE_DURATION, address(source)); + + ogvStaking = new MockOGVStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), address(mProxy)); + + migrator = new Migrator(address(ogv), address(ogn), address(ogvStaking), address(ognStaking)); + mProxy.initialize(address(migrator), governor, ""); + migrator = Migrator(address(mProxy)); + + // Make sure contract has enough OGN for migration + ogn.mint(address(migrator), 10000000 ether); + + // Users have enough OGV + ogv.mint(alice, 10000000 ether); + ogv.mint(bob, 10000000 ether); + ogv.mint(address(ogvStaking), 10000000 ether); + + // Begin migration + migrator.start(); + + migrator.transferExcessTokens(governor); + + vm.stopPrank(); + + // ... with allowance for Migrator + vm.startPrank(alice); + ogv.approve(address(migrator), type(uint256).max); + ogn.approve(address(migrator), type(uint256).max); + ogv.approve(address(ogvStaking), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bob); + ogv.approve(address(migrator), type(uint256).max); + ogn.approve(address(migrator), type(uint256).max); + ogv.approve(address(ogvStaking), type(uint256).max); + vm.stopPrank(); + } + + function testBalanceMigration() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + uint256 ogvSupply = ogv.totalSupply(); + + vm.startPrank(alice); + migrator.migrate(100 ether); + vm.stopPrank(); + + assertEq(ogv.balanceOf(alice), 10000000 ether - 100 ether, "More OGV burnt"); + assertEq(ogv.totalSupply(), ogvSupply - 100 ether, "OGV supply mismatch"); + + assertEq(ogn.balanceOf(alice), 9.137 ether, "Less OGN received"); + assertEq(ogn.balanceOf(address(migrator)), maxOgnAmount - 9.137 ether, "More OGN sent"); + } + + function testDustBalanceMigration() public { + vm.startPrank(alice); + migrator.migrate(1); + vm.stopPrank(); + } + + function testBurnOnDecomission() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + + vm.startPrank(alice); + migrator.migrate(1 ether); + vm.stopPrank(); + + vm.warp(migrator.endTime() + 100); + + migrator.decommission(); + + assertEq(ogn.balanceOf(address(migrator)), 0 ether, "OGN leftover"); + assertEq(ogn.balanceOf(address(0xdead)), maxOgnAmount - 0.09137 ether, "OGN not sent to burn address"); + } + + function testSolvencyDuringMigrate() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + + vm.startPrank(alice); + ogn.setNetTransferAmount(100 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 100 ether + ) + ); + migrator.migrate(1 ether); + + ogn.setNetTransferAmount(0.09138 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 0.09138 ether + ) + ); + migrator.migrate(1 ether); + + ogn.setNetTransferAmount(0.091371115 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 0.091371115 ether + ) + ); + migrator.migrate(1 ether); + + vm.stopPrank(); + } + + function testMigrateAfterTimelimit() public { + // Should allow migration even after timelimit + // but before decommission + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + + vm.warp(migrator.endTime() + 100); + + assertEq(migrator.isMigrationActive(), false, "Migration state not changed"); + + migrator.migrate(1 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + migrator.migrate(ids, 0, 0, false, 0, 0); + vm.stopPrank(); + } + + function testRevertDecommissionBeforeEnd() public { + vm.warp(migrator.endTime() - 1000); + + vm.expectRevert(bytes4(keccak256("MigrationNotComplete()"))); + migrator.decommission(); + } + + function testRevertDecommissionBeforeStart() public { + Migrator newMigrator = new Migrator(address(ogv), address(ogn), address(1), address(1)); + + vm.expectRevert(bytes4(keccak256("MigrationNotComplete()"))); + newMigrator.decommission(); + } + + function testMigrateStakes() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = (11000 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 0, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateSelectedStakes() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](1); + lockupIds[0] = 0; + + uint256 stakeAmount = (10000 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 0, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + // Shouldn't have deleted other migration + (amount, end, points) = ogvStaking.lockups(alice, 1); + assertEq(amount, 1000 ether, "Other lockup deleted"); + + vm.stopPrank(); + } + + function testMigrateStakesWithOGVBalance() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256 balanceBefore = ogv.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = (11500 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 500 ether, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogv.balanceOf(alice), balanceBefore - 500 ether, "Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateRevertOnEmptyLockups() public { + vm.startPrank(alice); + uint256[] memory lockupIds = new uint256[](0); + + vm.expectRevert(bytes4(keccak256("LockupIdsRequired()"))); + migrator.migrate(lockupIds, 500 ether, 0, false, 9000 ether, 300 days); + + vm.stopPrank(); + } + + function testMigrateWithRewards() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + // Arbitrary reward + ogvStaking.setRewardShare(2 * 1e11); + uint256 expectedRewards = ogvStaking.previewRewards(alice); + uint256 stakeAmount = ((11000 ether + expectedRewards) * 0.09137 ether) / 1 ether; + + migrator.migrate( + lockupIds, + 0, + 0, + true, // Include reward as well + stakeAmount, + 300 days + ); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateStakesWithOGNBalance() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + ogn.mint(alice, 500 ether); + + uint256 ognBalanceBefore = ogn.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = ognBalanceBefore + ((11000 ether * 0.09137 ether) / 1 ether); + + migrator.migrate(lockupIds, 0, 500 ether, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogn.balanceOf(alice), 0, "OGN Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateStakesWithOGNAndOGVBalances() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + ogn.mint(alice, 500 ether); + + uint256 ognBalanceBefore = ogn.balanceOf(alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = ognBalanceBefore + ((11500 ether * 0.09137 ether) / 1 ether); + + migrator.migrate(lockupIds, 500 ether, ognBalanceBefore, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogn.balanceOf(alice), 0, "OGN Balance mismatch"); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore - 500 ether, "OGN Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } +} diff --git a/tests/staking/OgvStaking.t.sol b/tests/staking/OgvStaking.t.sol index be3701af..217d51db 100644 --- a/tests/staking/OgvStaking.t.sol +++ b/tests/staking/OgvStaking.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.10; import "forge-std/Test.sol"; import "contracts/upgrades/RewardsSourceProxy.sol"; import "contracts/upgrades/OgvStakingProxy.sol"; +import "contracts/tests/MockOGVStaking.sol"; import "contracts/OgvStaking.sol"; import "contracts/RewardsSource.sol"; import "contracts/tests/MockOGV.sol"; @@ -11,11 +12,13 @@ import "contracts/tests/MockOGV.sol"; contract OgvStakingTest is Test { MockOGV ogv; OgvStaking staking; + RewardsSource source; address alice = address(0x42); address bob = address(0x43); address team = address(0x44); + address migrator = address(0x50); uint256 constant EPOCH = 1 days; uint256 constant MIN_STAKE_DURATION = 7 days; @@ -29,428 +32,203 @@ contract OgvStakingTest is Test { rewardsProxy.initialize(address(source), team, ""); source = RewardsSource(address(rewardsProxy)); - staking = new OgvStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source)); + staking = new OgvStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), migrator); + MockOGVStaking mockStaking = + new MockOGVStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), migrator); + OgvStakingProxy stakingProxy = new OgvStakingProxy(); - stakingProxy.initialize(address(staking), team, ""); - staking = OgvStaking(address(stakingProxy)); + stakingProxy.initialize(address(mockStaking), team, ""); - source.setRewardsTarget(address(staking)); - vm.stopPrank(); + source.setRewardsTarget(address(stakingProxy)); - ogv.mint(alice, 1000 ether); - ogv.mint(bob, 1000 ether); - ogv.mint(team, 100000000 ether); + mockStaking = MockOGVStaking(address(stakingProxy)); + mockStaking.setRewardShare(2 * 1e11); - vm.prank(alice); - ogv.approve(address(staking), 1e70); - vm.prank(bob); - ogv.approve(address(staking), 1e70); - vm.prank(team); - ogv.approve(address(source), 1e70); - } + ogv.mint(alice, 10000 ether); + ogv.mint(bob, 10000 ether); + ogv.mint(team, 100000000 ether); + vm.stopPrank(); - function testStakeUnstake() public { vm.startPrank(alice); - (uint256 previewPoints, uint256 previewEnd) = staking.previewPoints(10 ether, 10 days); - - uint256 beforeOgv = ogv.balanceOf(alice); - uint256 beforeOgvStaking = ogv.balanceOf(address(staking)); - - staking.stake(10 ether, 10 days); - - assertEq(ogv.balanceOf(alice), beforeOgv - 10 ether); - assertEq(ogv.balanceOf(address(staking)), beforeOgvStaking + 10 ether); - assertEq(staking.balanceOf(alice), previewPoints); - (uint128 lockupAmount, uint128 lockupEnd, uint256 lockupPoints) = staking.lockups(alice, 0); - assertEq(lockupAmount, 10 ether); - assertEq(lockupEnd, EPOCH + 10 days); - assertEq(lockupEnd, previewEnd); - assertEq(lockupPoints, previewPoints); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); - - vm.warp(31 days); - staking.unstake(0); - - assertEq(ogv.balanceOf(alice), beforeOgv); - assertEq(ogv.balanceOf(address(staking)), 0); - (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); - assertEq(lockupAmount, 0); - assertEq(lockupEnd, 0); - assertEq(lockupPoints, 0); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); - } - - function testMatchedDurations() public { - vm.prank(alice); - staking.stake(10 ether, 1000 days, alice); + ogv.approve(address(stakingProxy), 1e70); + mockStaking.mockStake(2000 ether, 365 days); + mockStaking.mockStake(1000 ether, 20 days); + vm.stopPrank(); - vm.warp(EPOCH + 900 days); - vm.prank(bob); - staking.stake(10 ether, 100 days, bob); + vm.startPrank(bob); + ogv.approve(address(stakingProxy), 1e70); + mockStaking.mockStake(3300 ether, 60 days); + vm.stopPrank(); - // Now both have 10 OGV staked for 100 days remaining - // which should mean that they have the same number of points - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + vm.startPrank(team); + stakingProxy.upgradeTo(address(staking)); + staking = OgvStaking(address(stakingProxy)); + ogv.approve(address(source), 1e70); + vm.stopPrank(); } - function testPreStaking() public { - vm.prank(alice); - staking.stake(100 ether, 100 days, alice); - - vm.warp(EPOCH); - vm.prank(bob); - staking.stake(100 ether, 100 days, bob); - - // Both should have the same points - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + function testStake() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.stake(100, 100); } - function testZeroStake() public { - vm.prank(alice); - vm.expectRevert("Staking: Not enough"); - staking.stake(0 ether, 100 days, alice); + function testStakeTo() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.stake(100, 100, address(0xdead)); } - function testStakeTooMuch() public { - vm.prank(alice); - vm.expectRevert("Staking: Too much"); - staking.stake(1e70, 100 days, alice); + function testExtend() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.extend(1, 100); } - function testStakeTooLong() public { - vm.prank(alice); - vm.expectRevert("Staking: Too long"); - staking.stake(1 ether, 1700 days, alice); + function testPreviewPoints() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.previewPoints(1, 100); } - function testStakeTooShort() public { - vm.prank(alice); - vm.expectRevert("Staking: Too short"); - staking.stake(1 ether, 6 days, alice); - } + function testDisabledInflation() public { + uint256 expectedRewards = (staking.balanceOf(alice) * 2 ether) / 10 ether; - function testExtend() public { - vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); - vm.startPrank(bob); - staking.stake(100 ether, 10 days, bob); - staking.extend(0, 100 days); - - // Both are now locked up for the same amount of time, - // and should have the same points. - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); - - (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); - (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); - assertEq(aliceAmount, bobAmount, "same amount"); - assertEq(aliceEnd, bobEnd, "same end"); - assertEq(alicePoints, bobPoints, "same points"); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); + vm.warp(EPOCH + 100 days); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); + + vm.warp(EPOCH + 2000 days); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); } - function testDoubleExtend() public { - vm.warp(EPOCH + 600 days); + function testCollectRewards() public { + uint256 balanceBefore = ogv.balanceOf(alice); + uint256 expectedRewards = (staking.balanceOf(alice) * 2 ether) / 10 ether; + // Should allow claiming rewards once vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + staking.collectRewards(); - vm.startPrank(bob); - staking.stake(100 ether, 10 days, bob); - staking.extend(0, 50 days); - staking.extend(0, 100 days); - - // Both are now locked up for the same amount of time, - // and should have the same points. - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); - - (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); - (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); - assertEq(aliceAmount, bobAmount, "same amount"); - assertEq(aliceEnd, bobEnd, "same end"); - assertEq(alicePoints, bobPoints, "same points"); - } + assertEq(ogv.balanceOf(alice), expectedRewards + balanceBefore, "Reward not collected"); + + assertEq(staking.previewRewards(alice), 0, "Reward not collected"); - function testShortExtendFail() public { + // Should not allow claiming more than once vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + staking.collectRewards(); - vm.startPrank(bob); - staking.stake(100 ether, 11 days, bob); - vm.expectRevert("Staking: New lockup must be longer"); - staking.extend(0, 10 days); + assertEq(ogv.balanceOf(alice), expectedRewards + balanceBefore, "Reward collected more than once"); } - function testDoubleStake() external { + function testUnstake() public { + // Should have no penaly for early unstaking vm.startPrank(alice); - uint256 beforeOgv = ogv.balanceOf(alice); - staking.stake(3 ether, 10 days, alice); - uint256 midOgv = ogv.balanceOf(alice); - uint256 midPoints = staking.balanceOf(alice); - staking.stake(5 ether, 40 days, alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.warp(EPOCH + 50 days); - staking.unstake(1); + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, 1); - assertEq(midPoints, staking.balanceOf(alice)); - assertEq(midOgv, ogv.balanceOf(alice)); + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstake(1); - staking.unstake(0); - assertEq(0, staking.balanceOf(alice)); // No points, since all unstaked - assertEq(beforeOgv, ogv.balanceOf(alice)); // All OGV back - } + assertEq(unstakedAmount, amount, "Penalty applied with Unstake"); - function testNoEarlyUnstake() public { - vm.startPrank(alice); - staking.stake(10 ether, 1000 days, alice); - vm.warp(999 days); - vm.expectRevert("Staking: End of lockup not reached"); - staking.unstake(0); - } + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - function testCollectRewards() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 4 ether; - slopes[1].start = uint64(EPOCH + 2 days); - slopes[1].ratePerDay = 2 ether; - slopes[2].start = uint64(EPOCH + 7 days); - slopes[2].ratePerDay = 1 ether; - vm.prank(team); - source.setInflation(slopes); // Add from start + assertEq(staking.balanceOf(alice), veOgvBalanceBefore - points, "veOGV not burned"); - vm.startPrank(alice); - staking.stake(1 ether, 360 days, alice); - - vm.warp(EPOCH + 2 days); - uint256 beforeOgv = ogv.balanceOf(alice); - uint256 preview = staking.previewRewards(alice); - staking.collectRewards(); - uint256 afterOgv = ogv.balanceOf(alice); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - uint256 collectedRewards = afterOgv - beforeOgv; - assertApproxEqAbs(collectedRewards, 8 ether, 1e8, "actual amount should be correct"); - assertEq(collectedRewards, preview, "preview should match actual"); - assertApproxEqAbs(preview, 8 ether, 1e8, "preview amount should be correct"); - } - - function testCollectedRewardsJumpInOut() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; + (amount, end, points) = staking.lockups(alice, 1); - vm.prank(team); - source.setInflation(slopes); + assertEq(end, 0, "Not unstaked"); - vm.prank(alice); - staking.stake(1 ether, 10 days, alice); + assertEq(points, 0, "Not unstaked, points mismatch"); - // One day later - vm.warp(EPOCH + 1 days); - vm.prank(alice); - staking.collectRewards(); // Alice collects + assertEq(amount, 0, "Not unstaked, amount mismatch"); - vm.prank(bob); - staking.stake(1 ether, 9 days, bob); // Bob stakes + // Should revert if it's already unstaked + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("AlreadyUnstaked(uint256)")), uint256(1))); + staking.unstake(1); - vm.warp(EPOCH + 2 days); // Alice and bob should split rewards evenly - uint256 aliceBefore = ogv.balanceOf(alice); - uint256 bobBefore = ogv.balanceOf(bob); - vm.prank(alice); - staking.collectRewards(); // Alice collects - vm.prank(bob); - staking.collectRewards(); // Bob collects - assertEq(ogv.balanceOf(alice) - aliceBefore, ogv.balanceOf(bob) - bobBefore); + vm.stopPrank(); } - function testMultipleUnstake() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - - vm.prank(team); - source.setInflation(slopes); - + function testUnstakeMultiple() public { vm.startPrank(alice); - staking.stake(1 ether, 10 days, alice); - vm.warp(EPOCH + 11 days); - staking.unstake(0); - vm.expectRevert("Staking: Already unstaked this lockup"); - staking.unstake(0); - } - function testCollectRewardsOnExpand() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.prank(team); - source.setInflation(slopes); + uint256[] memory lockupIds = new uint256[](2); + lockupIds[1] = 1; + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstake(lockupIds); - vm.prank(alice); - staking.stake(1 ether, 10 days); - vm.prank(bob); - staking.stake(1 ether, 10 days); + assertEq(unstakedAmount, 3000 ether, "Penalty applied with Unstake"); - vm.warp(EPOCH + 6 days); + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - vm.prank(bob); - staking.collectRewards(); - vm.prank(alice); - staking.extend(0, 10 days); + assertEq(staking.balanceOf(alice), 0, "veOGV not burned"); - assertEq(ogv.balanceOf(alice), ogv.balanceOf(bob)); - } + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - function testNoSupplyShortCircuts() public { - uint256 beforeAlice = ogv.balanceOf(alice); + for (uint256 i = 0; i < 2; ++i) { + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, i); - vm.prank(alice); - staking.previewRewards(alice); - assertEq(ogv.balanceOf(alice), beforeAlice); + assertEq(end, 0, "Not unstaked"); - vm.prank(alice); - staking.collectRewards(); - assertEq(ogv.balanceOf(alice), beforeAlice); + assertEq(points, 0, "Not unstaked, points mismatch"); - vm.prank(bob); - staking.stake(1 ether, 9 days, bob); + assertEq(amount, 0, "Not unstaked, amount mismatch"); - vm.prank(alice); - staking.previewRewards(alice); - assertEq(ogv.balanceOf(alice), beforeAlice); + // Should revert if it's already unstaked + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("AlreadyUnstaked(uint256)")), i)); + staking.unstake(i); + } - vm.prank(alice); - staking.collectRewards(); - assertEq(ogv.balanceOf(alice), beforeAlice); + vm.stopPrank(); } - function testMultipleStakesSameBlock() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 4 ether; - slopes[1].start = uint64(EPOCH + 2 days); - slopes[1].ratePerDay = 2 ether; - slopes[2].start = uint64(EPOCH + 7 days); - slopes[2].ratePerDay = 1 ether; - vm.prank(team); - source.setInflation(slopes); // Add from start + function testUnstakeForMigration() public { + vm.startPrank(migrator); - vm.prank(alice); - staking.stake(1 ether, 360 days, alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.warp(EPOCH + 9 days); + uint256[] memory lockupIds = new uint256[](2); + lockupIds[1] = 1; + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstakeFrom(alice, lockupIds); - vm.prank(alice); - staking.stake(1 ether, 60 days, alice); - vm.prank(bob); - staking.stake(1 ether, 90 days, bob); - vm.prank(alice); - staking.stake(1 ether, 180 days, alice); - vm.prank(bob); - staking.stake(1 ether, 240 days, bob); - vm.prank(alice); - staking.stake(1 ether, 360 days, alice); - vm.prank(alice); - staking.collectRewards(); - vm.prank(alice); - staking.collectRewards(); - } + assertEq(unstakedAmount, 3000 ether, "Penalty applied with Unstake"); - function testZeroSupplyRewardDebtPerShare() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - vm.prank(team); - source.setInflation(slopes); + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - vm.prank(alice); - staking.stake(1 ether, 10 days); - vm.prank(bob); - staking.stake(1 ether, 10 days); + assertEq(staking.balanceOf(alice), 0, "veOGV not burned"); - // Alice will unstake, setting her rewardDebtPerShare - vm.warp(EPOCH + 10 days); - vm.prank(alice); - staking.unstake(0); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - // Bob unstakes, setting the total supply to zero - vm.warp(EPOCH + 20 days); - vm.prank(bob); - staking.unstake(0); + for (uint256 i = 0; i < 2; ++i) { + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, i); - // Alice stakes. - // Even with the total supply being zero, it is important that - // Alice's rewardDebtPerShare per share be set to match the accRewardPerShare - vm.prank(alice); - staking.stake(1 ether, 10 days); + assertEq(end, 0, "Not unstaked"); - // Alice unstakes later. - // If rewardDebtPerShare was wrong, this will fail because she will - // try to collect more OGV than the contract has - vm.warp(EPOCH + 30 days); - vm.prank(alice); - staking.unstake(1); - } + assertEq(points, 0, "Not unstaked, points mismatch"); - function testFuzzCanAlwaysWithdraw(uint96 amountA, uint96 amountB, uint64 durationA, uint64 durationB, uint64 start) - public - { - uint256 HUNDRED_YEARS = 100 * 366 days; - uint256 LAST_START = HUNDRED_YEARS - 1461 days; - vm.warp(start % LAST_START); - - durationA = durationA % uint64(1461 days); - durationB = durationB % uint64(1461 days); - if (durationA < 7 days) { - durationA = 7 days; - } - if (durationB < 7 days) { - durationB = 7 days; + assertEq(amount, 0, "Not unstaked, amount mismatch"); } - if (amountA < 1) { - amountA = 1; - } - if (amountB < 1) { - amountB = 1; - } - - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - vm.prank(team); - source.setInflation(slopes); - vm.prank(alice); - ogv.mint(alice, amountA); - vm.prank(alice); - ogv.approve(address(staking), amountA); - vm.prank(alice); - staking.stake(amountA, durationA, alice); - - vm.prank(bob); - ogv.mint(bob, amountB); - vm.prank(bob); - ogv.approve(address(staking), amountB); - vm.prank(bob); - staking.stake(amountB, durationB, bob); + vm.stopPrank(); + } - vm.warp(HUNDRED_YEARS); - vm.prank(alice); - staking.unstake(0); - vm.prank(bob); - staking.unstake(0); + function testUnstakeFromPermission() public { + vm.prank(team); + uint256[] memory lockupIds = new uint256[](1); + vm.expectRevert(bytes4(keccak256("NotMigrator()"))); + staking.unstakeFrom(alice, lockupIds); } - function testFuzzSemiSanePowerFunction(uint256 start) public { - uint256 HUNDRED_YEARS = 100 * 366 days; - start = start % HUNDRED_YEARS; - vm.warp(start); - vm.prank(bob); - staking.stake(1e18, 10 days, bob); - uint256 y = (356 days + start + 10 days) / 365 days; - uint256 maxPoints = 2 ** y * 1e18; - assertLt(staking.balanceOf(bob), maxPoints); + function testUnstakeLockupLength() public { + vm.prank(alice); + uint256[] memory lockupIds = new uint256[](0); + vm.expectRevert(bytes4(keccak256("NoLockupsToUnstake()"))); + staking.unstake(lockupIds); } } From 51035a93e3273a76fd393e74904e5cb5f62fc437 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 8 May 2024 20:42:15 +0530 Subject: [PATCH 15/16] Return 0 if uninitialized (#415) --- contracts/FixedRateRewardsSource.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/FixedRateRewardsSource.sol b/contracts/FixedRateRewardsSource.sol index 2c51f4d4..0727b0f1 100644 --- a/contracts/FixedRateRewardsSource.sol +++ b/contracts/FixedRateRewardsSource.sol @@ -89,8 +89,10 @@ contract FixedRateRewardsSource is Governable, Initializable { /// @return rewardAmount Amount of reward that'll be distributed if collected now function previewRewards() public view returns (uint256) { RewardConfig memory _config = rewardConfig; + if (_config.lastCollect == 0) { + return 0; + } return (block.timestamp - _config.lastCollect) * _config.rewardsPerSecond; - // return _previewRewards(rewardConfig); } /// @dev Set address of the strategist From 2a512418c7027e6d0056d74f4b3f1f5fe7881c43 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 8 May 2024 20:57:34 +0530 Subject: [PATCH 16/16] Check available balance in `previewRewards` (#413) * Check available balance in `previewRewards` * Chore: forge fmt --------- Co-authored-by: Daniel Von Fange --- contracts/FixedRateRewardsSource.sol | 10 ++++++++-- tests/staking/FixedRateRewardsSource.t.sol | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/contracts/FixedRateRewardsSource.sol b/contracts/FixedRateRewardsSource.sol index 0727b0f1..4ef9f431 100644 --- a/contracts/FixedRateRewardsSource.sol +++ b/contracts/FixedRateRewardsSource.sol @@ -87,12 +87,18 @@ contract FixedRateRewardsSource is Governable, Initializable { /// @dev Compute pending rewards since last collect /// @return rewardAmount Amount of reward that'll be distributed if collected now - function previewRewards() public view returns (uint256) { + function previewRewards() public view returns (uint256 rewardAmount) { RewardConfig memory _config = rewardConfig; + if (_config.lastCollect == 0) { return 0; } - return (block.timestamp - _config.lastCollect) * _config.rewardsPerSecond; + + rewardAmount = (block.timestamp - _config.lastCollect) * _config.rewardsPerSecond; + uint256 balance = IERC20(rewardToken).balanceOf(address(this)); + if (rewardAmount > balance) { + rewardAmount = balance; + } } /// @dev Set address of the strategist diff --git a/tests/staking/FixedRateRewardsSource.t.sol b/tests/staking/FixedRateRewardsSource.t.sol index fce7c11b..07db745d 100644 --- a/tests/staking/FixedRateRewardsSource.t.sol +++ b/tests/staking/FixedRateRewardsSource.t.sol @@ -100,6 +100,18 @@ contract FixedRateRewardsSourceTest is Test { assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); } + function testLowBalanceCollection() public { + // Should also allow disabling rewards + vm.prank(strategist); + rewards.setRewardsPerSecond(2000000 ether); + + // Should never show more than balance + vm.warp(block.number + 10); + assertEq(rewards.previewRewards(), 1000000 ether, "Pending reward mismatch"); + vm.warp(block.number + 123); + assertEq(rewards.previewRewards(), 1000000 ether, "Pending reward mismatch"); + } + function testRewardRatePermission() public { // Should allow Strategist to change vm.prank(strategist);