diff --git a/contracts/Migrator.sol b/contracts/Migrator.sol new file mode 100644 index 00000000..c098717a --- /dev/null +++ b/contracts/Migrator.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "./Governable.sol"; + +interface IStaking { + function delegates(address staker) external view returns (address); + + // From OGVStaking.sol + function unstakeFrom(address staker, uint256[] memory lockupIds) external returns (uint256, uint256); + + // From ExponentialStaking.sol + function stake(uint256 amountIn, uint256 duration, address to, bool stakeRewards, int256 lockupId) external; +} + +contract Migrator is Governable { + ERC20Burnable public immutable ogv; + ERC20Burnable public immutable ogn; + + IStaking public immutable ogvStaking; + IStaking public immutable ognStaking; + + // Fixed conversion rate + uint256 public constant CONVERSION_RATE = 0.09137 ether; + + uint256 public endTime; + + event TokenExchanged(uint256 ogvAmountIn, uint256 ognAmountOut); + event Decommissioned(); + event LockupsMigrated(address indexed user, uint256[] ogvLockupIds, uint256 newStakeAmount, uint256 newDuration); + + error MigrationAlreadyStarted(); + error MigrationIsInactive(); + error MigrationNotComplete(); + error ContractInsolvent(uint256 expectedOGN, uint256 availableOGN); + error LockupIdsRequired(); + error InvalidStakeAmount(); + + constructor(address _ogv, address _ogn, address _ogvStaking, address _ognStaking) { + ogv = ERC20Burnable(_ogv); + ogn = ERC20Burnable(_ogn); + ogvStaking = IStaking(_ogvStaking); + ognStaking = IStaking(_ognStaking); + } + + /** + * @notice Solvency Checks + * + * This ensures that the contract always has enough OGN to + * continue with the migration. + * However, it doesn't revert if the difference is in favour + * of the contract (i.e. has more OGN than expected). + */ + modifier isSolvent() { + _; + + uint256 availableOGN = ogn.balanceOf(address(this)); + uint256 maxOGNNeeded = (ogv.totalSupply() * CONVERSION_RATE) / 1 ether; + + if (availableOGN < maxOGNNeeded) { + revert ContractInsolvent(maxOGNNeeded, availableOGN); + } + } + + /** + * @notice Starts the migration and sets it to end after + * 365 days. Also, approves xOGN to transfer OGN + * held in this contract. Can be invoked only once + */ + function start() external onlyGovernor isSolvent { + if (endTime != 0) { + revert MigrationAlreadyStarted(); + } + + // Max approve + ogn.approve(address(ognStaking), type(uint256).max); + + endTime = block.timestamp + 365 days; + } + + /** + * @notice Decommissions the contract. Can be called only + * after a year since `start()` was invoked. Burns + * all OGN in the contract by transferring them to + * to address(0xdead). + */ + function decommission() external { + // Only after a year of staking + if (endTime == 0 || isMigrationActive()) { + revert MigrationNotComplete(); + } + + emit Decommissioned(); + + uint256 ognBalance = ogn.balanceOf(address(this)); + if (ognBalance > 0) { + // OGN doesn't allow burning of tokens. Has `onlyOwner` + // modifier on `burn` and `burnFrom` methods. Also, + // `transfer` has a address(0) check. So, this transfers + // everything to address(0xdead). The `owner` multisig of + // OGN token can call `burnFrom(address(0xdead))` later. + + ogn.transfer(address(0xdead), ognBalance); + } + } + + /** + * @notice Computes the amount of OGN needed for migration + * and if the contract has more OGN than that, it + * transfers it back to the treasury. + * @param treasury Address that receives excess OGN + */ + function transferExcessTokens(address treasury) external onlyGovernor isSolvent { + uint256 availableOGN = ogn.balanceOf(address(this)); + uint256 totalOGV = ogv.totalSupply() - ogv.balanceOf(address(this)); + uint256 maxOGNNeeded = (totalOGV * CONVERSION_RATE) / 1 ether; + + if (availableOGN > maxOGNNeeded) { + ogn.transfer(treasury, availableOGN - maxOGNNeeded); + } + } + + /** + * @notice Returns the active status of the migration. + * @return True if migration has started and has not ended yet. + */ + function isMigrationActive() public view returns (bool) { + return endTime > 0 && block.timestamp < endTime; + } + + /** + * @notice Migrates the specified amount of OGV to OGN. + * Does not check if migration is active since + * that's okay (until we decommission). + * @param ogvAmount Amount of OGV to migrate + * @return ognReceived OGN Received + */ + function migrate(uint256 ogvAmount) external isSolvent returns (uint256 ognReceived) { + return _migrate(ogvAmount, msg.sender); + } + + /** + * @notice Migrates OGV stakes to OGN. Can also include unstaked OGN & OGV + * balances from the user's wallet (if specified). + * Does not check if migration is active since that's okay (until + * we decommission the contract). + * @param lockupIds OGV Lockup IDs to be migrated + * @param ogvAmountFromWallet Extra OGV balance from user's wallet to migrate & stake + * @param ognAmountFromWallet Extra OGN balance from user's wallet to stake + * @param migrateRewards If true, Migrate & Stake received rewards + * @param newStakeAmount Max amount of OGN (from wallet+unstake) to stake + * @param newStakeDuration Duration of the new stake + */ + function migrate( + uint256[] calldata lockupIds, + uint256 ogvAmountFromWallet, + uint256 ognAmountFromWallet, + bool migrateRewards, + uint256 newStakeAmount, + uint256 newStakeDuration + ) external isSolvent { + if (lockupIds.length == 0) { + revert LockupIdsRequired(); + } + + // Unstake + (uint256 ogvAmountUnlocked, uint256 rewardsCollected) = ogvStaking.unstakeFrom(msg.sender, lockupIds); + + if (migrateRewards) { + // Include rewards if needed + ogvAmountFromWallet += rewardsCollected; + } + + ogvAmountFromWallet += ogvAmountUnlocked; + + if (ognAmountFromWallet > 0) { + // Transfer in additional OGN to stake from user's wallet + ogn.transferFrom(msg.sender, address(this), ognAmountFromWallet); + } + + // Migrate OGV to OGN and include that along with existing balance + ognAmountFromWallet += _migrate(ogvAmountFromWallet, address(this)); + + if (ognAmountFromWallet < newStakeAmount) { + revert InvalidStakeAmount(); + } + + uint256 ognToWallet = ognAmountFromWallet - newStakeAmount; + if (ognToWallet > 0) { + ogn.transfer(msg.sender, ognToWallet); + } + + if (newStakeAmount > 0) { + // Stake it + ognStaking.stake( + newStakeAmount, + newStakeDuration, + msg.sender, + false, + -1 // New stake + ); + } + + emit LockupsMigrated(msg.sender, lockupIds, newStakeAmount, newStakeDuration); + } + + /** + * @notice Migrates caller's OGV to OGN and sends it to the `receiver` + * @return ognReceived OGN Received + */ + function _migrate(uint256 ogvAmount, address receiver) internal returns (uint256 ognReceived) { + ognReceived = (ogvAmount * CONVERSION_RATE) / 1 ether; + + emit TokenExchanged(ogvAmount, ognReceived); + + ogv.burnFrom(msg.sender, ogvAmount); + + if (receiver != address(this)) { + // When migrating stakes, the contract would directly + // stake the balance on behalf of the user. So there's + // no need to transfer to self. Transfering to user and then + // back to this contract would only increase gas cost (and + // an additional tx for the user). + ogn.transfer(receiver, ognReceived); + } + } +} diff --git a/contracts/OgvStaking.sol b/contracts/OgvStaking.sol index 4f06b815..f467b5f9 100644 --- a/contracts/OgvStaking.sol +++ b/contracts/OgvStaking.sol @@ -44,14 +44,22 @@ contract OgvStaking is ERC20Votes { // unless the user calls `delegate()` method. mapping(address => bool) public hasDelegationSet; + // Migrator contract address + address public immutable migratorAddr; + // Events event Stake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Unstake(address indexed user, uint256 lockupId, uint256 amount, uint256 end, uint256 points); event Reward(address indexed user, uint256 amount); - // 1. Core Functions + // Errors + error NotMigrator(); + error StakingDisabled(); + error NoLockupsToUnstake(); + error AlreadyUnstaked(uint256 lockupId); - constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_) + // 1. Core Functions + constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_, address migrator_) ERC20("", "") ERC20Permit("veOGV") { @@ -59,6 +67,7 @@ contract OgvStaking is ERC20Votes { epoch = epoch_; minStakeDuration = minStakeDuration_; rewardsSource = RewardsSource(rewardsSource_); + migratorAddr = migrator_; } function name() public pure override returns (string memory) { @@ -77,6 +86,14 @@ contract OgvStaking is ERC20Votes { revert("Staking: Transfers disabled"); } + modifier onlyMigrator() { + if (migratorAddr != msg.sender) { + revert NotMigrator(); + } + + _; + } + // 2. Staking and Lockup Functions /// @notice Stake OGV to an address that may not be the same as the @@ -93,7 +110,7 @@ contract OgvStaking is ERC20Votes { /// @param duration in seconds for the stake /// @param to address to receive ownership of the stake function stake(uint256 amount, uint256 duration, address to) external { - _stake(amount, duration, to); + revert StakingDisabled(); } /// @notice Stake OGV @@ -108,54 +125,83 @@ contract OgvStaking is ERC20Votes { /// @param amount OGV to lockup in the stake /// @param duration in seconds for the stake function stake(uint256 amount, uint256 duration) external { - _stake(amount, duration, msg.sender); + revert StakingDisabled(); } - /// @dev Internal method used for public staking - /// @param amount OGV to lockup in the stake - /// @param duration in seconds for the stake - /// @param to address to receive ownership of the stake - function _stake(uint256 amount, uint256 duration, address to) internal { - require(to != address(0), "Staking: To the zero address"); - require(amount <= type(uint128).max, "Staking: Too much"); - require(amount > 0, "Staking: Not enough"); - - // duration checked inside previewPoints - (uint256 points, uint256 end) = previewPoints(amount, duration); - require(points + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); - _collectRewards(to); - lockups[to].push( - Lockup({ - amount: uint128(amount), // max checked in require above - end: uint128(end), - points: points - }) - ); - _mint(to, points); - ogv.transferFrom(msg.sender, address(this), amount); // Important that it's sender - - if (!hasDelegationSet[to] && delegates(to) == address(0)) { - // Delegate voting power to the receiver, if unregistered - _delegate(to, to); - } + /// @notice Collect staked OGV for a lockup and any earned rewards. + /// @param lockupId the id of the lockup to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstake(uint256 lockupId) external returns (uint256 unstakedAmount, uint256 rewardCollected) { + uint256[] memory lockupIds = new uint256[](1); + lockupIds[0] = lockupId; + return _unstake(msg.sender, lockupIds); + } - emit Stake(to, lockups[to].length - 1, amount, end, points); + /// @notice Unstake multiple lockups at once. + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstake(uint256[] memory lockupIds) external returns (uint256 unstakedAmount, uint256 rewardCollected) { + return _unstake(msg.sender, lockupIds); } - /// @notice Collect staked OGV for a lockup and any earned rewards. - /// @param lockupId the id of the lockup to unstake - function unstake(uint256 lockupId) external { - Lockup memory lockup = lockups[msg.sender][lockupId]; - uint256 amount = lockup.amount; - uint256 end = lockup.end; - uint256 points = lockup.points; - require(block.timestamp >= end, "Staking: End of lockup not reached"); - require(end != 0, "Staking: Already unstaked this lockup"); - _collectRewards(msg.sender); - delete lockups[msg.sender][lockupId]; // Keeps empty in array, so indexes are stable - _burn(msg.sender, points); - ogv.transfer(msg.sender, amount); - emit Unstake(msg.sender, lockupId, amount, end, points); + /// @notice Unstakes lockups of an user. + /// Can only be called by the Migrator. + /// @param staker Address of the user + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function unstakeFrom(address staker, uint256[] memory lockupIds) + external + onlyMigrator + returns (uint256 unstakedAmount, uint256 rewardCollected) + { + return _unstake(staker, lockupIds); + } + + /// @notice Unstakes lockups of an user. + /// @param staker Address of the user + /// @param lockupIds Array of the lockup IDs to unstake + /// @return unstakedAmount OGV amount unstaked + /// @return rewardCollected OGV reward amount collected + function _unstake(address staker, uint256[] memory lockupIds) + internal + returns (uint256 unstakedAmount, uint256 rewardCollected) + { + if (lockupIds.length == 0) { + revert NoLockupsToUnstake(); + } + + // Collect rewards + rewardCollected = _collectRewards(staker); + + uint256 unstakedPoints = 0; + + for (uint256 i = 0; i < lockupIds.length; ++i) { + uint256 lockupId = lockupIds[i]; + Lockup memory lockup = lockups[staker][lockupId]; + uint256 amount = lockup.amount; + uint256 end = lockup.end; + uint256 points = lockup.points; + + unstakedAmount += amount; + unstakedPoints += points; + + // Make sure it isn't unstaked already + if (end == 0) { + revert AlreadyUnstaked(lockupId); + } + + delete lockups[staker][lockupId]; // Keeps empty in array, so indexes are stable + + emit Unstake(staker, lockupId, amount, end, points); + } + + // Transfer unstaked OGV + ogv.transfer(staker, unstakedAmount); + // ... and burn veOGV + _burn(staker, unstakedPoints); } /// @notice Extend a stake lockup for additional points. @@ -172,24 +218,7 @@ contract OgvStaking is ERC20Votes { /// @param lockupId the id of the old lockup to extend /// @param duration number of seconds from now to stake for function extend(uint256 lockupId, uint256 duration) external { - // duration checked inside previewPoints - _collectRewards(msg.sender); - Lockup memory lockup = lockups[msg.sender][lockupId]; - uint256 oldAmount = lockup.amount; - uint256 oldEnd = lockup.end; - uint256 oldPoints = lockup.points; - (uint256 newPoints, uint256 newEnd) = previewPoints(oldAmount, duration); - require(newEnd > oldEnd, "Staking: New lockup must be longer"); - lockup.end = uint128(newEnd); - lockup.points = newPoints; - lockups[msg.sender][lockupId] = lockup; - _mint(msg.sender, newPoints - oldPoints); - if (!hasDelegationSet[msg.sender] && delegates(msg.sender) == address(0)) { - // Delegate voting power to the receiver, if unregistered - _delegate(msg.sender, msg.sender); - } - emit Unstake(msg.sender, lockupId, oldAmount, oldEnd, oldPoints); - emit Stake(msg.sender, lockupId, oldAmount, newEnd, newPoints); + revert StakingDisabled(); } /// @notice Preview the number of points that would be returned for the @@ -200,20 +229,15 @@ contract OgvStaking is ERC20Votes { /// @return points staking points that would be returned /// @return end staking period end date function previewPoints(uint256 amount, uint256 duration) public view returns (uint256, uint256) { - require(duration >= minStakeDuration, "Staking: Too short"); - require(duration <= 1461 days, "Staking: Too long"); - uint256 start = block.timestamp > epoch ? block.timestamp : epoch; - uint256 end = start + duration; - uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; - uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); - return ((amount * multiplier) / 1e18, end); + revert StakingDisabled(); } // 3. Reward functions /// @notice Collect all earned OGV rewards. - function collectRewards() external { - _collectRewards(msg.sender); + /// @return rewardCollected OGV reward amount collected + function collectRewards() external returns (uint256 rewardCollected) { + return _collectRewards(msg.sender); } /// @notice Shows the amount of OGV a user would receive if they collected @@ -222,45 +246,40 @@ contract OgvStaking is ERC20Votes { /// @param user to preview rewards for /// @return OGV rewards amount function previewRewards(address user) external view returns (uint256) { - uint256 supply = totalSupply(); - if (supply == 0) { + if (totalSupply() == 0) { return 0; // No one has any points to even get rewards } - uint256 _accRewardPerShare = accRewardPerShare; - _accRewardPerShare += (rewardsSource.previewRewards() * 1e12) / supply; - uint256 netRewardsPerShare = _accRewardPerShare - rewardDebtPerShare[user]; + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; return (balanceOf(user) * netRewardsPerShare) / 1e12; } /// @dev Internal function to handle rewards accounting. /// - /// 1. Collect new rewards for everyone - /// 2. Calculate this user's rewards and accounting - /// 3. Distribute this user's rewards + /// 1. Calculate this user's rewards and accounting + /// 2. Distribute this user's rewards, if any /// /// This function *must* be called before any user balance changes. /// /// This will always update the user's rewardDebtPerShare to match - /// accRewardPerShare, which is essential to the accounting. + /// accRewardPerShare, which is essential to the accounting. This + /// wouldn't allow user to claim rewards twice /// /// @param user to collect rewards for - function _collectRewards(address user) internal { - uint256 supply = totalSupply(); - if (supply > 0) { - uint256 preBalance = ogv.balanceOf(address(this)); - try rewardsSource.collectRewards() {} - catch { - // Governance staking should continue, even if rewards fail - } - uint256 collected = ogv.balanceOf(address(this)) - preBalance; - accRewardPerShare += (collected * 1e12) / supply; + /// @param netRewards Net reward collected for user + function _collectRewards(address user) internal returns (uint256 netRewards) { + if (totalSupply() == 0) { + return 0; // No one has any points to even get rewards } + uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user]; - uint256 netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + netRewards = (balanceOf(user) * netRewardsPerShare) / 1e12; + rewardDebtPerShare[user] = accRewardPerShare; + if (netRewards == 0) { - return; + return 0; } + ogv.transfer(user, netRewards); emit Reward(user, netRewards); } diff --git a/contracts/tests/MockOGN.sol b/contracts/tests/MockOGN.sol index 952d3ea9..1e2cd9c9 100644 --- a/contracts/tests/MockOGN.sol +++ b/contracts/tests/MockOGN.sol @@ -4,9 +4,23 @@ pragma solidity 0.8.10; import {ERC20} from "OpenZeppelin/openzeppelin-contracts@4.6.0/contracts/token/ERC20/ERC20.sol"; contract MockOGN is ERC20 { + uint256 nextTransferAmount; + constructor() ERC20("OGN", "OGN") {} function mint(address to, uint256 amount) external { _mint(to, amount); } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (nextTransferAmount > 0) { + amount = nextTransferAmount; + } + + _transfer(msg.sender, to, amount); + } + + function setNetTransferAmount(uint256 amount) external { + nextTransferAmount = amount; + } } diff --git a/contracts/tests/MockOGV.sol b/contracts/tests/MockOGV.sol index 074c9f5a..750462b1 100644 --- a/contracts/tests/MockOGV.sol +++ b/contracts/tests/MockOGV.sol @@ -9,4 +9,12 @@ contract MockOGV is ERC20 { function mint(address to, uint256 amount) external { _mint(to, amount); } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } + + function burnFrom(address owner, uint256 amount) external { + _burn(owner, amount); + } } diff --git a/contracts/tests/MockOGVStaking.sol b/contracts/tests/MockOGVStaking.sol new file mode 100644 index 00000000..a5292beb --- /dev/null +++ b/contracts/tests/MockOGVStaking.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "../OgvStaking.sol"; + +struct Lockup { + uint128 amount; + uint128 end; + uint256 points; +} + +contract MockOGVStaking is OgvStaking { + constructor(address ogv_, uint256 epoch_, uint256 minStakeDuration_, address rewardsSource_, address migrator_) + OgvStaking(ogv_, epoch_, minStakeDuration_, rewardsSource_, migrator_) + {} + + function _previewPoints(uint256 amount, uint256 duration) internal view returns (uint256, uint256) { + require(duration >= minStakeDuration, "Staking: Too short"); + require(duration <= 1461 days, "Staking: Too long"); + uint256 start = block.timestamp > epoch ? block.timestamp : epoch; + uint256 end = start + duration; + uint256 endYearpoc = ((end - epoch) * 1e18) / 365 days; + uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE, endYearpoc); + return ((amount * multiplier) / 1e18, end); + } + + function mockStake(uint256 amountIn, uint256 duration) external { + mockStake(amountIn, duration, msg.sender); + } + + function mockStake(uint256 amountIn, uint256 duration, address to) public { + Lockup memory lockup; + + ogv.transferFrom(msg.sender, address(this), amountIn); + + (uint256 points, uint256 end) = _previewPoints(amountIn, duration); + require(points + totalSupply() <= type(uint192).max, "Staking: Max points exceeded"); + + lockup.end = uint128(end); + lockup.amount = uint128(amountIn); + lockup.points = points; + + uint256 lockupId = lockups[to].length; + + lockups[to].push(lockup); + + _mint(to, points); + emit Stake(to, uint256(lockupId), amountIn, end, points); + + if (!hasDelegationSet[to]) { + hasDelegationSet[to] = true; + super._delegate(to, to); + } + } + + function setRewardShare(uint256 _accRewardPerShare) external { + accRewardPerShare = _accRewardPerShare; + } +} diff --git a/contracts/tests/MockRewardsSource.sol b/contracts/tests/MockRewardsSource.sol new file mode 100644 index 00000000..2c6b7f38 --- /dev/null +++ b/contracts/tests/MockRewardsSource.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +contract MockRewardsSource { + constructor() {} + + function previewRewards() external view returns (uint256) { + return 0; + } + + function collectRewards() external returns (uint256) { + return 0; + } +} diff --git a/contracts/upgrades/MigratorProxy.sol b/contracts/upgrades/MigratorProxy.sol new file mode 100644 index 00000000..cef75877 --- /dev/null +++ b/contracts/upgrades/MigratorProxy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.10; + +import {InitializeGovernedUpgradeabilityProxy} from "./InitializeGovernedUpgradeabilityProxy.sol"; + +contract MigratorProxy is InitializeGovernedUpgradeabilityProxy {} diff --git a/scripts/deploy_staking.py b/scripts/deploy_staking.py index 665417c0..983e16c0 100644 --- a/scripts/deploy_staking.py +++ b/scripts/deploy_staking.py @@ -1,8 +1,12 @@ from brownie import * -def main(token_address, epoch, rewards_address): +def main(token_address, epoch, rewards_address, mock=False): min_staking = 7 * 24 * 60 * 60 - staking_impl = OgvStaking.deploy(token_address, epoch, min_staking, rewards_address) + if mock: + staking_impl = MockOGVStaking.deploy(token_address, epoch, min_staking, rewards_address, "0x0000000000000000000000000000000000000011") + return Contract.from_abi("MockOGVStaking", staking_impl.address, staking_impl.abi) + + staking_impl = OgvStaking.deploy(token_address, epoch, min_staking, rewards_address, "0x0000000000000000000000000000000000000011") # @TODO Proxy for staking implementation contract return Contract.from_abi("OgvStaking", staking_impl.address, staking_impl.abi) diff --git a/tests/distribution/test_mandatory_lockup.py b/tests/distribution/test_mandatory_lockup.py index f20d3600..eb27d655 100644 --- a/tests/distribution/test_mandatory_lockup.py +++ b/tests/distribution/test_mandatory_lockup.py @@ -1,3 +1,4 @@ +import pytest from brownie import * import brownie from ..helpers import WEEK, DAY @@ -9,7 +10,7 @@ 0xCB8BD9CA540F4B1C63F13D7DDFEC54AB24715F49F9A3640C1CCF9F548A896554, ] - +@pytest.mark.skip() def test_claim(mandatory_lockup_distributor, token, staking): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -30,7 +31,7 @@ def test_claim(mandatory_lockup_distributor, token, staking): assert lockup_four[0] == amount / 4 assert lockup_four[1] == tx.timestamp + 48 * 2629800 - +@pytest.mark.skip() def test_can_not_claim(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 @@ -40,7 +41,7 @@ def test_can_not_claim(mandatory_lockup_distributor, token): with brownie.reverts("Can no longer claim. Claim period expired"): mandatory_lockup_distributor.claim(1, amount, merkle_proof) - +@pytest.mark.skip() def test_burn_remaining_amount(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -51,7 +52,7 @@ def test_burn_remaining_amount(mandatory_lockup_distributor, token): mandatory_lockup_distributor.burnRemainingOGV() assert token.balanceOf(mandatory_lockup_distributor) == 0 - +@pytest.mark.skip() def test_can_not_burn_remaining_amount(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -62,7 +63,7 @@ def test_can_not_burn_remaining_amount(mandatory_lockup_distributor, token): with brownie.reverts("Can not yet burn the remaining OGV"): mandatory_lockup_distributor.burnRemainingOGV() - +@pytest.mark.skip() def test_valid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -71,7 +72,7 @@ def test_valid_proof(mandatory_lockup_distributor, token): 1, amount, accounts.default, merkle_proof ) - +@pytest.mark.skip() def test_invalid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -84,7 +85,7 @@ def test_invalid_proof(mandatory_lockup_distributor, token): 1, amount, accounts.default, false_merkle_proof ) - +@pytest.mark.skip() def test_cannot_claim_with_invalid_proof(mandatory_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup diff --git a/tests/distribution/test_optional_lockup.py b/tests/distribution/test_optional_lockup.py index b016c6ce..c6d4ec1b 100644 --- a/tests/distribution/test_optional_lockup.py +++ b/tests/distribution/test_optional_lockup.py @@ -1,3 +1,4 @@ +import pytest from brownie import * import brownie from ..helpers import WEEK @@ -14,7 +15,7 @@ 0xCB8BD9CA540F4B1C63F13D7DDFEC54AB24715F49F9A3640C1CCF9F548A896554, ] - +@pytest.mark.skip() def test_no_lockup_duration(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -24,7 +25,7 @@ def test_no_lockup_duration(optional_lockup_distributor, token): # Should have gotten amount transferred back to the contract. assert token.balanceOf(accounts.default) == before_balance + amount - +@pytest.mark.skip() def test_claim_with_lockup_duration(optional_lockup_distributor, token, staking): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -34,7 +35,7 @@ def test_claim_with_lockup_duration(optional_lockup_distributor, token, staking) chain.mine() assert staking.lockups(accounts.default, 0)[0] == amount - +@pytest.mark.skip() def test_can_not_claim(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -43,7 +44,7 @@ def test_can_not_claim(optional_lockup_distributor, token): with brownie.reverts("Can no longer claim. Claim period expired"): optional_lockup_distributor.claim(1, amount, merkle_proof, WEEK) - +@pytest.mark.skip() def test_burn_remaining_amount(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -54,7 +55,7 @@ def test_burn_remaining_amount(optional_lockup_distributor, token): optional_lockup_distributor.burnRemainingOGV() assert token.balanceOf(optional_lockup_distributor) == 0 - +@pytest.mark.skip() def test_can_not_burn_remaining_amount(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to give out @@ -65,7 +66,7 @@ def test_can_not_burn_remaining_amount(optional_lockup_distributor, token): with brownie.reverts("Can not yet burn the remaining OGV"): optional_lockup_distributor.burnRemainingOGV() - +@pytest.mark.skip() def test_valid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -74,7 +75,7 @@ def test_valid_proof(optional_lockup_distributor, token): 1, amount, accounts.default, merkle_proof ) - +@pytest.mark.skip() def test_invalid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup @@ -87,7 +88,7 @@ def test_invalid_proof(optional_lockup_distributor, token): 1, amount, accounts.default, false_merkle_proof ) - +@pytest.mark.skip() def test_cannot_claim_with_invalid_proof(optional_lockup_distributor, token): amount = 500000000 * 1e18 # Transfer to the distributor contract so it has something to lockup diff --git a/tests/fixtures.py b/tests/fixtures.py index 5464afe7..f22cb87c 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -135,7 +135,7 @@ def rewards(token): @pytest.fixture def staking(token, rewards): - return run("deploy_staking", "main", (token.address, DAY, rewards.address)) + return run("deploy_staking", "main", (token.address, DAY, rewards.address, True)) @pytest.fixture def whale_voter(token, staking): @@ -143,7 +143,7 @@ def whale_voter(token, staking): voter = accounts[3] amount = int(1e9) * int(1e18) token.approve(staking.address, amount) # Uses coins from default address - staking.stake(amount, WEEK * 52 * 4, voter) + staking.mockStake(amount, WEEK * 52 * 4, voter) return voter @pytest.fixture diff --git a/tests/governance/test_vote.py b/tests/governance/test_vote.py index 95b6d8b7..c995aa88 100644 --- a/tests/governance/test_vote.py +++ b/tests/governance/test_vote.py @@ -81,8 +81,8 @@ def test_proposal_can_fail_vote( token.approve(staking.address, amount * 2, {"from": bob}) token.grantMinterRole(rewards.address, {"from": alice}) rewards.setRewardsTarget(staking.address, {"from": alice}) - staking.stake(amount, WEEK, alice, {"from": alice}) - staking.stake(amount * 2, WEEK, bob, {"from": bob}) + staking.mockStake(amount, WEEK, alice, {"from": alice}) + staking.mockStake(amount * 2, WEEK, bob, {"from": bob}) tx = governance.propose( [governance.address], [0], diff --git a/tests/staking/DelegationTest.t.sol b/tests/staking/DelegationTest.t.sol index a1f7a405..6bd773d6 100644 --- a/tests/staking/DelegationTest.t.sol +++ b/tests/staking/DelegationTest.t.sol @@ -2,246 +2,248 @@ pragma solidity 0.8.10; import "forge-std/Test.sol"; -import "contracts/OgvStaking.sol"; -import "contracts/RewardsSource.sol"; -import "contracts/tests/MockOGV.sol"; +// import "contracts/tests/MockOGVStaking.sol"; +// import "contracts/RewardsSource.sol"; +// import "contracts/tests/MockOGV.sol"; // // Sanity test of OpenZeppelin's voting and delegation. // contract DelegationTest is Test { - using stdStorage for StdStorage; + // using stdStorage for StdStorage; - MockOGV ogv; - OgvStaking staking; - RewardsSource source; + // MockOGV ogv; + // MockOGVStaking staking; + // RewardsSource source; - address oak = address(0x42); - address aspen = address(0x43); - address taz = address(0x44); - address alice = address(0x45); - address bob = address(0x46); - address attacker = address(0x47); - address team = address(0x50); + // address oak = address(0x42); + // address aspen = address(0x43); + // address taz = address(0x44); + // address alice = address(0x45); + // address bob = address(0x46); + // address attacker = address(0x47); + // address team = address(0x50); - uint256 constant EPOCH = 1 days; + // uint256 constant EPOCH = 1 days; - uint256 POINTS = 0; + // uint256 POINTS = 0; function setUp() public { - vm.startPrank(team); - ogv = new MockOGV(); - source = new RewardsSource(address(ogv)); - staking = new OgvStaking(address(ogv), EPOCH, 7 days, address(source)); - source.setRewardsTarget(address(staking)); - vm.stopPrank(); - - ogv.mint(oak, 1000 ether); - ogv.mint(aspen, 1000 ether); - ogv.mint(taz, 100000000 ether); - - vm.prank(oak); - ogv.approve(address(staking), 1e70); - vm.prank(aspen); - ogv.approve(address(staking), 1e70); - vm.prank(taz); - ogv.approve(address(staking), 1e70); - - vm.prank(oak); - staking.stake(1 ether, 100 days); - vm.prank(aspen); - staking.stake(2 ether, 100 days); - vm.prank(taz); - staking.stake(1 ether, 100 days, alice); // Stake for alice - - POINTS = staking.balanceOf(oak); + // vm.startPrank(team); + // ogv = new MockOGV(); + // source = new RewardsSource(address(ogv)); + // staking = new MockOGVStaking(address(ogv), EPOCH, 7 days, address(source), address(0)); + // source.setRewardsTarget(address(staking)); + // vm.stopPrank(); + + // ogv.mint(oak, 1000 ether); + // ogv.mint(aspen, 1000 ether); + // ogv.mint(taz, 100000000 ether); + + // vm.prank(oak); + // ogv.approve(address(staking), 1e70); + // vm.prank(aspen); + // ogv.approve(address(staking), 1e70); + // vm.prank(taz); + // ogv.approve(address(staking), 1e70); + + // vm.prank(oak); + // staking.mockStake(1 ether, 100 days); + // vm.prank(aspen); + // staking.mockStake(2 ether, 100 days); + // vm.prank(taz); + // staking.mockStake(1 ether, 100 days, alice); // Stake for alice + + // POINTS = staking.balanceOf(oak); } - function testAutoDelegateOnStake() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - - // Can opt out of voting after staking - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(oak), address(0)); - } - - function testAutoDelegateOnStakeToOthers() external { - vm.roll(1); - - // Alice should have voting power after taz stakes for her - assertEq(staking.getVotes(alice), POINTS, "can vote after staking"); - assertEq(staking.getVotes(taz), 0, "should not have voting power"); - assertEq(staking.getPastVotes(alice, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.getPastVotes(taz, block.number - 1), 0, "should not have voting power"); - assertEq(staking.delegates(alice), alice, "delegated to receiver after staking"); - assertEq(staking.delegates(taz), address(0), "should not have a delegatee set"); - - vm.roll(2); - - // Alice can opt out of voting after staking - vm.prank(alice); - staking.delegate(address(0)); - assertEq(staking.getVotes(alice), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(alice, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(alice), address(0)); - } - - function testDelegateOnExtendAfterRenounce() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - // Can renounce voting powers - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); - assertEq(staking.delegates(oak), address(0)); - - vm.roll(3); - // Extend shouldn't change manual override - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), address(0), "should not change delegation on extend"); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - } - - function testDelegateOnExtendAfterTransfer() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - // Can move voting power - vm.prank(oak); - staking.delegate(alice); - assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); - assertEq(staking.delegates(oak), alice); - - vm.roll(3); - // Extend shouldn't change manual override - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), alice, "should not change delegation on extend"); - } - - function testDelegateOnExtendForOlderStakes() external { - // For test purposes, undo auto-staking on user - vm.prank(oak); - staking.delegate(address(0)); - stdstore.target(address(staking)).sig(staking.hasDelegationSet.selector).with_key(oak).checked_write(false); - - vm.roll(1); - - // Cannot vote because test undid auto-staking - assertEq(staking.getVotes(oak), 0, "can not vote"); - assertEq(staking.delegates(oak), address(0), "no delegation"); - assertEq(staking.hasDelegationSet(oak), false, "no hasDelegationSet"); - - // Extend should auto-delegate - vm.prank(oak); - staking.extend(0, 200 days); - assertEq(staking.delegates(oak), oak, "should auto delegate on extend"); - assertEq(staking.hasDelegationSet(oak), true, "should have hasDelegationSet"); - assertGt(staking.getVotes(oak), 1 * POINTS, "should have voting power after extend"); - } - - function testDelegate() external { - vm.roll(1); - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - vm.roll(2); - vm.prank(oak); - staking.delegate(aspen); - assertEq(staking.delegates(aspen), aspen); - assertEq(staking.delegates(oak), aspen); - assertEq( - staking.getVotes(aspen), - // Voting power of self + oak - 3 * POINTS, - "can vote after delegation" - ); - assertEq(staking.getPastVotes(aspen, block.number - 1), 2 * POINTS, "can vote after staking"); - } - - function testRenounceVotingPower() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - // Can opt out of voting - vm.roll(2); - vm.prank(oak); - staking.delegate(address(0)); - assertEq(staking.delegates(oak), address(0), "should renouce voting power"); - assertEq(staking.getVotes(oak), 0, "should not have voting power after renouncing"); - assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS, "can vote before renouncing"); - } - - function testSkipAutoDelegateIfDelegated() external { - vm.roll(1); - - // Can vote immediately after staking - assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); - assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); - assertEq(staking.delegates(oak), oak, "self-delegated after staking"); - - // Delegate to someone else - vm.roll(2); - vm.prank(oak); - staking.delegate(bob); - assertEq(staking.delegates(oak), bob, "should set a delegate"); - assertEq(staking.getVotes(oak), 0, "should not have voting power"); - assertEq(staking.getVotes(bob), 1 * POINTS, "should have voting power after delegation"); - - // Stake some more - vm.roll(3); - vm.prank(oak); - staking.stake(1 ether, 100 days); - assertEq(staking.getVotes(oak), 0, "cannot vote after delegation"); - assertEq(staking.getVotes(bob), 2 * POINTS, "should have voting power after delegation"); - assertEq(staking.delegates(oak), bob, "no change in delegation after staking"); - } - - function testRenounceAttack() external { - // Alice can vote, because she is staked - assertEq(staking.getVotes(alice), 1 * POINTS, "can vote after staking"); - - // Alice renounces voting. - vm.prank(alice); - staking.delegate(address(0)); - - // Attacker attacks - vm.startPrank(attacker); - ogv.mint(attacker, 1 ether); - ogv.approve(address(staking), 1 ether); - staking.stake(1 ether, 100 days, alice); - vm.stopPrank(); - - vm.roll(2); - - // Alice should still have renounced voting - assertEq(staking.getVotes(alice), 0, "can't vot after renouncing"); - } + // Commenting out since stake & extend are disabled + + // function testAutoDelegateOnStake() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + + // // Can opt out of voting after staking + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(oak), address(0)); + // } + + // function testAutoDelegateOnStakeToOthers() external { + // vm.roll(1); + + // // Alice should have voting power after taz stakes for her + // assertEq(staking.getVotes(alice), POINTS, "can vote after staking"); + // assertEq(staking.getVotes(taz), 0, "should not have voting power"); + // assertEq(staking.getPastVotes(alice, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.getPastVotes(taz, block.number - 1), 0, "should not have voting power"); + // assertEq(staking.delegates(alice), alice, "delegated to receiver after staking"); + // assertEq(staking.delegates(taz), address(0), "should not have a delegatee set"); + + // vm.roll(2); + + // // Alice can opt out of voting after staking + // vm.prank(alice); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(alice), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(alice, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(alice), address(0)); + // } + + // function testDelegateOnExtendAfterRenounce() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // // Can renounce voting powers + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS); + // assertEq(staking.delegates(oak), address(0)); + + // vm.roll(3); + // // Extend shouldn't change manual override + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), address(0), "should not change delegation on extend"); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // } + + // function testDelegateOnExtendAfterTransfer() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // // Can move voting power + // vm.prank(oak); + // staking.delegate(alice); + // assertEq(staking.getVotes(oak), 0, "zero after delegation removed"); + // assertEq(staking.delegates(oak), alice); + + // vm.roll(3); + // // Extend shouldn't change manual override + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), alice, "should not change delegation on extend"); + // } + + // function testDelegateOnExtendForOlderStakes() external { + // // For test purposes, undo auto-staking on user + // vm.prank(oak); + // staking.delegate(address(0)); + // stdstore.target(address(staking)).sig(staking.hasDelegationSet.selector).with_key(oak).checked_write(false); + + // vm.roll(1); + + // // Cannot vote because test undid auto-staking + // assertEq(staking.getVotes(oak), 0, "can not vote"); + // assertEq(staking.delegates(oak), address(0), "no delegation"); + // assertEq(staking.hasDelegationSet(oak), false, "no hasDelegationSet"); + + // // Extend should auto-delegate + // vm.prank(oak); + // staking.extend(0, 200 days); + // assertEq(staking.delegates(oak), oak, "should auto delegate on extend"); + // assertEq(staking.hasDelegationSet(oak), true, "should have hasDelegationSet"); + // assertGt(staking.getVotes(oak), 1 * POINTS, "should have voting power after extend"); + // } + + // function testDelegate() external { + // vm.roll(1); + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(aspen); + // assertEq(staking.delegates(aspen), aspen); + // assertEq(staking.delegates(oak), aspen); + // assertEq( + // staking.getVotes(aspen), + // // Voting power of self + oak + // 3 * POINTS, + // "can vote after delegation" + // ); + // assertEq(staking.getPastVotes(aspen, block.number - 1), 2 * POINTS, "can vote after staking"); + // } + + // function testRenounceVotingPower() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // // Can opt out of voting + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(address(0)); + // assertEq(staking.delegates(oak), address(0), "should renouce voting power"); + // assertEq(staking.getVotes(oak), 0, "should not have voting power after renouncing"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 1 * POINTS, "can vote before renouncing"); + // } + + // function testSkipAutoDelegateIfDelegated() external { + // vm.roll(1); + + // // Can vote immediately after staking + // assertEq(staking.getVotes(oak), 1 * POINTS, "can vote after staking"); + // assertEq(staking.getPastVotes(oak, block.number - 1), 0, "should not have voting power before staking"); + // assertEq(staking.delegates(oak), oak, "self-delegated after staking"); + + // // Delegate to someone else + // vm.roll(2); + // vm.prank(oak); + // staking.delegate(bob); + // assertEq(staking.delegates(oak), bob, "should set a delegate"); + // assertEq(staking.getVotes(oak), 0, "should not have voting power"); + // assertEq(staking.getVotes(bob), 1 * POINTS, "should have voting power after delegation"); + + // // Stake some more + // vm.roll(3); + // vm.prank(oak); + // staking.stake(1 ether, 100 days); + // assertEq(staking.getVotes(oak), 0, "cannot vote after delegation"); + // assertEq(staking.getVotes(bob), 2 * POINTS, "should have voting power after delegation"); + // assertEq(staking.delegates(oak), bob, "no change in delegation after staking"); + // } + + // function testRenounceAttack() external { + // // Alice can vote, because she is staked + // assertEq(staking.getVotes(alice), 1 * POINTS, "can vote after staking"); + + // // Alice renounces voting. + // vm.prank(alice); + // staking.delegate(address(0)); + + // // Attacker attacks + // vm.startPrank(attacker); + // ogv.mint(attacker, 1 ether); + // ogv.approve(address(staking), 1 ether); + // staking.stake(1 ether, 100 days, alice); + // vm.stopPrank(); + + // vm.roll(2); + + // // Alice should still have renounced voting + // assertEq(staking.getVotes(alice), 0, "can't vot after renouncing"); + // } } diff --git a/tests/staking/Migrator.t.sol b/tests/staking/Migrator.t.sol new file mode 100644 index 00000000..e5156f21 --- /dev/null +++ b/tests/staking/Migrator.t.sol @@ -0,0 +1,413 @@ +import "forge-std/Test.sol"; + +import "contracts/Migrator.sol"; + +import "contracts/OgvStaking.sol"; +import "contracts/ExponentialStaking.sol"; + +import "contracts/upgrades/MigratorProxy.sol"; + +import "contracts/tests/MockOGN.sol"; +import "contracts/tests/MockRewardsSource.sol"; +import "contracts/tests/MockOGV.sol"; +import "contracts/tests/MockOGVStaking.sol"; + +contract MigratorTest is Test { + MockOGV ogv; + MockOGN ogn; + + Migrator migrator; + + ExponentialStaking ognStaking; + MockOGVStaking ogvStaking; + + MockRewardsSource source; + + address alice = address(0x42); + address bob = address(0x43); + address governor = address(0x44); + + uint256 constant EPOCH = 1 days; + uint256 constant MIN_STAKE_DURATION = 7 days; + int256 constant NEW_STAKE = -1; + + function setUp() public { + vm.startPrank(governor); + ogv = new MockOGV(); + ogn = new MockOGN(); + + source = new MockRewardsSource(); + + MigratorProxy mProxy = new MigratorProxy(); + + ognStaking = new ExponentialStaking(address(ogn), EPOCH, MIN_STAKE_DURATION, address(source)); + + ogvStaking = new MockOGVStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), address(mProxy)); + + migrator = new Migrator(address(ogv), address(ogn), address(ogvStaking), address(ognStaking)); + mProxy.initialize(address(migrator), governor, ""); + migrator = Migrator(address(mProxy)); + + // Make sure contract has enough OGN for migration + ogn.mint(address(migrator), 10000000 ether); + + // Users have enough OGV + ogv.mint(alice, 10000000 ether); + ogv.mint(bob, 10000000 ether); + ogv.mint(address(ogvStaking), 10000000 ether); + + // Begin migration + migrator.start(); + + migrator.transferExcessTokens(governor); + + vm.stopPrank(); + + // ... with allowance for Migrator + vm.startPrank(alice); + ogv.approve(address(migrator), type(uint256).max); + ogn.approve(address(migrator), type(uint256).max); + ogv.approve(address(ogvStaking), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bob); + ogv.approve(address(migrator), type(uint256).max); + ogn.approve(address(migrator), type(uint256).max); + ogv.approve(address(ogvStaking), type(uint256).max); + vm.stopPrank(); + } + + function testBalanceMigration() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + uint256 ogvSupply = ogv.totalSupply(); + + vm.startPrank(alice); + migrator.migrate(100 ether); + vm.stopPrank(); + + assertEq(ogv.balanceOf(alice), 10000000 ether - 100 ether, "More OGV burnt"); + assertEq(ogv.totalSupply(), ogvSupply - 100 ether, "OGV supply mismatch"); + + assertEq(ogn.balanceOf(alice), 9.137 ether, "Less OGN received"); + assertEq(ogn.balanceOf(address(migrator)), maxOgnAmount - 9.137 ether, "More OGN sent"); + } + + function testDustBalanceMigration() public { + vm.startPrank(alice); + migrator.migrate(1); + vm.stopPrank(); + } + + function testBurnOnDecomission() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + + vm.startPrank(alice); + migrator.migrate(1 ether); + vm.stopPrank(); + + vm.warp(migrator.endTime() + 100); + + migrator.decommission(); + + assertEq(ogn.balanceOf(address(migrator)), 0 ether, "OGN leftover"); + assertEq(ogn.balanceOf(address(0xdead)), maxOgnAmount - 0.09137 ether, "OGN not sent to burn address"); + } + + function testSolvencyDuringMigrate() public { + uint256 maxOgnAmount = ogn.balanceOf(address(migrator)); + + vm.startPrank(alice); + ogn.setNetTransferAmount(100 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 100 ether + ) + ); + migrator.migrate(1 ether); + + ogn.setNetTransferAmount(0.09138 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 0.09138 ether + ) + ); + migrator.migrate(1 ether); + + ogn.setNetTransferAmount(0.091371115 ether); + vm.expectRevert( + abi.encodeWithSelector( + bytes4(keccak256("ContractInsolvent(uint256,uint256)")), + maxOgnAmount - 0.09137 ether, + maxOgnAmount - 0.091371115 ether + ) + ); + migrator.migrate(1 ether); + + vm.stopPrank(); + } + + function testMigrateAfterTimelimit() public { + // Should allow migration even after timelimit + // but before decommission + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + + vm.warp(migrator.endTime() + 100); + + assertEq(migrator.isMigrationActive(), false, "Migration state not changed"); + + migrator.migrate(1 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + migrator.migrate(ids, 0, 0, false, 0, 0); + vm.stopPrank(); + } + + function testRevertDecommissionBeforeEnd() public { + vm.warp(migrator.endTime() - 1000); + + vm.expectRevert(bytes4(keccak256("MigrationNotComplete()"))); + migrator.decommission(); + } + + function testRevertDecommissionBeforeStart() public { + Migrator newMigrator = new Migrator(address(ogv), address(ogn), address(1), address(1)); + + vm.expectRevert(bytes4(keccak256("MigrationNotComplete()"))); + newMigrator.decommission(); + } + + function testMigrateStakes() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = (11000 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 0, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateSelectedStakes() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](1); + lockupIds[0] = 0; + + uint256 stakeAmount = (10000 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 0, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + // Shouldn't have deleted other migration + (amount, end, points) = ogvStaking.lockups(alice, 1); + assertEq(amount, 1000 ether, "Other lockup deleted"); + + vm.stopPrank(); + } + + function testMigrateStakesWithOGVBalance() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256 balanceBefore = ogv.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = (11500 ether * 0.09137 ether) / 1 ether; + + migrator.migrate(lockupIds, 500 ether, 0, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogv.balanceOf(alice), balanceBefore - 500 ether, "Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateRevertOnEmptyLockups() public { + vm.startPrank(alice); + uint256[] memory lockupIds = new uint256[](0); + + vm.expectRevert(bytes4(keccak256("LockupIdsRequired()"))); + migrator.migrate(lockupIds, 500 ether, 0, false, 9000 ether, 300 days); + + vm.stopPrank(); + } + + function testMigrateWithRewards() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + // Arbitrary reward + ogvStaking.setRewardShare(2 * 1e11); + uint256 expectedRewards = ogvStaking.previewRewards(alice); + uint256 stakeAmount = ((11000 ether + expectedRewards) * 0.09137 ether) / 1 ether; + + migrator.migrate( + lockupIds, + 0, + 0, + true, // Include reward as well + stakeAmount, + 300 days + ); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateStakesWithOGNBalance() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + ogn.mint(alice, 500 ether); + + uint256 ognBalanceBefore = ogn.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = ognBalanceBefore + ((11000 ether * 0.09137 ether) / 1 ether); + + migrator.migrate(lockupIds, 0, 500 ether, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogn.balanceOf(alice), 0, "OGN Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } + + function testMigrateStakesWithOGNAndOGVBalances() public { + vm.startPrank(alice); + ogvStaking.mockStake(10000 ether, 365 days); + ogvStaking.mockStake(1000 ether, 20 days); + + ogn.mint(alice, 500 ether); + + uint256 ognBalanceBefore = ogn.balanceOf(alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + + uint256[] memory lockupIds = new uint256[](2); + lockupIds[0] = 0; + lockupIds[1] = 1; + + uint256 stakeAmount = ognBalanceBefore + ((11500 ether * 0.09137 ether) / 1 ether); + + migrator.migrate(lockupIds, 500 ether, ognBalanceBefore, false, stakeAmount, 300 days); + + // Should have merged it in a single OGN lockup + (uint128 amount, uint128 end, uint256 points) = ognStaking.lockups(alice, 0); + assertEq(amount, stakeAmount, "Lockup not migrated"); + + // Should have updated balance correctly + assertEq(ogn.balanceOf(alice), 0, "OGN Balance mismatch"); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore - 500 ether, "OGN Balance mismatch"); + + vm.expectRevert(); + (amount, end, points) = ognStaking.lockups(alice, 1); + + // Should have removed OGV staked + for (uint256 i = 0; i < lockupIds.length; ++i) { + (amount, end, points) = ogvStaking.lockups(alice, lockupIds[i]); + assertEq(amount, 0, "Lockup still exists"); + assertEq(end, 0, "Lockup still exists"); + assertEq(points, 0, "Lockup still exists"); + } + + vm.stopPrank(); + } +} diff --git a/tests/staking/OgvStaking.t.sol b/tests/staking/OgvStaking.t.sol index be3701af..217d51db 100644 --- a/tests/staking/OgvStaking.t.sol +++ b/tests/staking/OgvStaking.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.10; import "forge-std/Test.sol"; import "contracts/upgrades/RewardsSourceProxy.sol"; import "contracts/upgrades/OgvStakingProxy.sol"; +import "contracts/tests/MockOGVStaking.sol"; import "contracts/OgvStaking.sol"; import "contracts/RewardsSource.sol"; import "contracts/tests/MockOGV.sol"; @@ -11,11 +12,13 @@ import "contracts/tests/MockOGV.sol"; contract OgvStakingTest is Test { MockOGV ogv; OgvStaking staking; + RewardsSource source; address alice = address(0x42); address bob = address(0x43); address team = address(0x44); + address migrator = address(0x50); uint256 constant EPOCH = 1 days; uint256 constant MIN_STAKE_DURATION = 7 days; @@ -29,428 +32,203 @@ contract OgvStakingTest is Test { rewardsProxy.initialize(address(source), team, ""); source = RewardsSource(address(rewardsProxy)); - staking = new OgvStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source)); + staking = new OgvStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), migrator); + MockOGVStaking mockStaking = + new MockOGVStaking(address(ogv), EPOCH, MIN_STAKE_DURATION, address(source), migrator); + OgvStakingProxy stakingProxy = new OgvStakingProxy(); - stakingProxy.initialize(address(staking), team, ""); - staking = OgvStaking(address(stakingProxy)); + stakingProxy.initialize(address(mockStaking), team, ""); - source.setRewardsTarget(address(staking)); - vm.stopPrank(); + source.setRewardsTarget(address(stakingProxy)); - ogv.mint(alice, 1000 ether); - ogv.mint(bob, 1000 ether); - ogv.mint(team, 100000000 ether); + mockStaking = MockOGVStaking(address(stakingProxy)); + mockStaking.setRewardShare(2 * 1e11); - vm.prank(alice); - ogv.approve(address(staking), 1e70); - vm.prank(bob); - ogv.approve(address(staking), 1e70); - vm.prank(team); - ogv.approve(address(source), 1e70); - } + ogv.mint(alice, 10000 ether); + ogv.mint(bob, 10000 ether); + ogv.mint(team, 100000000 ether); + vm.stopPrank(); - function testStakeUnstake() public { vm.startPrank(alice); - (uint256 previewPoints, uint256 previewEnd) = staking.previewPoints(10 ether, 10 days); - - uint256 beforeOgv = ogv.balanceOf(alice); - uint256 beforeOgvStaking = ogv.balanceOf(address(staking)); - - staking.stake(10 ether, 10 days); - - assertEq(ogv.balanceOf(alice), beforeOgv - 10 ether); - assertEq(ogv.balanceOf(address(staking)), beforeOgvStaking + 10 ether); - assertEq(staking.balanceOf(alice), previewPoints); - (uint128 lockupAmount, uint128 lockupEnd, uint256 lockupPoints) = staking.lockups(alice, 0); - assertEq(lockupAmount, 10 ether); - assertEq(lockupEnd, EPOCH + 10 days); - assertEq(lockupEnd, previewEnd); - assertEq(lockupPoints, previewPoints); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); - - vm.warp(31 days); - staking.unstake(0); - - assertEq(ogv.balanceOf(alice), beforeOgv); - assertEq(ogv.balanceOf(address(staking)), 0); - (lockupAmount, lockupEnd, lockupPoints) = staking.lockups(alice, 0); - assertEq(lockupAmount, 0); - assertEq(lockupEnd, 0); - assertEq(lockupPoints, 0); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(alice)); - } - - function testMatchedDurations() public { - vm.prank(alice); - staking.stake(10 ether, 1000 days, alice); + ogv.approve(address(stakingProxy), 1e70); + mockStaking.mockStake(2000 ether, 365 days); + mockStaking.mockStake(1000 ether, 20 days); + vm.stopPrank(); - vm.warp(EPOCH + 900 days); - vm.prank(bob); - staking.stake(10 ether, 100 days, bob); + vm.startPrank(bob); + ogv.approve(address(stakingProxy), 1e70); + mockStaking.mockStake(3300 ether, 60 days); + vm.stopPrank(); - // Now both have 10 OGV staked for 100 days remaining - // which should mean that they have the same number of points - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + vm.startPrank(team); + stakingProxy.upgradeTo(address(staking)); + staking = OgvStaking(address(stakingProxy)); + ogv.approve(address(source), 1e70); + vm.stopPrank(); } - function testPreStaking() public { - vm.prank(alice); - staking.stake(100 ether, 100 days, alice); - - vm.warp(EPOCH); - vm.prank(bob); - staking.stake(100 ether, 100 days, bob); - - // Both should have the same points - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); + function testStake() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.stake(100, 100); } - function testZeroStake() public { - vm.prank(alice); - vm.expectRevert("Staking: Not enough"); - staking.stake(0 ether, 100 days, alice); + function testStakeTo() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.stake(100, 100, address(0xdead)); } - function testStakeTooMuch() public { - vm.prank(alice); - vm.expectRevert("Staking: Too much"); - staking.stake(1e70, 100 days, alice); + function testExtend() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.extend(1, 100); } - function testStakeTooLong() public { - vm.prank(alice); - vm.expectRevert("Staking: Too long"); - staking.stake(1 ether, 1700 days, alice); + function testPreviewPoints() public { + vm.expectRevert(bytes4(keccak256("StakingDisabled()"))); + staking.previewPoints(1, 100); } - function testStakeTooShort() public { - vm.prank(alice); - vm.expectRevert("Staking: Too short"); - staking.stake(1 ether, 6 days, alice); - } + function testDisabledInflation() public { + uint256 expectedRewards = (staking.balanceOf(alice) * 2 ether) / 10 ether; - function testExtend() public { - vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); - vm.startPrank(bob); - staking.stake(100 ether, 10 days, bob); - staking.extend(0, 100 days); - - // Both are now locked up for the same amount of time, - // and should have the same points. - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); - - (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); - (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); - assertEq(aliceAmount, bobAmount, "same amount"); - assertEq(aliceEnd, bobEnd, "same end"); - assertEq(alicePoints, bobPoints, "same points"); - assertEq(staking.accRewardPerShare(), staking.rewardDebtPerShare(bob)); + vm.warp(EPOCH + 100 days); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); + + vm.warp(EPOCH + 2000 days); + assertEq(staking.previewRewards(alice), expectedRewards, "Inflation not disabled"); } - function testDoubleExtend() public { - vm.warp(EPOCH + 600 days); + function testCollectRewards() public { + uint256 balanceBefore = ogv.balanceOf(alice); + uint256 expectedRewards = (staking.balanceOf(alice) * 2 ether) / 10 ether; + // Should allow claiming rewards once vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + staking.collectRewards(); - vm.startPrank(bob); - staking.stake(100 ether, 10 days, bob); - staking.extend(0, 50 days); - staking.extend(0, 100 days); - - // Both are now locked up for the same amount of time, - // and should have the same points. - assertEq(staking.balanceOf(alice), staking.balanceOf(bob)); - - (uint128 aliceAmount, uint128 aliceEnd, uint256 alicePoints) = staking.lockups(alice, 0); - (uint128 bobAmount, uint128 bobEnd, uint256 bobPoints) = staking.lockups(bob, 0); - assertEq(aliceAmount, bobAmount, "same amount"); - assertEq(aliceEnd, bobEnd, "same end"); - assertEq(alicePoints, bobPoints, "same points"); - } + assertEq(ogv.balanceOf(alice), expectedRewards + balanceBefore, "Reward not collected"); + + assertEq(staking.previewRewards(alice), 0, "Reward not collected"); - function testShortExtendFail() public { + // Should not allow claiming more than once vm.prank(alice); - staking.stake(100 ether, 100 days, alice); + staking.collectRewards(); - vm.startPrank(bob); - staking.stake(100 ether, 11 days, bob); - vm.expectRevert("Staking: New lockup must be longer"); - staking.extend(0, 10 days); + assertEq(ogv.balanceOf(alice), expectedRewards + balanceBefore, "Reward collected more than once"); } - function testDoubleStake() external { + function testUnstake() public { + // Should have no penaly for early unstaking vm.startPrank(alice); - uint256 beforeOgv = ogv.balanceOf(alice); - staking.stake(3 ether, 10 days, alice); - uint256 midOgv = ogv.balanceOf(alice); - uint256 midPoints = staking.balanceOf(alice); - staking.stake(5 ether, 40 days, alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.warp(EPOCH + 50 days); - staking.unstake(1); + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, 1); - assertEq(midPoints, staking.balanceOf(alice)); - assertEq(midOgv, ogv.balanceOf(alice)); + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstake(1); - staking.unstake(0); - assertEq(0, staking.balanceOf(alice)); // No points, since all unstaked - assertEq(beforeOgv, ogv.balanceOf(alice)); // All OGV back - } + assertEq(unstakedAmount, amount, "Penalty applied with Unstake"); - function testNoEarlyUnstake() public { - vm.startPrank(alice); - staking.stake(10 ether, 1000 days, alice); - vm.warp(999 days); - vm.expectRevert("Staking: End of lockup not reached"); - staking.unstake(0); - } + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - function testCollectRewards() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 4 ether; - slopes[1].start = uint64(EPOCH + 2 days); - slopes[1].ratePerDay = 2 ether; - slopes[2].start = uint64(EPOCH + 7 days); - slopes[2].ratePerDay = 1 ether; - vm.prank(team); - source.setInflation(slopes); // Add from start + assertEq(staking.balanceOf(alice), veOgvBalanceBefore - points, "veOGV not burned"); - vm.startPrank(alice); - staking.stake(1 ether, 360 days, alice); - - vm.warp(EPOCH + 2 days); - uint256 beforeOgv = ogv.balanceOf(alice); - uint256 preview = staking.previewRewards(alice); - staking.collectRewards(); - uint256 afterOgv = ogv.balanceOf(alice); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - uint256 collectedRewards = afterOgv - beforeOgv; - assertApproxEqAbs(collectedRewards, 8 ether, 1e8, "actual amount should be correct"); - assertEq(collectedRewards, preview, "preview should match actual"); - assertApproxEqAbs(preview, 8 ether, 1e8, "preview amount should be correct"); - } - - function testCollectedRewardsJumpInOut() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; + (amount, end, points) = staking.lockups(alice, 1); - vm.prank(team); - source.setInflation(slopes); + assertEq(end, 0, "Not unstaked"); - vm.prank(alice); - staking.stake(1 ether, 10 days, alice); + assertEq(points, 0, "Not unstaked, points mismatch"); - // One day later - vm.warp(EPOCH + 1 days); - vm.prank(alice); - staking.collectRewards(); // Alice collects + assertEq(amount, 0, "Not unstaked, amount mismatch"); - vm.prank(bob); - staking.stake(1 ether, 9 days, bob); // Bob stakes + // Should revert if it's already unstaked + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("AlreadyUnstaked(uint256)")), uint256(1))); + staking.unstake(1); - vm.warp(EPOCH + 2 days); // Alice and bob should split rewards evenly - uint256 aliceBefore = ogv.balanceOf(alice); - uint256 bobBefore = ogv.balanceOf(bob); - vm.prank(alice); - staking.collectRewards(); // Alice collects - vm.prank(bob); - staking.collectRewards(); // Bob collects - assertEq(ogv.balanceOf(alice) - aliceBefore, ogv.balanceOf(bob) - bobBefore); + vm.stopPrank(); } - function testMultipleUnstake() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - - vm.prank(team); - source.setInflation(slopes); - + function testUnstakeMultiple() public { vm.startPrank(alice); - staking.stake(1 ether, 10 days, alice); - vm.warp(EPOCH + 11 days); - staking.unstake(0); - vm.expectRevert("Staking: Already unstaked this lockup"); - staking.unstake(0); - } - function testCollectRewardsOnExpand() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.prank(team); - source.setInflation(slopes); + uint256[] memory lockupIds = new uint256[](2); + lockupIds[1] = 1; + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstake(lockupIds); - vm.prank(alice); - staking.stake(1 ether, 10 days); - vm.prank(bob); - staking.stake(1 ether, 10 days); + assertEq(unstakedAmount, 3000 ether, "Penalty applied with Unstake"); - vm.warp(EPOCH + 6 days); + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - vm.prank(bob); - staking.collectRewards(); - vm.prank(alice); - staking.extend(0, 10 days); + assertEq(staking.balanceOf(alice), 0, "veOGV not burned"); - assertEq(ogv.balanceOf(alice), ogv.balanceOf(bob)); - } + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - function testNoSupplyShortCircuts() public { - uint256 beforeAlice = ogv.balanceOf(alice); + for (uint256 i = 0; i < 2; ++i) { + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, i); - vm.prank(alice); - staking.previewRewards(alice); - assertEq(ogv.balanceOf(alice), beforeAlice); + assertEq(end, 0, "Not unstaked"); - vm.prank(alice); - staking.collectRewards(); - assertEq(ogv.balanceOf(alice), beforeAlice); + assertEq(points, 0, "Not unstaked, points mismatch"); - vm.prank(bob); - staking.stake(1 ether, 9 days, bob); + assertEq(amount, 0, "Not unstaked, amount mismatch"); - vm.prank(alice); - staking.previewRewards(alice); - assertEq(ogv.balanceOf(alice), beforeAlice); + // Should revert if it's already unstaked + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("AlreadyUnstaked(uint256)")), i)); + staking.unstake(i); + } - vm.prank(alice); - staking.collectRewards(); - assertEq(ogv.balanceOf(alice), beforeAlice); + vm.stopPrank(); } - function testMultipleStakesSameBlock() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](3); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 4 ether; - slopes[1].start = uint64(EPOCH + 2 days); - slopes[1].ratePerDay = 2 ether; - slopes[2].start = uint64(EPOCH + 7 days); - slopes[2].ratePerDay = 1 ether; - vm.prank(team); - source.setInflation(slopes); // Add from start + function testUnstakeForMigration() public { + vm.startPrank(migrator); - vm.prank(alice); - staking.stake(1 ether, 360 days, alice); + uint256 ogvBalanceBefore = ogv.balanceOf(alice); + uint256 veOgvBalanceBefore = staking.balanceOf(alice); - vm.warp(EPOCH + 9 days); + uint256[] memory lockupIds = new uint256[](2); + lockupIds[1] = 1; + (uint256 unstakedAmount, uint256 rewardCollected) = staking.unstakeFrom(alice, lockupIds); - vm.prank(alice); - staking.stake(1 ether, 60 days, alice); - vm.prank(bob); - staking.stake(1 ether, 90 days, bob); - vm.prank(alice); - staking.stake(1 ether, 180 days, alice); - vm.prank(bob); - staking.stake(1 ether, 240 days, bob); - vm.prank(alice); - staking.stake(1 ether, 360 days, alice); - vm.prank(alice); - staking.collectRewards(); - vm.prank(alice); - staking.collectRewards(); - } + assertEq(unstakedAmount, 3000 ether, "Penalty applied with Unstake"); - function testZeroSupplyRewardDebtPerShare() public { - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - vm.prank(team); - source.setInflation(slopes); + assertEq((veOgvBalanceBefore * 2 ether) / 10 ether, rewardCollected, "Reward mismatch"); - vm.prank(alice); - staking.stake(1 ether, 10 days); - vm.prank(bob); - staking.stake(1 ether, 10 days); + assertEq(staking.balanceOf(alice), 0, "veOGV not burned"); - // Alice will unstake, setting her rewardDebtPerShare - vm.warp(EPOCH + 10 days); - vm.prank(alice); - staking.unstake(0); + assertEq(ogv.balanceOf(alice), ogvBalanceBefore + unstakedAmount + rewardCollected, "OGV balance mismatch"); - // Bob unstakes, setting the total supply to zero - vm.warp(EPOCH + 20 days); - vm.prank(bob); - staking.unstake(0); + for (uint256 i = 0; i < 2; ++i) { + (uint128 amount, uint128 end, uint256 points) = staking.lockups(alice, i); - // Alice stakes. - // Even with the total supply being zero, it is important that - // Alice's rewardDebtPerShare per share be set to match the accRewardPerShare - vm.prank(alice); - staking.stake(1 ether, 10 days); + assertEq(end, 0, "Not unstaked"); - // Alice unstakes later. - // If rewardDebtPerShare was wrong, this will fail because she will - // try to collect more OGV than the contract has - vm.warp(EPOCH + 30 days); - vm.prank(alice); - staking.unstake(1); - } + assertEq(points, 0, "Not unstaked, points mismatch"); - function testFuzzCanAlwaysWithdraw(uint96 amountA, uint96 amountB, uint64 durationA, uint64 durationB, uint64 start) - public - { - uint256 HUNDRED_YEARS = 100 * 366 days; - uint256 LAST_START = HUNDRED_YEARS - 1461 days; - vm.warp(start % LAST_START); - - durationA = durationA % uint64(1461 days); - durationB = durationB % uint64(1461 days); - if (durationA < 7 days) { - durationA = 7 days; - } - if (durationB < 7 days) { - durationB = 7 days; + assertEq(amount, 0, "Not unstaked, amount mismatch"); } - if (amountA < 1) { - amountA = 1; - } - if (amountB < 1) { - amountB = 1; - } - - RewardsSource.Slope[] memory slopes = new RewardsSource.Slope[](1); - slopes[0].start = uint64(EPOCH); - slopes[0].ratePerDay = 2 ether; - vm.prank(team); - source.setInflation(slopes); - vm.prank(alice); - ogv.mint(alice, amountA); - vm.prank(alice); - ogv.approve(address(staking), amountA); - vm.prank(alice); - staking.stake(amountA, durationA, alice); - - vm.prank(bob); - ogv.mint(bob, amountB); - vm.prank(bob); - ogv.approve(address(staking), amountB); - vm.prank(bob); - staking.stake(amountB, durationB, bob); + vm.stopPrank(); + } - vm.warp(HUNDRED_YEARS); - vm.prank(alice); - staking.unstake(0); - vm.prank(bob); - staking.unstake(0); + function testUnstakeFromPermission() public { + vm.prank(team); + uint256[] memory lockupIds = new uint256[](1); + vm.expectRevert(bytes4(keccak256("NotMigrator()"))); + staking.unstakeFrom(alice, lockupIds); } - function testFuzzSemiSanePowerFunction(uint256 start) public { - uint256 HUNDRED_YEARS = 100 * 366 days; - start = start % HUNDRED_YEARS; - vm.warp(start); - vm.prank(bob); - staking.stake(1e18, 10 days, bob); - uint256 y = (356 days + start + 10 days) / 365 days; - uint256 maxPoints = 2 ** y * 1e18; - assertLt(staking.balanceOf(bob), maxPoints); + function testUnstakeLockupLength() public { + vm.prank(alice); + uint256[] memory lockupIds = new uint256[](0); + vm.expectRevert(bytes4(keccak256("NoLockupsToUnstake()"))); + staking.unstake(lockupIds); } }