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 27, 2024
1 parent 5bc7ebf commit cfb50de
Show file tree
Hide file tree
Showing 9 changed files with 394 additions and 8 deletions.
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) public 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
21 changes: 21 additions & 0 deletions src/StakeVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)));
}
}
}
1 change: 1 addition & 0 deletions src/interfaces/IStakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit cfb50de

Please sign in to comment.