Skip to content

Commit

Permalink
feat(RewardsStreamerMP): introduce emergency mode and ability to leave
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.

Closes #66
  • Loading branch information
0x-r4bbit committed Oct 29, 2024
1 parent 6fea58b commit 2f4b5a0
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 56 deletions.
42 changes: 22 additions & 20 deletions .gas-report
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,34 @@
| src/RewardsStreamerMP.sol:RewardsStreamerMP contract | | | | | |
|------------------------------------------------------|-----------------|-------|--------|-------|---------|
| Deployment Cost | Deployment Size | | | | |
| 1216069 | 5542 | | | | |
| 1329354 | 6071 | | | | |
| Function Name | min | avg | median | max | # calls |
| MAX_LOCKUP_PERIOD | 228 | 228 | 228 | 228 | 22 |
| MAX_MULTIPLIER | 229 | 229 | 229 | 229 | 28 |
| MAX_MULTIPLIER | 274 | 274 | 274 | 274 | 28 |
| MIN_LOCKUP_PERIOD | 275 | 275 | 275 | 275 | 11 |
| MP_RATE_PER_YEAR | 231 | 231 | 231 | 231 | 3 |
| SCALE_FACTOR | 229 | 229 | 229 | 229 | 39 |
| STAKING_TOKEN | 273 | 273 | 273 | 273 | 128 |
| accountedRewards | 395 | 953 | 395 | 2395 | 68 |
| getAccount | 1596 | 1596 | 1596 | 1596 | 65 |
| isTrustedCodehash | 496 | 996 | 496 | 2496 | 128 |
| rewardIndex | 351 | 380 | 351 | 2351 | 68 |
| setTrustedCodehash | 47926 | 47926 | 47926 | 47926 | 32 |
| totalMP | 352 | 352 | 352 | 352 | 71 |
| totalMaxMP | 395 | 395 | 395 | 395 | 71 |
| totalStaked | 374 | 374 | 374 | 374 | 71 |
| updateAccountMP | 34654 | 36892 | 37156 | 37156 | 19 |
| updateGlobalState | 30008 | 55588 | 47387 | 80334 | 25 |
| SCALE_FACTOR | 295 | 295 | 295 | 295 | 39 |
| STAKING_TOKEN | 273 | 273 | 273 | 273 | 160 |
| accountedRewards | 351 | 878 | 351 | 2351 | 72 |
| enableEmergencyMode | 23504 | 40411 | 45696 | 45696 | 8 |
| getAccount | 1596 | 1596 | 1596 | 1596 | 69 |
| isTrustedCodehash | 496 | 996 | 496 | 2496 | 160 |
| rewardIndex | 373 | 400 | 373 | 2373 | 72 |
| setTrustedCodehash | 47926 | 47926 | 47926 | 47926 | 40 |
| totalMP | 330 | 330 | 330 | 330 | 75 |
| totalMaxMP | 351 | 351 | 351 | 351 | 75 |
| totalStaked | 330 | 330 | 330 | 330 | 75 |
| updateAccountMP | 34632 | 36870 | 37134 | 37134 | 19 |
| updateGlobalState | 30008 | 58240 | 47387 | 80334 | 28 |


| src/StakeVault.sol:StakeVault contract | | | | | |
|----------------------------------------|-----------------|--------|--------|--------|---------|
| Deployment Cost | Deployment Size | | | | |
| 857122 | 4070 | | | | |
| 980726 | 4655 | | | | |
| Function Name | min | avg | median | max | # calls |
| stake | 196978 | 234038 | 240671 | 261155 | 46 |
| leave | 38583 | 97470 | 113028 | 126708 | 7 |
| stake | 197021 | 234519 | 240714 | 261198 | 53 |
| unstake | 83287 | 111971 | 101308 | 144002 | 13 |


Expand Down Expand Up @@ -117,10 +119,10 @@
| Deployment Cost | Deployment Size | | | | |
| 639406 | 3369 | | | | |
| Function Name | min | avg | median | max | # calls |
| approve | 46334 | 46343 | 46346 | 46346 | 165 |
| balanceOf | 561 | 1351 | 561 | 2561 | 286 |
| mint | 51284 | 59028 | 51284 | 68384 | 181 |
| transfer | 34390 | 48070 | 51490 | 51490 | 10 |
| approve | 46334 | 46343 | 46346 | 46346 | 205 |
| balanceOf | 561 | 1356 | 561 | 2561 | 337 |
| mint | 51284 | 58865 | 51284 | 68384 | 221 |
| transfer | 34390 | 48859 | 51490 | 51490 | 13 |


