diff --git a/.gas-report b/.gas-report index ca1854c..7179b6a 100644 --- a/.gas-report +++ b/.gas-report @@ -15,34 +15,37 @@ | src/RewardsStreamerMP.sol:RewardsStreamerMP contract | | | | | | |------------------------------------------------------|-----------------|-------|--------|-------|---------| | Deployment Cost | Deployment Size | | | | | -| 1327847 | 6066 | | | | | +| 1425736 | 6528 | | | | | | Function Name | min | avg | median | max | # calls | | MAX_LOCKUP_PERIOD | 228 | 228 | 228 | 228 | 23 | | MAX_MULTIPLIER | 274 | 274 | 274 | 274 | 30 | | MIN_LOCKUP_PERIOD | 275 | 275 | 275 | 275 | 11 | | MP_RATE_PER_YEAR | 231 | 231 | 231 | 231 | 3 | | SCALE_FACTOR | 295 | 295 | 295 | 295 | 41 | -| STAKING_TOKEN | 273 | 273 | 273 | 273 | 140 | -| accountedRewards | 373 | 931 | 373 | 2373 | 68 | -| getAccount | 1596 | 1596 | 1596 | 1596 | 67 | -| isTrustedCodehash | 496 | 996 | 496 | 2496 | 140 | -| rewardIndex | 351 | 380 | 351 | 2351 | 68 | -| setTrustedCodehash | 47926 | 47926 | 47926 | 47926 | 35 | -| totalMP | 330 | 330 | 330 | 330 | 71 | -| totalMaxMP | 373 | 373 | 373 | 373 | 71 | -| totalStaked | 352 | 352 | 352 | 352 | 71 | -| updateAccountMP | 34632 | 36870 | 37134 | 37134 | 19 | -| updateGlobalState | 30008 | 55588 | 47387 | 80334 | 25 | +| STAKING_TOKEN | 273 | 273 | 273 | 273 | 172 | +| accountedRewards | 351 | 906 | 351 | 2351 | 72 | +| emergencyModeEnabled | 2377 | 2377 | 2377 | 2377 | 7 | +| enableEmergencyMode | 23504 | 40411 | 45696 | 45696 | 8 | +| getAccount | 1596 | 1596 | 1596 | 1596 | 71 | +| isTrustedCodehash | 496 | 996 | 496 | 2496 | 172 | +| rewardIndex | 373 | 400 | 373 | 2373 | 72 | +| setTrustedCodehash | 47926 | 47926 | 47926 | 47926 | 43 | +| totalMP | 330 | 330 | 330 | 330 | 75 | +| totalMaxMP | 351 | 351 | 351 | 351 | 75 | +| totalStaked | 330 | 330 | 330 | 330 | 75 | +| updateAccountMP | 36758 | 38996 | 39260 | 39260 | 19 | +| updateGlobalState | 32134 | 60366 | 49513 | 82460 | 28 | | src/StakeVault.sol:StakeVault contract | | | | | | |----------------------------------------|-----------------|--------|--------|--------|---------| | Deployment Cost | Deployment Size | | | | | -| 894174 | 4243 | | | | | +| 1095864 | 5202 | | | | | | Function Name | min | avg | median | max | # calls | -| lock | 36236 | 58390 | 40615 | 98319 | 3 | -| stake | 196956 | 234305 | 240649 | 261178 | 48 | -| unstake | 83287 | 111971 | 101308 | 144002 | 13 | +| emergencyExit | 31410 | 43924 | 43160 | 60260 | 7 | +| lock | 38362 | 60516 | 42741 | 100445 | 3 | +| stake | 199213 | 236948 | 242906 | 263435 | 55 | +| unstake | 84988 | 113999 | 103434 | 146128 | 13 | | src/XPNFTToken.sol:XPNFTToken contract | | | | | | @@ -118,10 +121,10 @@ | Deployment Cost | Deployment Size | | | | | | 639406 | 3369 | | | | | | Function Name | min | avg | median | max | # calls | -| approve | 46334 | 46343 | 46346 | 46346 | 180 | -| balanceOf | 561 | 1351 | 561 | 2561 | 291 | -| mint | 51284 | 58959 | 51284 | 68384 | 196 | -| transfer | 34390 | 48070 | 51490 | 51490 | 10 | +| approve | 46334 | 46343 | 46346 | 46346 | 220 | +| balanceOf | 561 | 1381 | 561 | 2561 | 334 | +| mint | 51284 | 58817 | 51284 | 68384 | 236 | +| transfer | 34390 | 48859 | 51490 | 51490 | 13 | | test/mocks/XPProviderMock.sol:XPProviderMock contract | | | | | | diff --git a/.gas-snapshot b/.gas-snapshot index 8ba793c..94d93e7 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,7 +1,15 @@ -IntegrationTest:testStakeFoo() (gas: 1471121) -LockTest:test_LockFailsWithInvalidPeriod() (gas: 285795) -LockTest:test_LockFailsWithNoStake() (gas: 51253) -LockTest:test_LockWithoutPriorLock() (gas: 378889) +EmergencyExitTest:test_CannotEnableEmergencyModeTwice() (gas: 79829) +EmergencyExitTest:test_CannotLeaveBeforeEmergencyMode() (gas: 283234) +EmergencyExitTest:test_EmergencyExitBasic() (gas: 379874) +EmergencyExitTest:test_EmergencyExitMultipleUsers() (gas: 788714) +EmergencyExitTest:test_EmergencyExitToAlternateAddress() (gas: 517455) +EmergencyExitTest:test_EmergencyExitWithLock() (gas: 370304) +EmergencyExitTest:test_EmergencyExitWithRewards() (gas: 507361) +EmergencyExitTest:test_OnlyOwnerCanEnableEmergencyMode() (gas: 34607) +IntegrationTest:testStakeFoo() (gas: 1490300) +LockTest:test_LockFailsWithInvalidPeriod() (gas: 290178) +LockTest:test_LockFailsWithNoStake() (gas: 53357) +LockTest:test_LockWithoutPriorLock() (gas: 383272) NFTMetadataGeneratorSVGTest:testGenerateMetadata() (gas: 92874) NFTMetadataGeneratorSVGTest:testSetImageStrings() (gas: 60081) NFTMetadataGeneratorSVGTest:testSetImageStringsRevert() (gas: 35818) @@ -9,37 +17,37 @@ NFTMetadataGeneratorURLTest:testGenerateMetadata() (gas: 109345) NFTMetadataGeneratorURLTest:testSetBaseURL() (gas: 50653) NFTMetadataGeneratorURLTest:testSetBaseURLRevert() (gas: 35993) RewardsStreamerTest:testStake() (gas: 869874) -StakeTest:test_StakeMultipleAccounts() (gas: 488809) -StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634232) -StakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801063) -StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494911) -StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516313) -StakeTest:test_StakeOneAccount() (gas: 282063) -StakeTest:test_StakeOneAccountAndRewards() (gas: 427482) -StakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488293) -StakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483447) -StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295896) -StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295974) -StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 296085) -UnstakeTest:test_StakeMultipleAccounts() (gas: 488831) -UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634209) -UnstakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801085) -UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494888) -UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516335) -UnstakeTest:test_StakeOneAccount() (gas: 282086) -UnstakeTest:test_StakeOneAccountAndRewards() (gas: 427504) -UnstakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488315) -UnstakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483427) -UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295941) -UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295974) -UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 296063) -UnstakeTest:test_UnstakeBonusMPAndAccuredMP() (gas: 500049) -UnstakeTest:test_UnstakeMultipleAccounts() (gas: 680902) -UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 1002330) -UnstakeTest:test_UnstakeOneAccount() (gas: 474602) -UnstakeTest:test_UnstakeOneAccountAndAccruedMP() (gas: 488113) -UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 579585) -UnstakeTest:test_UnstakeOneAccountWithLockUpAndAccruedMP() (gas: 510049) +StakeTest:test_StakeMultipleAccounts() (gas: 493279) +StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 640763) +StakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 818252) +StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 499381) +StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 520783) +StakeTest:test_StakeOneAccount() (gas: 284277) +StakeTest:test_StakeOneAccountAndRewards() (gas: 431756) +StakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 498901) +StakeTest:test_StakeOneAccountReachingMPLimit() (gas: 494078) +StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 298175) +StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 298187) +StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 298298) +UnstakeTest:test_StakeMultipleAccounts() (gas: 493323) +UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 640807) +UnstakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 818251) +UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 499380) +UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 520827) +UnstakeTest:test_StakeOneAccount() (gas: 284300) +UnstakeTest:test_StakeOneAccountAndRewards() (gas: 431800) +UnstakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 498945) +UnstakeTest:test_StakeOneAccountReachingMPLimit() (gas: 494080) +UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 298132) +UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 298187) +UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 298298) +UnstakeTest:test_UnstakeBonusMPAndAccuredMP() (gas: 508511) +UnstakeTest:test_UnstakeMultipleAccounts() (gas: 688755) +UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 1014239) +UnstakeTest:test_UnstakeOneAccount() (gas: 480152) +UnstakeTest:test_UnstakeOneAccountAndAccruedMP() (gas: 496638) +UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 585964) +UnstakeTest:test_UnstakeOneAccountWithLockUpAndAccruedMP() (gas: 518574) XPNFTTokenTest:testApproveNotAllowed() (gas: 10507) XPNFTTokenTest:testGetApproved() (gas: 10531) XPNFTTokenTest:testIsApprovedForAll() (gas: 10705) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66af58c..73aff50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,3 +169,8 @@ jobs: strategy: fail-fast: false max-parallel: 16 + matrix: + rule: + - verify:rewards_streamer_mp + - verify:emergency_mode + - verify:xp_token diff --git a/certora/confs/EmergencyMode.conf b/certora/confs/EmergencyMode.conf new file mode 100644 index 0000000..2e8171b --- /dev/null +++ b/certora/confs/EmergencyMode.conf @@ -0,0 +1,21 @@ +{ + "files": [ + "src/RewardsStreamerMP.sol", + "certora/helpers/ERC20A.sol" + ], + "link" : [ + "RewardsStreamerMP:STAKING_TOKEN=ERC20A", + "RewardsStreamerMP:REWARD_TOKEN=ERC20A" + ], + "msg": "Verifying RewardsStreamerMP.sol", + "rule_sanity": "basic", + "verify": "RewardsStreamerMP:certora/specs/EmergencyMode.spec", + "parametric_contracts": ["RewardsStreamerMP"], + "optimistic_loop": true, + "loop_iter": "3", + "packages": [ + "forge-std=lib/forge-std/src", + "@openzeppelin=lib/openzeppelin-contracts" + ] +} + diff --git a/certora/specs/EmergencyMode.spec b/certora/specs/EmergencyMode.spec new file mode 100644 index 0000000..1de2d50 --- /dev/null +++ b/certora/specs/EmergencyMode.spec @@ -0,0 +1,54 @@ +using RewardsStreamerMP as streamer; +using ERC20A as staked; + +methods { + function emergencyModeEnabled() external returns (bool) envfree; +} + +definition isViewFunction(method f) returns bool = ( + f.selector == sig:streamer.STAKING_TOKEN().selector || + f.selector == sig:streamer.REWARD_TOKEN().selector || + f.selector == sig:streamer.SCALE_FACTOR().selector || + f.selector == sig:streamer.MP_RATE_PER_YEAR().selector || + f.selector == sig:streamer.MIN_LOCKUP_PERIOD().selector || + f.selector == sig:streamer.MAX_LOCKUP_PERIOD().selector || + f.selector == sig:streamer.MAX_MULTIPLIER().selector || + f.selector == sig:streamer.accountedRewards().selector || + f.selector == sig:streamer.rewardIndex().selector || + f.selector == sig:streamer.lastMPUpdatedTime().selector || + f.selector == sig:streamer.owner().selector || + f.selector == sig:streamer.totalStaked().selector || + f.selector == sig:streamer.totalMaxMP().selector || + f.selector == sig:streamer.totalMP().selector || + f.selector == sig:streamer.accounts(address).selector || + f.selector == sig:streamer.emergencyModeEnabled().selector || + f.selector == sig:streamer.getStakedBalance(address).selector || + f.selector == sig:streamer.getAccount(address).selector || + f.selector == sig:streamer.getPendingRewards(address).selector || + f.selector == sig:streamer.calculateAccountRewards(address).selector +); + +definition isOwnableFunction(method f) returns bool = ( + f.selector == sig:streamer.renounceOwnership().selector || + f.selector == sig:streamer.transferOwnership(address).selector +); + +definition isTrustedCodehashAccessFunction(method f) returns bool = ( + f.selector == sig:streamer.setTrustedCodehash(bytes32, bool).selector || + f.selector == sig:streamer.isTrustedCodehash(bytes32).selector +); + +rule accountCanOnlyLeaveInEmergencyMode(method f) { + env e; + calldataarg args; + + require emergencyModeEnabled() == true; + + f@withrevert(e, args); + bool isReverted = lastReverted; + + assert !isReverted => isViewFunction(f) || + isOwnableFunction(f) || + isTrustedCodehashAccessFunction(f); +} + diff --git a/certora/specs/RewardsStreamerMP.spec b/certora/specs/RewardsStreamerMP.spec index c5ef8cf..90d105c 100644 --- a/certora/specs/RewardsStreamerMP.spec +++ b/certora/specs/RewardsStreamerMP.spec @@ -11,6 +11,7 @@ methods { function lastMPUpdatedTime() external returns (uint256) envfree; function updateGlobalState() external; function updateAccountMP(address accountAddress) external; + function emergencyModeEnabled() external returns (bool) envfree; } ghost mathint sumOfBalances { diff --git a/package.json b/package.json index ceb6d04..9413391 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "scripts": { "clean": "rm -rf cache out", "lint": "pnpm lint:sol && pnpm prettier:check", - "verify": "pnpm verify:rewards_streamer_mp && pnpm verify:xp_token", + "verify": "pnpm verify:rewards_streamer_mp && pnpm verify:xp_token && pnpm verify:emergency_mode", "verify:rewards_streamer_mp": "certoraRun certora/confs/RewardsStreamerMP.conf", + "verify:emergency_mode": "certoraRun certora/confs/EmergencyMode.conf", "verify:xp_token": "certoraRun certora/confs/XPToken.conf", "lint:sol": "forge fmt --check && pnpm solhint {script,src,test,certora}/**/*.sol", "prettier:check": "prettier --check **/*.{json,md,yml} --ignore-path=.prettierignore", diff --git a/src/RewardsStreamerMP.sol b/src/RewardsStreamerMP.sol index c2cf4eb..c5acb81 100644 --- a/src/RewardsStreamerMP.sol +++ b/src/RewardsStreamerMP.sol @@ -15,6 +15,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu error StakingManager__CannotRestakeWithLockedFunds(); error StakingManager__TokensAreLocked(); error StakingManager__AlreadyLocked(); + error StakingManager__EmergencyModeEnabled(); IERC20 public immutable STAKING_TOKEN; IERC20 public immutable REWARD_TOKEN; @@ -32,6 +33,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu uint256 public rewardIndex; uint256 public accountedRewards; uint256 public lastMPUpdatedTime; + bool public emergencyModeEnabled; struct Account { uint256 stakedBalance; @@ -44,13 +46,20 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu mapping(address account => Account data) public accounts; + modifier onlyNotEmergencyMode() { + if (emergencyModeEnabled) { + revert StakingManager__EmergencyModeEnabled(); + } + _; + } + constructor(address _owner, address _stakingToken, address _rewardToken) TrustedCodehashAccess(_owner) { STAKING_TOKEN = IERC20(_stakingToken); REWARD_TOKEN = IERC20(_rewardToken); lastMPUpdatedTime = block.timestamp; } - function stake(uint256 amount, uint256 lockPeriod) external onlyTrustedCodehash nonReentrant { + function stake(uint256 amount, uint256 lockPeriod) external onlyTrustedCodehash onlyNotEmergencyMode nonReentrant { if (amount == 0) { revert StakingManager__AmountCannotBeZero(); } @@ -99,7 +108,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu account.lastMPUpdateTime = block.timestamp; } - function lock(uint256 lockPeriod) external onlyTrustedCodehash nonReentrant { + function lock(uint256 lockPeriod) external onlyTrustedCodehash onlyNotEmergencyMode nonReentrant { if (lockPeriod < MIN_LOCKUP_PERIOD || lockPeriod > MAX_LOCKUP_PERIOD) { revert StakingManager__InvalidLockingPeriod(); } @@ -132,7 +141,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu account.lastMPUpdateTime = block.timestamp; } - function unstake(uint256 amount) external onlyTrustedCodehash nonReentrant { + function unstake(uint256 amount) external onlyTrustedCodehash onlyNotEmergencyMode nonReentrant { Account storage account = accounts[msg.sender]; if (amount > account.stakedBalance) { revert StakingManager__InsufficientBalance(); @@ -170,7 +179,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu updateRewardIndex(); } - function updateGlobalState() external { + function updateGlobalState() external onlyNotEmergencyMode { _updateGlobalState(); } @@ -246,7 +255,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu account.lastMPUpdateTime = block.timestamp; } - function updateAccountMP(address accountAddress) external { + function updateAccountMP(address accountAddress) external onlyNotEmergencyMode { _updateAccountMP(accountAddress); } @@ -272,7 +281,14 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu } } - function getStakedBalance(address accountAddress) external view returns (uint256) { + function enableEmergencyMode() external onlyOwner { + if (emergencyModeEnabled) { + revert StakingManager__EmergencyModeEnabled(); + } + emergencyModeEnabled = true; + } + + function getStakedBalance(address accountAddress) public view returns (uint256) { return accounts[accountAddress].stakedBalance; } diff --git a/src/StakeVault.sol b/src/StakeVault.sol index 79be4b2..efb0322 100644 --- a/src/StakeVault.sol +++ b/src/StakeVault.sol @@ -18,6 +18,7 @@ contract StakeVault is Ownable { error StakeVault__UpdateNotAvailable(); error StakeVault__StakingFailed(); error StakeVault__UnstakingFailed(); + error StakeVault__NotAllowedToExit(); //STAKING_TOKEN must be kept as an immutable, otherwise, StakeManager would accept StakeVaults with any token //if is needed that STAKING_TOKEN to be a variable, StakeManager should be changed to check codehash and @@ -163,4 +164,24 @@ contract StakeVault is Ownable { function amountStaked() public view returns (uint256) { return stakeManager.getStakedBalance(address(this)); } + + /** + * @notice Allows vaults to exit the system in case of emergency or the system is rigged. + * @param _destination The address to receive the funds. + * @dev This function tries to read `IStakeManager.emergencyModeEnabeled()` to check if an + * emergency mode is enabled. If the call fails, it will still transfer the funds to the + * destination address. + * @dev This function is only callable by the owner. + * @dev Reverts when `emergencyModeEnabled()` returns false. + */ + function emergencyExit(address _destination) external onlyOwner validDestination(_destination) { + try stakeManager.emergencyModeEnabled() returns (bool enabled) { + if (!enabled) { + revert StakeVault__NotAllowedToExit(); + } + STAKING_TOKEN.transfer(_destination, STAKING_TOKEN.balanceOf(address(this))); + } catch { + STAKING_TOKEN.transfer(_destination, STAKING_TOKEN.balanceOf(address(this))); + } + } } diff --git a/src/interfaces/IStakeManager.sol b/src/interfaces/IStakeManager.sol index ac75683..6bd84b2 100644 --- a/src/interfaces/IStakeManager.sol +++ b/src/interfaces/IStakeManager.sol @@ -14,6 +14,7 @@ interface IStakeManager is ITrustedCodehashAccess { function lock(uint256 _seconds) external; function unstake(uint256 _amount) external; + function emergencyModeEnabled() external view returns (bool); function totalStaked() external view returns (uint256); function totalMP() external view returns (uint256); function totalMaxMP() external view returns (uint256); diff --git a/test/RewardsStreamerMP.t.sol b/test/RewardsStreamerMP.t.sol index 13d87ec..5800f27 100644 --- a/test/RewardsStreamerMP.t.sol +++ b/test/RewardsStreamerMP.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.26; import { Test } from "forge-std/Test.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { RewardsStreamerMP } from "../src/RewardsStreamerMP.sol"; import { StakeVault } from "../src/StakeVault.sol"; import { MockToken } from "./mocks/MockToken.sol"; @@ -65,6 +66,7 @@ contract RewardsStreamerMPTest is Test { address account; uint256 rewardBalance; uint256 stakedBalance; + uint256 vaultBalance; uint256 rewardIndex; uint256 accountMP; uint256 maxMP; @@ -76,7 +78,7 @@ contract RewardsStreamerMPTest is Test { RewardsStreamerMP.Account memory accountInfo = streamer.getAccount(p.account); assertEq(accountInfo.stakedBalance, p.stakedBalance, "wrong account staked balance"); - assertEq(stakingToken.balanceOf(p.account), p.stakedBalance, "wrong staking balance"); + assertEq(stakingToken.balanceOf(p.account), p.vaultBalance, "wrong vault balance"); assertEq(accountInfo.accountRewardIndex, p.rewardIndex, "wrong account reward index"); assertEq(accountInfo.accountMP, p.accountMP, "wrong account MP"); assertEq(accountInfo.maxMP, p.maxMP, "wrong account max MP"); @@ -103,6 +105,12 @@ contract RewardsStreamerMPTest is Test { vault.unstake(amount); } + function _emergencyExit(address account) public { + StakeVault vault = StakeVault(vaults[account]); + vm.prank(account); + vault.emergencyExit(account); + } + function _addReward(uint256 amount) public { vm.prank(admin); rewardToken.transfer(address(streamer), amount); @@ -169,6 +177,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -195,6 +204,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -206,6 +216,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -234,6 +245,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -245,6 +257,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -290,6 +303,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 250e18, stakedBalance: 0e18, + vaultBalance: 0e18, rewardIndex: 10e18, accountMP: 0e18, maxMP: 0e18 @@ -301,6 +315,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -327,6 +342,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 250e18, stakedBalance: 0e18, + vaultBalance: 0e18, rewardIndex: 10e18, accountMP: 0e18, maxMP: 0e18 @@ -338,6 +354,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -349,6 +366,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[charlie], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 10e18, accountMP: 30e18, maxMP: 150e18 @@ -377,6 +395,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 250e18, stakedBalance: 0e18, + vaultBalance: 0e18, rewardIndex: 10e18, accountMP: 0e18, maxMP: 0e18 @@ -388,6 +407,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -399,6 +419,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[charlie], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 10e18, accountMP: 30e18, maxMP: 150e18 @@ -426,6 +447,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 250e18, stakedBalance: 0e18, + vaultBalance: 0e18, rewardIndex: 10e18, accountMP: 0, maxMP: 0 @@ -444,6 +466,7 @@ contract IntegrationTest is RewardsStreamerMPTest { // bobs total rewards = 555.55 + 750 of the first bucket = 1305.55 rewardBalance: 1_305_555_555_555_555_555_525, stakedBalance: 0e18, + vaultBalance: 0e18, rewardIndex: 17_407_407_407_407_407_407, accountMP: 0, maxMP: 0 @@ -455,6 +478,7 @@ contract IntegrationTest is RewardsStreamerMPTest { account: vaults[charlie], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 10e18, accountMP: 30e18, maxMP: 150e18 @@ -488,6 +512,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -515,6 +540,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -645,6 +671,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: totalMP, // accountMP == totalMP because only one account is staking maxMP: totalMaxMP @@ -677,6 +704,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: totalMP, // accountMP == totalMP because only one account is staking maxMP: totalMaxMP @@ -708,6 +736,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: totalMP, // accountMP == totalMP because only one account is staking maxMP: totalMaxMP // maxMP == totalMaxMP because only one account is staking @@ -738,6 +767,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: totalMaxMP, maxMP: totalMaxMP @@ -788,6 +818,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -799,6 +830,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -830,6 +862,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 10e18, + vaultBalance: 10e18, rewardIndex: 0, accountMP: 10e18, maxMP: 50e18 @@ -841,6 +874,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 30e18, + vaultBalance: 30e18, rewardIndex: 0, accountMP: 30e18, maxMP: 150e18 @@ -957,6 +991,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: aliceStakeAmount, + vaultBalance: aliceStakeAmount, rewardIndex: 0, accountMP: aliceMP, maxMP: aliceMaxMP @@ -967,6 +1002,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: bobStakeAmount, + vaultBalance: bobStakeAmount, rewardIndex: 0, accountMP: bobMP, maxMP: bobMaxMP @@ -1005,6 +1041,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: aliceStakeAmount, + vaultBalance: aliceStakeAmount, rewardIndex: 0, accountMP: aliceMP, maxMP: aliceMaxMP @@ -1015,6 +1052,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: bobStakeAmount, + vaultBalance: bobStakeAmount, rewardIndex: 0, accountMP: bobMP, maxMP: bobMaxMP @@ -1053,6 +1091,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: aliceStakeAmount, + vaultBalance: aliceStakeAmount, rewardIndex: 0, accountMP: aliceMP, maxMP: aliceMaxMP @@ -1063,6 +1102,7 @@ contract StakeTest is RewardsStreamerMPTest { account: vaults[bob], rewardBalance: 0, stakedBalance: bobStakeAmount, + vaultBalance: bobStakeAmount, rewardIndex: 0, accountMP: bobMP, maxMP: bobMaxMP @@ -1098,6 +1138,7 @@ contract UnstakeTest is StakeTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 2e18, + vaultBalance: 2e18, rewardIndex: 0, accountMP: 2e18, maxMP: 10e18 @@ -1224,6 +1265,7 @@ contract UnstakeTest is StakeTest { account: vaults[alice], rewardBalance: 1000e18, stakedBalance: 2e18, + vaultBalance: 2e18, rewardIndex: 50e18, // alice reward index has been updated accountMP: 2e18, maxMP: 10e18 @@ -1351,6 +1393,7 @@ contract UnstakeTest is StakeTest { account: vaults[alice], rewardBalance: 0, stakedBalance: 0, + vaultBalance: 0, rewardIndex: 0, accountMP: 0, maxMP: 0 @@ -1362,6 +1405,7 @@ contract UnstakeTest is StakeTest { account: vaults[bob], rewardBalance: 0, stakedBalance: 20e18, + vaultBalance: 20e18, rewardIndex: 0, accountMP: 20e18, maxMP: 100e18 @@ -1392,6 +1436,7 @@ contract UnstakeTest is StakeTest { account: vaults[alice], rewardBalance: 250e18, stakedBalance: 0, + vaultBalance: 0, rewardIndex: 125e17, accountMP: 0, maxMP: 0 @@ -1417,6 +1462,7 @@ contract UnstakeTest is StakeTest { account: vaults[bob], rewardBalance: 750e18, stakedBalance: 20e18, + vaultBalance: 20e18, rewardIndex: 125e17, accountMP: 20e18, maxMP: 100e18 @@ -1442,6 +1488,7 @@ contract UnstakeTest is StakeTest { account: vaults[bob], rewardBalance: 750e18, stakedBalance: 0, + vaultBalance: 0, rewardIndex: 125e17, accountMP: 0, maxMP: 0 @@ -1475,6 +1522,7 @@ contract LockTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: initialAccountMP, maxMP: initialMaxMP @@ -1493,6 +1541,7 @@ contract LockTest is RewardsStreamerMPTest { account: vaults[alice], rewardBalance: 0, stakedBalance: stakeAmount, + vaultBalance: stakeAmount, rewardIndex: 0, accountMP: initialAccountMP + expectedBonusMP, maxMP: initialMaxMP + expectedBonusMP @@ -1513,3 +1562,220 @@ contract LockTest is RewardsStreamerMPTest { _lock(alice, 0); } } + +contract EmergencyExitTest is RewardsStreamerMPTest { + function setUp() public override { + super.setUp(); + } + + function test_CannotLeaveBeforeEmergencyMode() public { + _stake(alice, 10e18, 0); + vm.expectRevert(StakeVault.StakeVault__NotAllowedToExit.selector); + _emergencyExit(alice); + } + + function test_OnlyOwnerCanEnableEmergencyMode() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + streamer.enableEmergencyMode(); + } + + function test_CannotEnableEmergencyModeTwice() public { + streamer.enableEmergencyMode(); + vm.expectRevert(RewardsStreamerMP.StakingManager__EmergencyModeEnabled.selector); + streamer.enableEmergencyMode(); + } + + function test_EmergencyExitBasic() public { + uint256 aliceBalance = stakingToken.balanceOf(alice); + + _stake(alice, 10e18, 0); + + streamer.enableEmergencyMode(); + + _emergencyExit(alice); + + // emergency exit will not perform any internal accounting + checkStreamer( + CheckStreamerParams({ + totalStaked: 10e18, + totalMP: 10e18, + totalMaxMP: 50e18, + stakingBalance: 0, + rewardBalance: 0, + rewardIndex: 0, + accountedRewards: 0 + }) + ); + + checkAccount( + CheckAccountParams({ + account: vaults[alice], + rewardBalance: 0, + stakedBalance: 10e18, + vaultBalance: 0, + rewardIndex: 0, + accountMP: 10e18, + maxMP: 50e18 + }) + ); + + assertEq(stakingToken.balanceOf(alice), aliceBalance, "Alice should get tokens back"); + assertEq(stakingToken.balanceOf(vaults[alice]), 0, "Vault should be empty"); + } + + function test_EmergencyExitWithRewards() public { + uint256 aliceInitialBalance = stakingToken.balanceOf(alice); + uint256 aliceInitialRewardBalance = rewardToken.balanceOf(vaults[alice]); + + _stake(alice, 10e18, 0); + + // Add some rewards + _addReward(1000e18); + + streamer.enableEmergencyMode(); + + _emergencyExit(alice); + + checkStreamer( + CheckStreamerParams({ + totalStaked: 10e18, + totalMP: 10e18, + totalMaxMP: 50e18, + stakingBalance: 10e18, + rewardBalance: 1000e18, + rewardIndex: 50e18, + accountedRewards: 1000e18 + }) + ); + + // Check Alice staked tokens but no rewards + assertEq(stakingToken.balanceOf(alice), aliceInitialBalance, "Alice should get staked tokens back"); + assertEq(rewardToken.balanceOf(vaults[alice]), aliceInitialRewardBalance, "Alice should not get rewards"); + assertEq(stakingToken.balanceOf(address(vaults[alice])), 0, "Vault should be empty"); + } + + function test_EmergencyExitWithLock() public { + uint256 aliceInitialBalance = stakingToken.balanceOf(alice); + + _stake(alice, 10e18, 90 days); + + streamer.enableEmergencyMode(); + + _emergencyExit(alice); + + // Check Alice got tokens back despite lock + assertEq(stakingToken.balanceOf(alice), aliceInitialBalance, "Alice should get tokens back despite lock"); + assertEq(stakingToken.balanceOf(address(vaults[alice])), 0, "Vault should be empty"); + } + + function test_EmergencyExitMultipleUsers() public { + uint256 aliceInitialBalance = stakingToken.balanceOf(alice); + uint256 bobInitialBalance = stakingToken.balanceOf(bob); + uint256 aliceInitialRewardBalance = rewardToken.balanceOf(vaults[alice]); + uint256 bobInitialRewardBalance = rewardToken.balanceOf(vaults[bob]); + + // Setup multiple stakers + _stake(alice, 10e18, 0); + _stake(bob, 30e18, 0); + _addReward(1000e18); + + streamer.enableEmergencyMode(); + + // Alice exits first + _emergencyExit(alice); + + // Check intermediate state + checkStreamer( + CheckStreamerParams({ + totalStaked: 40e18, + totalMP: 40e18, + totalMaxMP: 200e18, + stakingBalance: 40e18, + rewardBalance: 1000e18, + rewardIndex: 125e17, + accountedRewards: 1000e18 + }) + ); + + // Bob exits + _emergencyExit(bob); + + // Check final state + checkStreamer( + CheckStreamerParams({ + totalStaked: 40e18, + totalMP: 40e18, + totalMaxMP: 200e18, + stakingBalance: 40e18, + rewardBalance: 1000e18, + rewardIndex: 125e17, + accountedRewards: 1000e18 + }) + ); + + checkAccount( + CheckAccountParams({ + account: vaults[alice], + rewardBalance: aliceInitialRewardBalance, + stakedBalance: 10e18, + vaultBalance: 0, + rewardIndex: 0, + accountMP: 10e18, + maxMP: 50e18 + }) + ); + + checkAccount( + CheckAccountParams({ + account: vaults[bob], + rewardBalance: bobInitialRewardBalance, + stakedBalance: 30e18, + vaultBalance: 0, + rewardIndex: 0, + accountMP: 30e18, + maxMP: 150e18 + }) + ); + + // Verify both users got their tokens back + assertEq(stakingToken.balanceOf(alice), aliceInitialBalance, "Alice should get staked tokens back"); + assertEq(stakingToken.balanceOf(bob), bobInitialBalance, "Bob should get staked tokens back"); + assertEq(stakingToken.balanceOf(vaults[alice]), 0, "Alice vault should have 0 staked tokens"); + assertEq(stakingToken.balanceOf(vaults[bob]), 0, "Bob vault should have 0 staked tokens"); + } + + function test_EmergencyExitToAlternateAddress() public { + _stake(alice, 10e18, 0); + _addReward(1000e18); + + address alternateAddress = makeAddr("alternate"); + uint256 alternateInitialBalance = stakingToken.balanceOf(alternateAddress); + + streamer.enableEmergencyMode(); + + // Alice exits to alternate address + vm.prank(alice); + StakeVault aliceVault = StakeVault(vaults[alice]); + aliceVault.emergencyExit(alternateAddress); + + checkAccount( + CheckAccountParams({ + account: vaults[alice], + rewardBalance: 0, + stakedBalance: 10e18, + vaultBalance: 0, + rewardIndex: 0, + accountMP: 10e18, + maxMP: 50e18 + }) + ); + + // Check alternate address received everything + assertEq( + stakingToken.balanceOf(alternateAddress), + alternateInitialBalance + 10e18, + "Alternate address should get staked tokens" + ); + } +}