From ef72063340b0e18b4be83cc8d4c6528e4d858a72 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Wed, 17 Apr 2024 15:51:49 -0400 Subject: [PATCH 1/8] Draft xOGN staking contract --- contracts/ExponentialStaking.sol | 267 +++++++++++++ tests/staking/ExponentialStaking.t.sol | 523 +++++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 contracts/ExponentialStaking.sol create mode 100644 tests/staking/ExponentialStaking.t.sol diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol new file mode 100644 index 00000000..4ade9d69 --- /dev/null +++ b/contracts/ExponentialStaking.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import {ERC20Votes} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import {ERC20Permit} from + "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; +import {ERC20} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/ERC20.sol"; +import {PRBMathUD60x18} from "paulrberg/prb-math@2.5.0/contracts/PRBMathUD60x18.sol"; +import {RewardsSource} from "./RewardsSource.sol"; + +/// @title ExponentialStaking +/// @author Daniel Von Fange +/// @notice Provides staking, vote power history, vote delegation, and rewards +/// distribution. +/// +/// The balance received for staking (and thus the voting power and rewards +/// distribution) goes up exponentially by the end of the staked period. +contract ExponentialStaking is ERC20Votes { + uint256 public immutable epoch; // timestamp + ERC20 public immutable asset; // Must not allow reentrancy + RewardsSource public immutable rewardsSource; + uint256 public immutable minStakeDuration; // in seconds + uint256 public constant maxStakeDuration = 365 days; + uint256 constant YEAR_BASE = 14e17; + int256 constant NEW_STAKE = -1; + + // 2. Staking and Lockup Storage + struct Lockup { + uint128 amount; + uint128 end; + uint256 points; + } + + mapping(address => Lockup[]) public lockups; + + // 3. Reward Storage + mapping(address => uint256) public rewardDebtPerShare; + uint256 public accRewardPerShare; + + // Events + event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); + event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); + event Reward(address indexed user, uint256 amount); + + // Core ERC20 Functions + + constructor(address asset_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_) + ERC20("", "") + ERC20Permit("xOGN") + { + asset = ERC20(asset_); + epoch = epoch_; + minStakeDuration = minStakeDuration_; + rewardsSource = RewardsSource(rewardsSource_); + } + + function name() public pure override returns (string memory) { + return "Staked OGN"; + } + + function symbol() public pure override returns (string memory) { + return "xOGN"; + } + + function transfer(address, uint256) public override returns (bool) { + revert("Staking: Transfers disabled"); + } + + function transferFrom(address, address, uint256) public override returns (bool) { + revert("Staking: Transfers disabled"); + } + + // Staking Functions + + /// @notice Stake asset to an address that may not be the same as the + /// sender of the funds. This can be used to give staked funds to someone + /// else. + /// + /// If staking before the start of staking (epoch), then the lockup start + /// and end dates are shifted forward so that the lockup starts at the + /// epoch. + /// + /// Any rewards previously earned will be paid out or rolled into the stake. + /// + /// @param amountIn asset to lockup in the stake + /// @param duration in seconds for the stake + /// @param to address to receive ownership of the stake + /// @param stakeRewards should pending user rewards be added to the stake + /// @param lockupId previous stake to extend / add funds to. -1 to create a new stake. + function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external { + require(to != address(0), "Staking: To the zero address"); + require(duration >= minStakeDuration, "Staking: Too short"); + require(duration <= maxStakeDuration, "Staking: Too long"); + + uint256 newAmount = amountIn; + uint256 oldPoints = 0; + uint256 oldEnd = 0; + Lockup memory lockup; + + // Allow gifts, but not control of other's accounts + if (to != msg.sender) { + require(stakeRewards == false, "Staking: Self only"); + require(lockupId == NEW_STAKE, "Staking: Self only"); + } + + // Collect funds from user + if (amountIn > 0) { + // Important that `msg.sender` aways pays, not the `to` address. + asset.transferFrom(msg.sender, address(this), amountIn); + // amountIn already added into newAmount during initialization + } + + // Collect funds from old stake (optional) + if (lockupId != NEW_STAKE) { + lockup = lockups[to][uint256(lockupId)]; + uint256 oldAmount = lockup.amount; + oldEnd = lockup.end; + oldPoints = lockup.points; + require(oldAmount > 1, "Staking: Already closed stake"); + emit Unstake(to, uint256(lockupId), oldAmount, oldEnd, oldPoints); + newAmount += oldAmount; + } + + // Collect funds from rewards (optional) + newAmount += _collectRewards(to, stakeRewards); + + // Caculate Points and lockup + require(newAmount > 0, "Staking: Not enough"); + require(newAmount <= type(uint128).max, "Staking: Too much"); + (uint256 newPoints, uint256 newEnd) = previewPoints(newAmount, duration); + require(newPoints + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); + lockup.end = uint128(newEnd); + lockup.amount = uint128(newAmount); // max checked in require above + lockup.points = newPoints; + + // Update or create lockup + if (lockupId != NEW_STAKE) { + require(newEnd > oldEnd, "Staking: New lockup must be longer"); + lockups[to][uint256(lockupId)] = lockup; + } else { + lockups[to].push(lockup); + uint256 numLockups = lockups[to].length; + // Delegate voting power to the receiver, if unregistered and first stake + if (numLockups == 1 && delegates(to) == address(0)) { + _delegate(to, to); + } + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + } + _mint(to, newPoints - oldPoints); + emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); + } + + /// @notice Collect staked asset for a lockup and any earned rewards. + /// @param lockupId the id of the lockup to unstake + function unstake(uint256 lockupId) external { + Lockup memory lockup = lockups[msg.sender][lockupId]; + uint256 amount = lockup.amount; + uint256 end = lockup.end; + uint256 points = lockup.points; + require(end != 0, "Staking: Already unstaked this lockup"); + _collectRewards(msg.sender, false); + + uint256 withdrawAmount = previewWithdraw(amount, end); + uint256 penalty = amount - withdrawAmount; + + delete lockups[msg.sender][lockupId]; // Keeps empty in array, so indexes are stable + _burn(msg.sender, points); + if (penalty > 0) { + asset.transfer(address(rewardsSource), penalty); + } + asset.transfer(msg.sender, withdrawAmount); + emit Unstake(msg.sender, lockupId, withdrawAmount, end, points); + } + + // 3. Reward functions + + /// @notice Collect all earned asset rewards. + function collectRewards() external { + _collectRewards(msg.sender, false); + } + + /// @dev Internal function to handle rewards accounting. + /// + /// 1. Collect new rewards for everyone + /// 2. Calculate this user's rewards and accounting + /// 3. Distribute this user's rewards + /// + /// This function *must* be called before any user balance changes. + /// + /// This will always update the user's rewardDebtPerShare to match + /// accRewardPerShare, which is essential to the accounting. + /// + /// @param user to collect rewards for + /// @param shouldRetainRewards if true user's rewards kept in this contract rather than sent + /// @return retainedRewards amount of rewards not sent to user + function _collectRewards(address user, bool shouldRetainRewards) internal returns (uint256) { + uint256 supply = totalSupply(); + if (supply > 0) { + uint256 preBalance = asset.balanceOf(address(this)); + try rewardsSource.collectRewards() {} + catch { + // Governance staking should continue, even if rewards fail + } + uint256 collected = asset.balanceOf(address(this)) - preBalance; + accRewardPerShare += (collected * 1e12) / supply; + } + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; + uint256 netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + rewardDebtPerShare[user] = accRewardPerShare; + if (netRewards == 0) { + return 0; + } + emit Reward(user, netRewards); + if (shouldRetainRewards) { + return netRewards; + } else { + asset.transfer(user, netRewards); + } + } + + /// @notice Preview the number of points that would be returned for the + /// given amount and duration. + /// + /// @param amount asset to be staked + /// @param duration number of seconds to stake for + /// @return points staking points that would be returned + /// @return end staking period end date + function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { + require(duration <= 1461 days, "Staking: Too long"); + uint256 start = block.timestamp > epoch ? block.timestamp : epoch; + uint256 end = start + duration; + uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; + uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); + return ((amount * multiplier) / 1e18, end); + } + + /// @notice Preview the amount of asset a user would receive if they collected + /// rewards at this time. + /// + /// @param user to preview rewards for + /// @return asset rewards amount + function previewRewards(address user) external view returns (uint256) { + uint256 supply = totalSupply(); + if (supply == 0) { + return 0; // No one has any points to even get rewards + } + uint256 _accRewardPerShare = accRewardPerShare; + _accRewardPerShare += (rewardsSource.previewRewards() * 1e12) / supply; + uint256 netRewardsPerShare = _accRewardPerShare - rewardDebtPerShare[user]; + return (balanceOf(user) * netRewardsPerShare) / 1e12; + } + + /// @notice Preview the amount that a user would receive if they withdraw now. + /// This amount is after any early withdraw fees are removed for early withdraws. + /// @param amount staked asset amount to be withdrawn + /// @param end stake end date to be withdrawn from. + /// @return withdrawAmount amount of assets that the user will receive from withdraw + function previewWithdraw(uint256 amount, uint256 end) public view returns (uint256) { + if (block.timestamp >= end) { + return amount; + } + uint256 fullDuration = end - block.timestamp; + (uint256 fullPoints,) = previewPoints(1e18, fullDuration); + (uint256 currentPoints,) = previewPoints(1e36, 0); // 1e36 saves a later multiplication + return amount * ((currentPoints / fullPoints)) / 1e18; + } +} diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol new file mode 100644 index 00000000..470ef7dd --- /dev/null +++ b/tests/staking/ExponentialStaking.t.sol @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.10; + +import "forge-std/Test.sol"; +import "contracts/upgrades/RewardsSourceProxy.sol"; +import "contracts/upgrades/OgvStakingProxy.sol"; +import "contracts/ExponentialStaking.sol"; +import "contracts/RewardsSource.sol"; +import "contracts/tests/MockOgv.sol"; + +contract exponentialStakingTest is Test { + MockOGV ogn; + ExponentialStaking staking; + RewardsSource source; + + address alice = address(0x42); + address bob = address(0x43); + address team = address(0x44); + + uint256 constant EPOCH = 1 days; + uint256 constant MIN_STAKE_DURATION = 7 days; + int256 constant NEW_STAKE = -1; + + function setUp() public { + vm.startPrank(team); + ogn = new MockOGV(); + source = new RewardsSource(address(ogn)); + + RewardsSourceProxy rewardsProxy = new RewardsSourceProxy(); + rewardsProxy.initialize(address(source), team, ""); + source = RewardsSource(address(rewardsProxy)); + + staking = new ExponentialStaking(address(ogn), EPOCH, MIN_STAKE_DURATION, address(source)); + OgvStakingProxy stakingProxy = new OgvStakingProxy(); + stakingProxy.initialize(address(staking), team, ""); + staking = ExponentialStaking(address(stakingProxy)); + + source.setRewardsTarget(address(staking)); + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = 1; + slopes[0].ratePerDay = 0; + source.setInflation(slopes); // Add from start + assertGt(source.lastRewardTime(), 0); + vm.stopPrank(); + + ogn.mint(alice, 1000 ether); + ogn.mint(bob, 1000 ether); + ogn.mint(team, 100000000 ether); + + vm.prank(alice); + ogn.approve(address(staking), 1e70); + vm.prank(bob); + ogn.approve(address(staking), 1e70); + vm.prank(team); + ogn.approve(address(source), 1e70); + } + + function testStakeUnstake() public { + vm.startPrank(alice); + (uint256 previewPoints, uint256 previewEnd) = staking.previewPoints(10 ether, 10 days); + + uint256 beforeOgv = ogn.balanceOf(alice); + uint256 beforexOGN = ogn.balanceOf(address(staking)); + + staking.stake(10 ether, 10 days, alice, false, NEW_STAKE); + + assertEq(ogn.balanceOf(alice), beforeOgv - 10 ether); + assertEq(ogn.balanceOf(address(staking)), beforexOGN + 10 ether); + assertEq(staking.balanceOf(alice), previewPoints); + (uint128 lockupAmount, uint128 lockupEnd, uint256 lockupPoints) = staking.lockups(alice, 0); + assertEq(lockupAmount, 10 ether); + assertEq(lockupEnd, EPOCH + 10 days); + assertEq(lockupEnd, previewEnd); + assertEq(lockupPoints, previewPoints); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); + + vm.warp(31 days); + staking.unstake(0); + + assertEq(ogn.balanceOf(alice), beforeOgv); + assertEq(ogn.balanceOf(address(staking)), 0); + (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); + assertEq(lockupAmount, 0); + assertEq(lockupEnd, 0); + assertEq(lockupPoints, 0); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); + } + + function testMatchedDurations() public { + vm.prank(alice); + staking.stake(10 ether, 100 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 90 days); + vm.prank(bob); + staking.stake(10 ether, 10 days, bob, false, NEW_STAKE); + + // Now both have 10 OGV staked for 10 days remaining + // which should mean that they have the same number of points + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + } + + function testPreStaking() public { + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH); + vm.prank(bob); + staking.stake(100 ether, 100 days, bob, false, NEW_STAKE); + + // Both should have the same points + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + } + + function testZeroStake() public { + vm.prank(alice); + vm.expectRevert("Staking: Not enough"); + staking.stake(0 ether, 100 days, alice, false, NEW_STAKE); + } + + function testStakeTooMuch() public { + ogn.mint(alice, 1e70); + vm.prank(alice); + vm.expectRevert("Staking: Too much"); + staking.stake(1e70, 100 days, alice, false, NEW_STAKE); + } + + function testStakeTooLong() public { + vm.prank(alice); + vm.expectRevert("Staking: Too long"); + staking.stake(1 ether, 1700 days, alice, false, NEW_STAKE); + } + + function testStakeTooShort() public { + vm.prank(alice); + vm.expectRevert("Staking: Too short"); + staking.stake(1 ether, 1 days - 60, alice, false, NEW_STAKE); + } + + function testExtend() public { + vm.warp(EPOCH - 5); + + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 10 days, bob, false, NEW_STAKE); + staking.stake(0, 100 days, bob, false, 0); + + // Both are now locked up for the same amount of time, + // and should have the same points. + assertEq(staking.balanceOf(alice), staking.balanceOf(bob), "same balance"); + + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); + } + + function testDoubleExtend() public { + vm.warp(EPOCH + 600 days); + + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 10 days, bob, false, NEW_STAKE); + staking.stake(0, 50 days, bob, false, 0); + staking.stake(0, 100 days, bob, false, 0); + + // Both are now locked up for the same amount of time, + // and should have the same points. + assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + } + + function testShortExtendFail() public { + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, NEW_STAKE); + + vm.startPrank(bob); + staking.stake(100 ether, 11 days, bob, false, NEW_STAKE); + vm.expectRevert("Staking: New lockup must be longer"); + staking.stake(1 ether, 8 days, bob, false, 0); + } + + function testExtendWithAddtionalFunds() external { + vm.prank(alice); + staking.stake(100 ether, 90 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.stake(100 ether, 100 days, alice, false, 0); + + vm.prank(bob); + staking.stake(200 ether, 100 days, bob, false, NEW_STAKE); + + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertEq(aliceAmount, bobAmount, "same amount"); + assertEq(aliceEnd, bobEnd, "same end"); + assertEq(alicePoints, bobPoints, "same points"); + } + + function testExtendWithRewards() external { + vm.prank(alice); + staking.stake(100 ether, 90 days, alice, false, NEW_STAKE); + ogn.mint(address(source), 100 ether); + vm.warp(EPOCH - 1); + vm.prank(alice); + staking.stake(0 ether, 100 days, alice, true, 0); + + vm.prank(bob); + staking.stake(200 ether, 100 days, bob, false, NEW_STAKE); + + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + _assertApproxEqualAliceBob(); + } + + function testDoubleStake() external { + vm.startPrank(alice); + + uint256 beforeOgv = ogn.balanceOf(alice); + staking.stake(3 ether, 10 days, alice, false, NEW_STAKE); + uint256 midOgv = ogn.balanceOf(alice); + uint256 midPoints = staking.balanceOf(alice); + staking.stake(5 ether, 40 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 50 days); + staking.unstake(1); + + assertEq(midPoints, staking.balanceOf(alice)); + assertEq(midOgv, ogn.balanceOf(alice)); + + staking.unstake(0); + assertEq(0, staking.balanceOf(alice)); // No points, since all unstaked + assertEq(beforeOgv, ogn.balanceOf(alice)); // All OGV back + } + + function testCollectRewards() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 4 ether; + slopes[1].start = uint64(EPOCH + 2 days); + slopes[1].ratePerDay = 2 ether; + slopes[2].start = uint64(EPOCH + 7 days); + slopes[2].ratePerDay = 1 ether; + vm.prank(team); + source.setInflation(slopes); // Add from start + + vm.startPrank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 2 days); + uint256 beforeOgv = ogn.balanceOf(alice); + uint256 preview = staking.previewRewards(alice); + staking.collectRewards(); + uint256 afterOgv = ogn.balanceOf(alice); + + uint256 collectedRewards = afterOgv - beforeOgv; + assertApproxEqAbs(collectedRewards, 8 ether, 1e8, "actual amount should be correct"); + assertEq(collectedRewards, preview, "preview should match actual"); + assertApproxEqAbs(preview, 8 ether, 1e8, "preview amount should be correct"); + } + + function testCollectedRewardsJumpInOut() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + + // One day later + vm.warp(EPOCH + 1 days); + vm.prank(alice); + staking.collectRewards(); // Alice collects + + vm.prank(bob); + staking.stake(1 ether, 9 days, bob, false, NEW_STAKE); // Bob stakes + + vm.warp(EPOCH + 2 days); // Alice and bob should split rewards evenly + uint256 aliceBefore = ogn.balanceOf(alice); + uint256 bobBefore = ogn.balanceOf(bob); + vm.prank(alice); + staking.collectRewards(); // Alice collects + vm.prank(bob); + staking.collectRewards(); // Bob collects + assertEq(ogn.balanceOf(alice) - aliceBefore, ogn.balanceOf(bob) - bobBefore); + } + + function testMultipleUnstake() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.startPrank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.warp(EPOCH + 11 days); + staking.unstake(0); + vm.expectRevert("Staking: Already unstaked this lockup"); + staking.unstake(0); + } + + function testEarlyUnstake() public { + vm.startPrank(alice); + vm.warp(EPOCH); + staking.stake(1 ether, 200 days, alice, false, NEW_STAKE); + + // console.log("----"); + // for(uint256 i = 0; i < 721; i++){ + // console.log(i, staking.previewWithdraw(1e18, EPOCH + i * 1 days)); + // } + // console.log("----"); + + vm.warp(EPOCH + 100 days); + uint256 before = ogn.balanceOf(alice); + staking.unstake(0); + uint256 returnAmount = ogn.balanceOf(alice) - before; + assertEq(returnAmount, 911937178579591520); + } + + function testCollectRewardsOnExpand() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 10 days, bob, false, NEW_STAKE); + + vm.warp(EPOCH + 6 days); + + vm.prank(bob); + staking.collectRewards(); + vm.prank(alice); + staking.stake(0, 10 days, alice, false, 0); + + assertEq(ogn.balanceOf(alice), ogn.balanceOf(bob)); + } + + function testNoSupplyShortCircuts() public { + uint256 beforeAlice = ogn.balanceOf(alice); + + vm.prank(alice); + staking.previewRewards(alice); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(alice); + staking.collectRewards(); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(bob); + staking.stake(1 ether, 9 days, bob, false, NEW_STAKE); + + vm.prank(alice); + staking.previewRewards(alice); + assertEq(ogn.balanceOf(alice), beforeAlice); + + vm.prank(alice); + staking.collectRewards(); + assertEq(ogn.balanceOf(alice), beforeAlice); + } + + function testMultipleStakesSameBlock() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 4 ether; + slopes[1].start = uint64(EPOCH + 2 days); + slopes[1].ratePerDay = 2 ether; + slopes[2].start = uint64(EPOCH + 7 days); + slopes[2].ratePerDay = 1 ether; + vm.prank(team); + source.setInflation(slopes); // Add from start + + vm.prank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + + vm.warp(EPOCH + 9 days); + + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 90 days, bob, false, NEW_STAKE); + vm.prank(alice); + staking.stake(1 ether, 180 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 240 days, bob, false, NEW_STAKE); + vm.prank(alice); + staking.stake(1 ether, 360 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.collectRewards(); + vm.prank(alice); + staking.collectRewards(); + } + + function testZeroSupplyRewardDebtPerShare() public { + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + vm.prank(bob); + staking.stake(1 ether, 10 days, bob, false, NEW_STAKE); + + // Alice will unstake, setting her rewardDebtPerShare + vm.warp(EPOCH + 10 days); + vm.prank(alice); + staking.unstake(0); + + // Bob unstakes, setting the total supply to zero + vm.warp(EPOCH + 20 days); + vm.prank(bob); + staking.unstake(0); + + // Alice stakes. + // Even with the total supply being zero, it is important that + // Alice's rewardDebtPerShare per share be set to match the accRewardPerShare + vm.prank(alice); + staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); + + // Alice unstakes later. + // If rewardDebtPerShare was wrong, this will fail because she will + // try to collect more OGV than the contract has + vm.warp(EPOCH + 30 days); + vm.prank(alice); + staking.unstake(1); + } + + function testFuzzCanAlwaysWithdraw(uint96 amountA, uint96 amountB, uint64 durationA, uint64 durationB, uint64 start) + public + { + uint256 HUNDRED_YEARS = 100 * 366 days; + uint256 LAST_START = HUNDRED_YEARS - 366 days; + vm.warp(start % LAST_START); + + durationA = durationA % uint64(365 days); + durationB = durationB % uint64(365 days); + if (durationA < 7 days) { + durationA = 7 days; + } + if (durationB < 7 days) { + durationB = 7 days; + } + if (amountA < 1) { + amountA = 1; + } + if (amountB < 1) { + amountB = 1; + } + + RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); + slopes[0].start = uint64(EPOCH); + slopes[0].ratePerDay = 2 ether; + vm.prank(team); + source.setInflation(slopes); + + vm.prank(alice); + ogn.mint(alice, amountA); + vm.prank(alice); + ogn.approve(address(staking), amountA); + vm.prank(alice); + staking.stake(amountA, durationA, alice, false, NEW_STAKE); + + vm.prank(bob); + ogn.mint(bob, amountB); + vm.prank(bob); + ogn.approve(address(staking), amountB); + vm.prank(bob); + staking.stake(amountB, durationB, bob, false, NEW_STAKE); + + vm.warp(HUNDRED_YEARS); + vm.prank(alice); + staking.unstake(0); + vm.prank(bob); + staking.unstake(0); + } + + function testFuzzSemiSanePowerFunction(uint256 start) public { + uint256 HUNDRED_YEARS = 100 * 366 days; + start = start % HUNDRED_YEARS; + vm.warp(start); + vm.prank(bob); + staking.stake(1e18, 10 days, bob, false, NEW_STAKE); + uint256 y = (356 days + start + 10 days) / 365 days; + uint256 maxPoints = 2 ** y * 1e18; + assertLt(staking.balanceOf(bob), maxPoints); + } + + function _assertApproxEqualAliceBob() internal { + // Both should now have the same amount locked up for the same end date + // which should result in the same stakes + (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); + (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); + assertLt(aliceAmount, bobAmount * 100001 / 100000, "same amount"); + assertLt(aliceEnd, bobEnd * 100001 / 100000, "same end"); + assertLt(alicePoints, bobPoints * 100001 / 100000, "same points"); + + assertGt(aliceAmount, bobAmount * 99999 / 100000, "same amount"); + assertGt(aliceEnd, bobEnd * 99999 / 100000, "same end"); + assertGt(alicePoints, bobPoints * 99999 / 100000, "same points"); + } +} From 1126cf2064a2ad096a6b445d39b6746b6e274081 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 18 Apr 2024 09:16:37 -0400 Subject: [PATCH 2/8] Correct maxStakeDuration --- contracts/ExponentialStaking.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 4ade9d69..814381fd 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -90,7 +90,7 @@ contract ExponentialStaking is ERC20Votes { function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external { require(to != address(0), "Staking: To the zero address"); require(duration >= minStakeDuration, "Staking: Too short"); - require(duration <= maxStakeDuration, "Staking: Too long"); + // Too long checked in preview points uint256 newAmount = amountIn; uint256 oldPoints = 0; @@ -226,7 +226,7 @@ contract ExponentialStaking is ERC20Votes { /// @return points staking points that would be returned /// @return end staking period end date function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { - require(duration <= 1461 days, "Staking: Too long"); + require(duration <= maxStakeDuration, "Staking: Too long"); uint256 start = block.timestamp > epoch ? block.timestamp : epoch; uint256 end = start + duration; uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; From cd1bfdab2af8e6bf73f39c150d18d269dbb053ee Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 18 Apr 2024 09:18:32 -0400 Subject: [PATCH 3/8] Add penalty event --- contracts/ExponentialStaking.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 814381fd..bce03f47 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -41,6 +41,7 @@ contract ExponentialStaking is ERC20Votes { event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Reward(address indexed user, uint256 amount); + event Penalty(address indexed user, uint256 amount); // Core ERC20 Functions @@ -167,6 +168,7 @@ contract ExponentialStaking is ERC20Votes { _burn(msg.sender, points); if (penalty > 0) { asset.transfer(address(rewardsSource), penalty); + emit Penalty(msg.sender, penalty); } asset.transfer(msg.sender, withdrawAmount); emit Unstake(msg.sender, lockupId, withdrawAmount, end, points); From 3f51c2d70308499b4981396bbae19bb4920a6348 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:32:38 +0530 Subject: [PATCH 4/8] Fix lockup ID --- contracts/ExponentialStaking.sol | 8 +++++--- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index bce03f47..89573994 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -139,13 +139,15 @@ contract ExponentialStaking is ERC20Votes { require(newEnd > oldEnd, "Staking: New lockup must be longer"); lockups[to][uint256(lockupId)] = lockup; } else { + lockupId = lockups[to].length; + require(lockupId < uint256(type(int256).max), "Staking: Too many lockups"); + lockups[to].push(lockup); - uint256 numLockups = lockups[to].length; + // Delegate voting power to the receiver, if unregistered and first stake - if (numLockups == 1 && delegates(to) == address(0)) { + if (lockupId == 0 && delegates(to) == address(0)) { _delegate(to, to); } - require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 470ef7dd..7a8ca722 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -6,7 +6,7 @@ import "contracts/upgrades/RewardsSourceProxy.sol"; import "contracts/upgrades/OgvStakingProxy.sol"; import "contracts/ExponentialStaking.sol"; import "contracts/RewardsSource.sol"; -import "contracts/tests/MockOgv.sol"; +import "contracts/tests/MockOGV.sol"; contract exponentialStakingTest is Test { MockOGV ogn; From c02d6b56053394ea277b3164eaede64b675aed88 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:39:04 +0530 Subject: [PATCH 5/8] Revert change and cast properly --- contracts/ExponentialStaking.sol | 9 ++++----- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 89573994..33194e05 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -139,15 +139,14 @@ contract ExponentialStaking is ERC20Votes { require(newEnd > oldEnd, "Staking: New lockup must be longer"); lockups[to][uint256(lockupId)] = lockup; } else { - lockupId = lockups[to].length; - require(lockupId < uint256(type(int256).max), "Staking: Too many lockups"); - lockups[to].push(lockup); - + uint256 numLockups = lockups[to].length; // Delegate voting power to the receiver, if unregistered and first stake - if (lockupId == 0 && delegates(to) == address(0)) { + if (numLockups == 1 && delegates(to) == address(0)) { _delegate(to, to); } + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + lockupId = int256(numLockups - 1); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 7a8ca722..4bbb2be0 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -8,7 +8,7 @@ import "contracts/ExponentialStaking.sol"; import "contracts/RewardsSource.sol"; import "contracts/tests/MockOGV.sol"; -contract exponentialStakingTest is Test { +contract ExponentialStakingTest is Test { MockOGV ogn; ExponentialStaking staking; RewardsSource source; From 820fccf79076e35a8edd20805790a62c52e9b815 Mon Sep 17 00:00:00 2001 From: Shahul Hameed <10547529+shahthepro@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:53:41 +0530 Subject: [PATCH 6/8] Add `getLockupsCount` method (#411) --- contracts/ExponentialStaking.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 33194e05..63fbecff 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -267,4 +267,12 @@ contract ExponentialStaking is ERC20Votes { (uint256 currentPoints,) = previewPoints(1e36, 0); // 1e36 saves a later multiplication return amount * ((currentPoints / fullPoints)) / 1e18; } + + /// @notice Returns the total number of lockups the user has + /// created so far (including expired & unstaked ones) + /// @param user Address + /// @return asset Number of lockups the user has had + function getLockupsCount(address user) external view returns (uint256) { + return lockups[user].length; + } } From 37843333f8a98e0feeb0fd6b6ebda6145a243fe0 Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Wed, 24 Apr 2024 12:42:29 -0400 Subject: [PATCH 7/8] Allow non-duration change amount increase staking extends --- contracts/ExponentialStaking.sol | 5 +++-- tests/staking/ExponentialStaking.t.sol | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index 63fbecff..dd765b7a 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -16,7 +16,7 @@ import {RewardsSource} from "./RewardsSource.sol"; /// The balance received for staking (and thus the voting power and rewards /// distribution) goes up exponentially by the end of the staked period. contract ExponentialStaking is ERC20Votes { - uint256 public immutable epoch; // timestamp + uint256 public immutable epoch; // Start of staking program - timestamp ERC20 public immutable asset; // Must not allow reentrancy RewardsSource public immutable rewardsSource; uint256 public immutable minStakeDuration; // in seconds @@ -136,7 +136,8 @@ contract ExponentialStaking is ERC20Votes { // Update or create lockup if (lockupId != NEW_STAKE) { - require(newEnd > oldEnd, "Staking: New lockup must be longer"); + require(newEnd >= oldEnd, "Staking: New lockup must not be shorter"); + require(newPoints > oldPoints, "Staking: Must have increased amount or duration"); lockups[to][uint256(lockupId)] = lockup; } else { lockups[to].push(lockup); diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 4bbb2be0..83b4adf1 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -186,7 +186,7 @@ contract ExponentialStakingTest is Test { vm.startPrank(bob); staking.stake(100 ether, 11 days, bob, false, NEW_STAKE); - vm.expectRevert("Staking: New lockup must be longer"); + vm.expectRevert("Staking: New lockup must not be shorter"); staking.stake(1 ether, 8 days, bob, false, 0); } From 1864b688bbe8c7fc597c014c07928d4df7a2911f Mon Sep 17 00:00:00 2001 From: Daniel Von Fange Date: Thu, 25 Apr 2024 11:31:52 -0400 Subject: [PATCH 8/8] Add tests, add move lockupid code --- contracts/ExponentialStaking.sol | 6 +-- tests/staking/ExponentialStaking.t.sol | 66 +++++++++++++++++++++----- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/contracts/ExponentialStaking.sol b/contracts/ExponentialStaking.sol index dd765b7a..e43ea552 100644 --- a/contracts/ExponentialStaking.sol +++ b/contracts/ExponentialStaking.sol @@ -142,12 +142,12 @@ contract ExponentialStaking is ERC20Votes { } else { lockups[to].push(lockup); uint256 numLockups = lockups[to].length; + require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); + lockupId = int256(numLockups - 1); // Delegate voting power to the receiver, if unregistered and first stake if (numLockups == 1 && delegates(to) == address(0)) { _delegate(to, to); } - require(numLockups < uint256(type(int256).max), "Staking: Too many lockups"); - lockupId = int256(numLockups - 1); } _mint(to, newPoints - oldPoints); emit Stake(to, uint256(lockupId), newAmount, newEnd, newPoints); @@ -273,7 +273,7 @@ contract ExponentialStaking is ERC20Votes { /// created so far (including expired & unstaked ones) /// @param user Address /// @return asset Number of lockups the user has had - function getLockupsCount(address user) external view returns (uint256) { + function lockupsCount(address user) external view returns (uint256) { return lockups[user].length; } } diff --git a/tests/staking/ExponentialStaking.t.sol b/tests/staking/ExponentialStaking.t.sol index 83b4adf1..f4b608b0 100644 --- a/tests/staking/ExponentialStaking.t.sol +++ b/tests/staking/ExponentialStaking.t.sol @@ -61,9 +61,11 @@ contract ExponentialStakingTest is Test { uint256 beforeOgv = ogn.balanceOf(alice); uint256 beforexOGN = ogn.balanceOf(address(staking)); + assertEq(staking.lockupsCount(alice), 0); staking.stake(10 ether, 10 days, alice, false, NEW_STAKE); + assertEq(staking.lockupsCount(alice), 1); assertEq(ogn.balanceOf(alice), beforeOgv - 10 ether); assertEq(ogn.balanceOf(address(staking)), beforexOGN + 10 ether); assertEq(staking.balanceOf(alice), previewPoints); @@ -77,6 +79,7 @@ contract ExponentialStakingTest is Test { vm.warp(31 days); staking.unstake(0); + assertEq(staking.lockupsCount(alice), 1); assertEq(ogn.balanceOf(alice), beforeOgv); assertEq(ogn.balanceOf(address(staking)), 0); (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); @@ -158,6 +161,39 @@ contract ExponentialStakingTest is Test { assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); } + function testExtendOnOtherUser() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + + vm.expectRevert("Staking: Self only"); + vm.prank(bob); + staking.stake(1 ether, 60 days, alice, false, 0); + + vm.expectRevert("Staking: Self only"); + vm.prank(bob); + staking.stake(1 ether, 60 days, alice, true, NEW_STAKE); + } + + function testExtendOnClosed() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + vm.prank(alice); + staking.unstake(0); + + vm.expectRevert("Staking: Already closed stake"); + vm.prank(alice); + staking.stake(1 ether, 80 days, alice, false, 0); + } + + function testExtendNoChange() public { + vm.prank(alice); + staking.stake(1 ether, 60 days, alice, false, NEW_STAKE); + + vm.expectRevert("Staking: Must have increased amount or duration"); + vm.prank(alice); + staking.stake(0, 60 days, alice, false, 0); + } + function testDoubleExtend() public { vm.warp(EPOCH + 600 days); @@ -300,13 +336,6 @@ contract ExponentialStakingTest is Test { } function testMultipleUnstake() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - - vm.prank(team); - source.setInflation(slopes); - vm.startPrank(alice); staking.stake(1 ether, 10 days, alice, false, NEW_STAKE); vm.warp(EPOCH + 11 days); @@ -315,22 +344,29 @@ contract ExponentialStakingTest is Test { staking.unstake(0); } + function testUnstakeNeverStaked() public { + vm.startPrank(alice); + vm.expectRevert(); + staking.unstake(0); + } + function testEarlyUnstake() public { vm.startPrank(alice); vm.warp(EPOCH); staking.stake(1 ether, 200 days, alice, false, NEW_STAKE); - // console.log("----"); - // for(uint256 i = 0; i < 721; i++){ - // console.log(i, staking.previewWithdraw(1e18, EPOCH + i * 1 days)); - // } - // console.log("----"); - vm.warp(EPOCH + 100 days); uint256 before = ogn.balanceOf(alice); + uint256 beforeCollected = ogn.balanceOf(address(source)); + uint256 expectedWithdraw = staking.previewWithdraw(1 ether, EPOCH + 200 days); + staking.unstake(0); + uint256 returnAmount = ogn.balanceOf(alice) - before; assertEq(returnAmount, 911937178579591520); + assertEq(expectedWithdraw, returnAmount); + uint256 penaltyCollected = ogn.balanceOf(address(source)) - beforeCollected; + assertEq(penaltyCollected, 1 ether - 911937178579591520); } function testCollectRewardsOnExpand() public { @@ -479,8 +515,12 @@ contract ExponentialStakingTest is Test { ogn.mint(alice, amountA); vm.prank(alice); ogn.approve(address(staking), amountA); + assertEq(staking.balanceOf(alice), 0); + // preview check + (uint256 expectedPoints,) = staking.previewPoints(amountA, durationA); vm.prank(alice); staking.stake(amountA, durationA, alice, false, NEW_STAKE); + assertEq(staking.balanceOf(alice), expectedPoints); vm.prank(bob); ogn.mint(bob, amountB);