| test/mocks/XPProviderMock.sol:XPProviderMock contract | | | | | |
Expand Down
72 changes: 40 additions & 32 deletions .gas-snapshot
Original file line number Diff line number Diff line change
@@ -1,42 +1,50 @@
IntegrationTest:testStakeFoo() (gas: 1471979)
EmergencyExitTest:test_CannotEnableEmergencyModeTwice() (gas: 79829)
EmergencyExitTest:test_CannotLeaveBeforeEmergencyMode() (gas: 288215)
EmergencyExitTest:test_EmergencyExitBasic() (gas: 393709)
EmergencyExitTest:test_EmergencyExitMultipleUsers() (gas: 864854)
EmergencyExitTest:test_EmergencyExitToAlternateAddress() (gas: 550699)
EmergencyExitTest:test_EmergencyExitWithLock() (gas: 392290)
EmergencyExitTest:test_EmergencyExitWithRewards() (gas: 545096)
EmergencyExitTest:test_OnlyOwnerCanEnableEmergencyMode() (gas: 34673)
IntegrationTest:testStakeFoo() (gas: 1470920)
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: 488941)
StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634452)
StakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801369)
StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494644)
StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516001)
StakeTest:test_StakeOneAccount() (gas: 282173)
StakeTest:test_StakeOneAccountAndRewards() (gas: 427680)
StakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488578)
StakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483621)
StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295784)
StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295862)
StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 295973)
UnstakeTest:test_StakeMultipleAccounts() (gas: 488963)
UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634429)
UnstakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801391)
UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494621)
UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516023)
UnstakeTest:test_StakeOneAccount() (gas: 282196)
UnstakeTest:test_StakeOneAccountAndRewards() (gas: 427702)
UnstakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488600)
UnstakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483601)
UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295829)
UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295862)
UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 295951)
UnstakeTest:test_UnstakeBonusMPAndAccuredMP() (gas: 500003)
UnstakeTest:test_UnstakeMultipleAccounts() (gas: 681122)
UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 1002814)
UnstakeTest:test_UnstakeOneAccount() (gas: 474888)
UnstakeTest:test_UnstakeOneAccountAndAccruedMP() (gas: 488421)
UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 579871)
UnstakeTest:test_UnstakeOneAccountWithLockUpAndAccruedMP() (gas: 509781)
StakeTest:test_StakeMultipleAccounts() (gas: 488961)
StakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634252)
StakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801084)
StakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494930)
StakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516287)
StakeTest:test_StakeOneAccount() (gas: 282084)
StakeTest:test_StakeOneAccountAndRewards() (gas: 427437)
StakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488204)
StakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483358)
StakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295938)
StakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295950)
StakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 296039)
UnstakeTest:test_StakeMultipleAccounts() (gas: 488917)
UnstakeTest:test_StakeMultipleAccountsAndRewards() (gas: 634296)
UnstakeTest:test_StakeMultipleAccountsMPIncreasesMaxMPDoesNotChange() (gas: 801083)
UnstakeTest:test_StakeMultipleAccountsWithMinLockUp() (gas: 494929)
UnstakeTest:test_StakeMultipleAccountsWithRandomLockUp() (gas: 516331)
UnstakeTest:test_StakeOneAccount() (gas: 282107)
UnstakeTest:test_StakeOneAccountAndRewards() (gas: 427481)
UnstakeTest:test_StakeOneAccountMPIncreasesMaxMPDoesNotChange() (gas: 488248)
UnstakeTest:test_StakeOneAccountReachingMPLimit() (gas: 483360)
UnstakeTest:test_StakeOneAccountWithMaxLockUp() (gas: 295895)
UnstakeTest:test_StakeOneAccountWithMinLockUp() (gas: 295950)
UnstakeTest:test_StakeOneAccountWithRandomLockUp() (gas: 296039)
UnstakeTest:test_UnstakeBonusMPAndAccuredMP() (gas: 499937)
UnstakeTest:test_UnstakeMultipleAccounts() (gas: 680944)
UnstakeTest:test_UnstakeMultipleAccountsAndRewards() (gas: 1002240)
UnstakeTest:test_UnstakeOneAccount() (gas: 474535)
UnstakeTest:test_UnstakeOneAccountAndAccruedMP() (gas: 488046)
UnstakeTest:test_UnstakeOneAccountAndRewards() (gas: 579518)
UnstakeTest:test_UnstakeOneAccountWithLockUpAndAccruedMP() (gas: 509937)
XPNFTTokenTest:testApproveNotAllowed() (gas: 10507)
XPNFTTokenTest:testGetApproved() (gas: 10531)
XPNFTTokenTest:testIsApprovedForAll() (gas: 10705)
Expand Down
48 changes: 48 additions & 0 deletions certora/specs/RewardsStreamerMP.spec
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 Expand Up @@ -41,6 +42,39 @@ function getAccountLockUntil(address account) returns uint256 {
return lockUntil;
}

