From b4b800d32c6d31180552f95c9663da4ff9fa98ad Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Fri, 26 Apr 2024 11:47:28 -0400 Subject: [PATCH 1/3] xOGN staking contract (#409) * Draft xOGN staking contract * Correct maxStakeDuration * Add penalty event * Fix lockup ID * Revert change and cast properly * Add `getLockupsCount` method (#411) * Allow non-duration change amount increase staking extends * Add tests, add move lockupid code --------- Co-authored-by: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> --- contracts/ExponentialStaking.sol | 279 ++++++++++++ tests/staking/ExponentialStaking.t.sol | 563 +++++++++++++++++++++++++ 2 files changed, 842 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..e43ea552 --- /dev/null +++ b/contracts/ExponentialStaking.sol @@ -0,0 +1,279 @@ +// 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; // Start of staking program - 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); + event Penalty(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"); + // Too long checked in preview points + + 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 not be shorter"); + require(newPoints > oldPoints, "Staking: Must have increased amount or duration"); + lockups[to][uint256(lockupId)] = lockup; + } 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); + } + } + _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); + emit Penalty(msg.sender, 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 <= maxStakeDuration, "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; + } + + /// @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 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 new file mode 100644 index 00000000..f4b608b0 --- /dev/null +++ b/tests/staking/ExponentialStaking.t.sol @@ -0,0 +1,563 @@ +// 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)); + 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); + (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(staking.lockupsCount(alice), 1); + 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 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); + + 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 not be shorter"); + 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 { + 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 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); + + 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 { + 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); + 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); + 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 7f166bdba13c726e4f596510639665d8eff11f1d Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Thu, 16 May 2024 23:31:02 +0400 Subject: [PATCH 2/3] OGN Token Staking + Migration * Add OGNRewardsSource contract * Make collectRewards only callable by RewardsTarget * Draft xOGN staking contract * Correct maxStakeDuration * Add penalty event * Change names * Fix lockup ID * Revert change and cast properly * Gas opts * Remove casting * Add `getLockupsCount` method (#411) * Allow non-duration change amount increase staking extends * Add tests, add move lockupid code * 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 * Return 0 if uninitialized (#415) * Check available balance in `previewRewards` (#413) * Check available balance in `previewRewards` * Chore: forge fmt --------- Co-authored-by: Daniel Von Fange --------- Co-authored-by: Daniel Von Fange --- contracts/FixedRateRewardsSource.sol | 144 ++++++ contracts/Migrator.sol | 228 +++++++++ contracts/OgvStaking.sol | 207 ++++---- contracts/tests/MockOGN.sol | 26 + contracts/tests/MockOGV.sol | 8 + contracts/tests/MockOGVStaking.sol | 59 +++ contracts/tests/MockRewardsSource.sol | 14 + .../upgrades/FixedRateRewardsSourceProxy.sol | 7 + contracts/upgrades/Initializable.sol | 39 ++ 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/FixedRateRewardsSource.t.sol | 183 +++++++ tests/staking/Migrator.t.sol | 413 +++++++++++++++ tests/staking/OgvStaking.t.sol | 472 +++++------------- 19 files changed, 1624 insertions(+), 691 deletions(-) create mode 100644 contracts/FixedRateRewardsSource.sol create mode 100644 contracts/Migrator.sol create mode 100644 contracts/tests/MockOGN.sol create mode 100644 contracts/tests/MockOGVStaking.sol create mode 100644 contracts/tests/MockRewardsSource.sol create mode 100644 contracts/upgrades/FixedRateRewardsSourceProxy.sol create mode 100644 contracts/upgrades/Initializable.sol create mode 100644 contracts/upgrades/MigratorProxy.sol create mode 100644 tests/staking/FixedRateRewardsSource.t.sol create mode 100644 tests/staking/Migrator.t.sol diff --git a/contracts/FixedRateRewardsSource.sol b/contracts/FixedRateRewardsSource.sol new file mode 100644 index 00000000..4ef9f431 --- /dev/null +++ b/contracts/FixedRateRewardsSource.sol @@ -0,0 +1,144 @@ +// 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 FixedRateRewardsSource is Governable, Initializable { + error UnauthorizedCaller(); + 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 rewardToken; + + 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 _rewardToken) { + rewardToken = _rewardToken; + } + + /// @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, uint192 _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 rewardAmount) { + address _target = rewardsTarget; + if (_target != msg.sender) { + revert UnauthorizedCaller(); + } + + // Compute pending rewards + rewardAmount = previewRewards(); + + // Update timestamp + rewardConfig.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(rewardToken).transfer(_target, rewardAmount); + } + } + + /// @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 rewardAmount) { + RewardConfig memory _config = rewardConfig; + + if (_config.lastCollect == 0) { + return 0; + } + + 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 + /// @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 rewardToken to distribute per second + 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(uint192 _rewardsPerSecond) internal { + // Update storage + RewardConfig storage _config = rewardConfig; + emit RewardsPerSecondChanged(_rewardsPerSecond, _config.rewardsPerSecond); + _config.rewardsPerSecond = _rewardsPerSecond; + } +} 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 new file mode 100644 index 00000000..1e2cd9c9 --- /dev/null +++ b/contracts/tests/MockOGN.sol @@ -0,0 +1,26 @@ +// 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 { + 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/FixedRateRewardsSourceProxy.sol b/contracts/upgrades/FixedRateRewardsSourceProxy.sol new file mode 100644 index 00000000..ac48b516 --- /dev/null +++ b/contracts/upgrades/FixedRateRewardsSourceProxy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.10; + +import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol"; + +contract FixedRateRewardsSourceProxy is InitializeGovernedUpgradeabilityProxy {} 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/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/FixedRateRewardsSource.t.sol b/tests/staking/FixedRateRewardsSource.t.sol new file mode 100644 index 00000000..07db745d --- /dev/null +++ b/tests/staking/FixedRateRewardsSource.t.sol @@ -0,0 +1,183 @@ +import "forge-std/Test.sol"; +import "contracts/upgrades/FixedRateRewardsSourceProxy.sol"; +import "contracts/FixedRateRewardsSource.sol"; +import "contracts/tests/MockOGN.sol"; + +contract FixedRateRewardsSourceTest is Test { + MockOGN ogn; + FixedRateRewardsSource 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 FixedRateRewardsSource(address(ogn)); + + // Setup Rewards Proxy + FixedRateRewardsSourceProxy rewardsProxy = new FixedRateRewardsSourceProxy(); + rewardsProxy.initialize(address(rewards), governor, ""); + rewards = FixedRateRewardsSource(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 + vm.prank(staking); + rewards.collectRewards(); + + assertEq(rewards.previewRewards(), 0 ether, "Pending reward mismatch"); + + assertEq(ogn.balanceOf(address(staking)), 10000 ether, "Rewards not distributed to staking"); + } + + function testCollectPermission() public { + // Time travel + vm.warp(block.number + 100); + + // Should allow rewardsTarget to collect rewards + vm.prank(staking); + rewards.collectRewards(); + + // Should not allow anyone else to collect rewards + vm.prank(governor); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); + rewards.collectRewards(); + + vm.prank(alice); + vm.expectRevert(bytes4(keccak256("UnauthorizedCaller()"))); + 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 + vm.prank(staking); + 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 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); + 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)); + } +} 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 ab5559934bc6bfa8ea88100f5bbce84cc0498dab Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 16 May 2024 15:34:08 -0400 Subject: [PATCH 3/3] Reclaim OGN After (#412) --- contracts/Migrator.sol | 40 +++++++----------------------------- tests/staking/Migrator.t.sol | 18 ++-------------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/contracts/Migrator.sol b/contracts/Migrator.sol index c098717a..cf827c04 100644 --- a/contracts/Migrator.sol +++ b/contracts/Migrator.sol @@ -31,8 +31,6 @@ contract Migrator is Governable { 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(); @@ -79,45 +77,21 @@ contract Migrator is Governable { 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. + * transfers it back to the treasury. If migration complete, transfers all. * @param treasury Address that receives excess OGN */ - function transferExcessTokens(address treasury) external onlyGovernor isSolvent { + function transferExcessTokens(address treasury) external onlyGovernor { uint256 availableOGN = ogn.balanceOf(address(this)); - uint256 totalOGV = ogv.totalSupply() - ogv.balanceOf(address(this)); - uint256 maxOGNNeeded = (totalOGV * CONVERSION_RATE) / 1 ether; - if (availableOGN > maxOGNNeeded) { + if (endTime == 0 || isMigrationActive()) { + uint256 maxOGNNeeded = (ogv.totalSupply() * CONVERSION_RATE) / 1 ether; ogn.transfer(treasury, availableOGN - maxOGNNeeded); + } else { + emit Decommissioned(); + ogn.transfer(treasury, availableOGN); } } diff --git a/tests/staking/Migrator.t.sol b/tests/staking/Migrator.t.sol index e5156f21..203ca8b7 100644 --- a/tests/staking/Migrator.t.sol +++ b/tests/staking/Migrator.t.sol @@ -106,8 +106,8 @@ contract MigratorTest is Test { vm.stopPrank(); vm.warp(migrator.endTime() + 100); - - migrator.decommission(); + vm.prank(governor); + migrator.transferExcessTokens(address(0xdead)); assertEq(ogn.balanceOf(address(migrator)), 0 ether, "OGN leftover"); assertEq(ogn.balanceOf(address(0xdead)), maxOgnAmount - 0.09137 ether, "OGN not sent to burn address"); @@ -167,20 +167,6 @@ contract MigratorTest is Test { 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);