Skip to content

Commit

Permalink
feat(RewardStreamerMP): add emergency mode so users can exit the system
Browse files Browse the repository at this point in the history
This adds a new emergency mode that can be enabled by the owner of the system.
When in emergency mode, stakers or `StakeVault`s can leave the system immediately.

This also applies when there was a malicious upgrade and a call to
`emergencyModeEnabled()` panics.

To have this in a fully secure manner, we still have to add the counter
part of "leaving" the system. This will allow users that don't agree
with a (malicious) upgrade to get their funds out of the vaults
regardless.

Closes #66
  • Loading branch information
0x-r4bbit committed Nov 28, 2024
1 parent 5bc7ebf commit 1e703e3
Show file tree
Hide file tree
Showing 11 changed files with 460 additions and 63 deletions.
43 changes: 23 additions & 20 deletions .gas-report
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | | | | |
Expand Down Expand Up @@ -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 | | | | | |
Expand Down
78 changes: 43 additions & 35 deletions .gas-snapshot
Original file line number Diff line number Diff line change
@@ -1,45 +1,53 @@
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)
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)
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,8 @@ jobs:
strategy:
fail-fast: false
max-parallel: 16
matrix:
rule:
- verify:rewards_streamer_mp
- verify:emergency_mode
- verify:xp_token
21 changes: 21 additions & 0 deletions certora/confs/EmergencyMode.conf
Original file line number Diff line number Diff line change
@@ -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"
]
}

54 changes: 54 additions & 0 deletions certora/specs/EmergencyMode.spec
Original file line number Diff line number Diff line change
@@ -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);
}

1 change: 1 addition & 0 deletions certora/specs/RewardsStreamerMP.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 22 additions & 6 deletions src/RewardsStreamerMP.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -170,7 +179,7 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu
updateRewardIndex();
}

function updateGlobalState() external {
function updateGlobalState() external onlyNotEmergencyMode {
_updateGlobalState();
}

Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
}

Expand Down
Loading

0 comments on commit 1e703e3

Please sign in to comment.