definition isTrustedCodehashAccessFunction(method f) returns bool = (
f.selector == sig:streamer.setTrustedCodehash(bytes32, bool).selector ||
f.selector == sig:streamer.isTrustedCodehash(bytes32).selector
);

definition isOwnableFunction(method f) returns bool = (
f.selector == sig:streamer.renounceOwnership().selector ||
f.selector == sig:streamer.transferOwnership(address).selector
);

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
);

invariant sumOfBalancesIsTotalStaked()
sumOfBalances == to_mathint(totalStaked());

Expand Down Expand Up @@ -100,3 +134,17 @@ rule stakingGreaterLockupTimeMeansGreaterMPs {
assert lockupTime1 >= lockupTime2 => to_mathint(multiplierPointsAfter1) >= to_mathint(multiplierPointsAfter2);
satisfy to_mathint(multiplierPointsAfter1) > to_mathint(multiplierPointsAfter2);
}

rule accountCanOnlyLeaveInEmergencyMode(method f) filtered {
f -> !isViewFunction(f) && !isOwnableFunction(f) && !isTrustedCodehashAccessFunction(f)
} {
env e;
calldataarg args;

require emergencyModeEnabled() == true;
require e.msg.sender != staked;

f(e, args);

assert f.selector == sig:streamer.leave().selector;
}
46 changes: 42 additions & 4 deletions src/RewardsStreamerMP.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu
error StakingManager__InvalidLockingPeriod();
error StakingManager__CannotRestakeWithLockedFunds();
error StakingManager__TokensAreLocked();
error StakingManager__EmergencyModeEnabled();
error StakingManager__NotAllowedToLeave();

IERC20 public immutable STAKING_TOKEN;
IERC20 public immutable REWARD_TOKEN;
Expand All @@ -31,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 @@ -43,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 +109,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 @@ -132,12 +142,33 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu
account.accountRewardIndex = rewardIndex;
}

function leave() external onlyTrustedCodehash nonReentrant {
if (!emergencyModeEnabled) {
revert StakingManager__NotAllowedToLeave();
}
Account storage account = accounts[msg.sender];

if (account.stakedBalance > 0) {
_updateGlobalState();
_updateAccountMP(msg.sender);

uint256 accountRewards = calculateAccountRewards(msg.sender);
if (accountRewards > 0) {
distributeRewards(msg.sender, accountRewards);
}
}
totalMP -= account.accountMP;
totalMaxMP -= account.maxMP;
totalStaked -= account.stakedBalance;
delete accounts[msg.sender];
}

function _updateGlobalState() internal {
updateGlobalMP();
updateRewardIndex();
}

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

Expand Down Expand Up @@ -208,7 +239,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 @@ -234,6 +265,13 @@ contract RewardsStreamerMP is IStakeManager, TrustedCodehashAccess, ReentrancyGu
}
}

function enableEmergencyMode() external onlyOwner {
if (emergencyModeEnabled) {
revert StakingManager__EmergencyModeEnabled();
}
emergencyModeEnabled = true;
}

function getStakedBalance(address accountAddress) external view returns (uint256) {
return accounts[accountAddress].stakedBalance;
}
Expand Down
8 changes: 8 additions & 0 deletions src/StakeVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ contract StakeVault is Ownable {
_unstake(_amount, _destination);
}

/**
* @notice Withdraw all tokens from the contract to the owner.
*/
function leave(address _destination) external onlyOwner validDestination(_destination) {
stakeManager.leave();
STAKING_TOKEN.transfer(_destination, STAKING_TOKEN.balanceOf(address(this)));
}

/**
* @notice Withdraw tokens from the contract.
* @param _token The IERC20 token to withdraw.
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/IStakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface IStakeManager is ITrustedCodehashAccess {

function stake(uint256 _amount, uint256 _seconds) external;
function unstake(uint256 _amount) external;
function leave() external;

function totalStaked() external view returns (uint256);
function totalMP() external view returns (uint256);
Expand Down
Loading

0 comments on commit 2f4b5a0

Please sign in to comment.