Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/core/LiquidityPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, Re
//--------------------------------------------------------------------------------------
uint256 public constant SHARE_UNIT = 1e18;

// Hard cap on how far a single rebase may INCREASE TVL (rewards), in bps of TVL.
// 25 bps ≈ 1 month of reward accrual at 3% APR — there is no legitimate reason for a
// single report to raise the rate by more than this, so it is a fixed invariant (not
// governance-configurable). Bounds a buggy/compromised rebase caller at the share-rate
// chokepoint regardless of the oracle-side checks.
uint256 public constant MAX_POSITIVE_REBASE_BPS = 25;
uint256 private constant REBASE_BPS_DENOMINATOR = 10_000;

//--------------------------------------------------------------------------------------
//------------------------------------- EVENTS ---------------------------------------
//--------------------------------------------------------------------------------------
Expand Down Expand Up @@ -113,6 +121,7 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, Re
error InvalidValidatorSize();
error InvalidAmountForShare();
error InvalidRate();
error RebaseExceedsPositiveCap();
error AlreadyMigrated();
error MigrationNotComplete();
error AlreadyRegistered();
Expand Down Expand Up @@ -438,6 +447,17 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, Re
/// @notice Rebase by ether.fi
function rebase(int128 _accruedRewards) public {
if (msg.sender != address(membershipManager)) revert IncorrectCaller();

// Positive (reward) upper bound, enforced at the share-rate chokepoint regardless
// of who calls rebase. A single rebase cannot increase TVL by more than
// MAX_POSITIVE_REBASE_BPS of pre-rebase TVL. Defense-in-depth alongside the
// oracle-side negative cap in EtherFiAdmin; the negative side is intentionally not
// re-checked here (the oracle path owns it and bounds it tighter).
if (_accruedRewards > 0) {
uint256 maxIncrease = (getTotalPooledEther() * MAX_POSITIVE_REBASE_BPS) / REBASE_BPS_DENOMINATOR;
if (uint256(uint128(_accruedRewards)) > maxIncrease) revert RebaseExceedsPositiveCap();
}

totalValueOutOfLp = uint128(int128(totalValueOutOfLp) + _accruedRewards);

_checkMinAmountForShare();
Expand Down
6 changes: 6 additions & 0 deletions src/governance/utils/PausableUntil.sol
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ abstract contract PausableUntil {
_requireNotPausedUntil();
PausableUntilStorage storage $ = _getPausableUntilStorage();
uint256 pauseUntilDuration = $.pauseUntilDuration;
// If the duration was never configured (0 — e.g. a fresh proxy or a just-upgraded
// contract before setPauseUntilDuration is called), fall back to MIN_PAUSE_DURATION
// so the emergency pause is always effective. Without this, `pausedUntil` would be
// `block.timestamp + 0` (expires the same block) — a silent no-op that still burns
// the pauser's cooldown.
if (pauseUntilDuration == 0) pauseUntilDuration = MIN_PAUSE_DURATION;
if ($.lastPauseTimestamp[msg.sender] + pauseUntilDuration + PAUSER_UNTIL_COOLDOWN > block.timestamp) revert PauserCooldownStillActive();
$.pausedUntil = block.timestamp + pauseUntilDuration;
$.lastPauseTimestamp[msg.sender] = block.timestamp;
Expand Down
45 changes: 45 additions & 0 deletions src/oracle/EtherFiAdmin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
uint256 public maxFinalizedWithdrawalAmountPerDay;
uint256 public maxNumValidatorsToApprovePerDay;

// Override for the per-report negative (slashing) rebase cap, in bps of TVL.
// 0 = use DEFAULT_MAX_NEGATIVE_REBASE_BPS. Settable behind the operating timelock.
uint256 public maxNegativeRebaseBps;

//--------------------------------------------------------------------------------------
//--------------------------------- IMMUTABLES --------------------------------------
//--------------------------------------------------------------------------------------
Expand Down Expand Up @@ -89,6 +93,17 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
uint256 public constant STALE_REPORT_FINALIZATION_COOLDOWN = 7200; // 1 day
uint256 public constant BASIS_POINTS_DENOMINATOR = 10_000;

// Default cap on how far a single report may DECREASE TVL (slashing), in bps of TVL,
// independent of elapsedTime. `acceptableRebaseAprInBps` is annualized over elapsedTime,
// so a long-spanning report could pass it while still dropping an outsized absolute
// amount in one rebase. The max *initial* slashing penalty is maxEffBalance/4096 ≈
// 2.44 bps of TVL even if every validator is slashed at once, so 3 bps is the tight
// default. `maxNegativeRebaseBps` (settable behind the operating timelock) overrides
// it — raised only if a correlated/mid-term slashing event is detected (visible well
// before a 2-day timelock matters). The positive/reward upper bound lives in
// LiquidityPool.rebase (the share-rate-increasing chokepoint).
uint256 public constant DEFAULT_MAX_NEGATIVE_REBASE_BPS = 3;

struct ConstructorAddresses {
address etherFiOracle;
address stakingManager;
Expand All @@ -106,6 +121,7 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
//--------------------------------------------------------------------------------------

event AdminUpdated(address _address, bool _isAdmin);
event MaxNegativeRebaseBpsUpdated(uint256 bps);
event AdminOperationsExecuted(address indexed _address, bytes32 indexed _reportHash);

event ValidatorApprovalTaskCreated(bytes32 indexed _taskHash, bytes32 indexed _reportHash, uint256[] _validators);
Expand All @@ -121,6 +137,7 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
error InvalidMaxFinalizedWithdrawalAmountPerDay();
error InvalidMaxNumValidatorsToApprovePerDay();
error InvalidAcceptableRebaseApr();
error InvalidMaxNegativeRebaseBps();
error InvalidValidatorTaskBatchSize();
error InvalidMaxAcceptableRebaseApr();
error InvalidStaleOracleReportBlockWindow();
Expand Down Expand Up @@ -410,6 +427,19 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
}
int256 absApr = (apr > 0) ? apr : - apr;
if (absApr > acceptableRebaseAprInBps) return (false, "EtherFiAdmin: TVL changed too much");

// Negative (slashing) cap, independent of elapsedTime. The APR check above is
// annualized, so a long-spanning report could pass it while still dropping an
// outsized absolute amount in one rebase. A single report can only legitimately
// DECREASE TVL by at most the max initial slashing penalty (~2.44 bps if every
// validator is slashed at once), so bound the drop to effectiveMaxNegativeRebaseBps.
// The positive/reward upper bound is enforced in LiquidityPool.rebase.
if (_report.accruedRewards < 0 && currentTVL > 0) {
int256 drop = -int256(_report.accruedRewards);
if (drop * int256(BASIS_POINTS_DENOMINATOR) > currentTVL * int256(effectiveMaxNegativeRebaseBps())) {
return (false, "EtherFiAdmin: negative rebase exceeds cap");
}
}
return (true, "");
}

Expand Down Expand Up @@ -447,6 +477,21 @@ contract EtherFiAdmin is Initializable, OwnableUpgradeable, UUPSUpgradeable, Rol
return (true, "");
}

/// @notice Override the per-report negative (slashing) rebase cap, in bps of TVL.
/// @dev Operation-Timelock-gated. 0 resets to DEFAULT_MAX_NEGATIVE_REBASE_BPS. Capped
/// at 100% so it can be raised during a real correlated-slashing event but never
/// set to a nonsensical value.
function setMaxNegativeRebaseBps(uint256 _bps) external onlyAdmin {
if (_bps > BASIS_POINTS_DENOMINATOR) revert InvalidMaxNegativeRebaseBps();
maxNegativeRebaseBps = _bps;
emit MaxNegativeRebaseBpsUpdated(_bps);
}

function effectiveMaxNegativeRebaseBps() public view returns (uint256) {
uint256 v = maxNegativeRebaseBps;
return v == 0 ? DEFAULT_MAX_NEGATIVE_REBASE_BPS : v;
}

function slotForNextReportToProcess() public view returns (uint32) {
return (lastHandledReportRefSlot == 0) ? 0 : lastHandledReportRefSlot + 1;
}
Expand Down
51 changes: 39 additions & 12 deletions src/restaking/EtherFiRestaker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "@etherfi/deposits/Liquifier.sol";
import "@etherfi/core/LiquidityPool.sol";
import "@etherfi/governance/utils/RolesLibrary.sol";
import "@etherfi/governance/utils/PausableUntil.sol";

import "@etherfi/eigenlayer-interfaces/IStrategyManager.sol";
import "@etherfi/eigenlayer-interfaces/IDelegationManager.sol";
Expand All @@ -21,7 +22,7 @@ import "@etherfi/deposits/interfaces/ILiquifier.sol";
import "@etherfi/core/interfaces/ILiquidityPool.sol";
import "@etherfi/governance/rate-limiting/interfaces/IEtherFiRateLimiter.sol";

contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, RolesLibrary {
contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, PausableUntil, RolesLibrary {
using SafeERC20 for IERC20;
using EnumerableSet for EnumerableSet.Bytes32Set;

Expand Down Expand Up @@ -143,7 +144,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
/// @notice Transfer stETH to a recipient for instant withdrawal
/// @param recipient The address to receive stETH
/// @param amount The amount of stETH to transfer
function transferStETH(address recipient, uint256 amount) external {
function transferStETH(address recipient, uint256 amount) external whenNotPaused {
if(msg.sender != etherFiRedemptionManager) revert IncorrectCaller();
if (amount > lido.balanceOf(address(this))) revert InsufficientBalance();
IERC20(address(lido)).safeTransfer(recipient, amount);
Expand All @@ -158,7 +159,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,

/// @notice Request for a specific amount of stETH holdings
/// @param _amount the amount of stETH to request
function stEthRequestWithdrawal(uint256 _amount) public onlyExecutorOperations returns (uint256[] memory) {
function stEthRequestWithdrawal(uint256 _amount) public onlyExecutorOperations whenNotPaused returns (uint256[] memory) {
rateLimiter.consume(STETH_REQUEST_WITHDRAWAL_LIMIT_ID, _amountToGwei(_amount));

uint256 minAmount = lidoWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT();
Expand Down Expand Up @@ -192,7 +193,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
/// @notice Claim a batch of withdrawal requests if they are finalized sending the ETH to the this contract back
/// @param _requestIds array of request ids to claim
/// @param _hints checkpoint hint for each id. Can be obtained with `findCheckpointHints()`
function stEthClaimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external onlyHousekeepingOperations {
function stEthClaimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external onlyHousekeepingOperations whenNotPaused {
lidoWithdrawalQueue.claimWithdrawals(_requestIds, _hints);

_withdrawEther();
Expand All @@ -201,7 +202,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
}

// Send the ETH back to the liquidity pool
function withdrawEther() public onlyHousekeepingOperations {
function withdrawEther() public onlyHousekeepingOperations whenNotPaused {
_withdrawEther();
}

Expand Down Expand Up @@ -230,7 +231,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
}

// undelegate from the current AVS operator & un-restake all
function undelegate() external onlyOperatingMultisig returns (bytes32[] memory) {
function undelegate() external onlyOperatingMultisig whenNotPaused returns (bytes32[] memory) {
bytes32[] memory withdrawalRoots = eigenLayerDelegationManager.undelegate(address(this));

for (uint256 i = 0; i < withdrawalRoots.length; i++) {
Expand All @@ -245,7 +246,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
}

// deposit the token in holding into the restaking strategy
function depositIntoStrategy(address token, uint256 amount) external onlyExecutorOperations returns (uint256) {
function depositIntoStrategy(address token, uint256 amount) external onlyExecutorOperations whenNotPaused returns (uint256) {
rateLimiter.consume(DEPOSIT_INTO_STRATEGY_LIMIT_ID, _amountToGwei(amount));

IERC20(token).safeIncreaseAllowance(address(eigenLayerStrategyManager), amount);
Expand All @@ -260,7 +261,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
/// Made easy for operators
/// @param token the token to withdraw
/// @param amount the amount of token to withdraw
function queueWithdrawals(address token, uint256 amount) public onlyExecutorOperations returns (bytes32[] memory) {
function queueWithdrawals(address token, uint256 amount) public onlyExecutorOperations whenNotPaused returns (bytes32[] memory) {
rateLimiter.consume(QUEUE_WITHDRAWALS_LIMIT_ID, _amountToGwei(amount));

uint256 shares = getEigenLayerRestakingStrategy(token).underlyingToSharesView(amount);
Expand All @@ -280,7 +281,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
function completeQueuedWithdrawals(
IDelegationManager.Withdrawal[] memory _queuedWithdrawals,
IERC20[][] memory _tokens
) external onlyHousekeepingOperations {
) external onlyHousekeepingOperations whenNotPaused {
uint256 num = _queuedWithdrawals.length;
bool[] memory receiveAsTokens = new bool[](num);
for (uint256 i = 0; i < num; i++) {
Expand Down Expand Up @@ -397,16 +398,42 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable,
return total;
}

// Pauses the contract
// Boolean pause (multisig). Indefinite until the multisig unpauses.
function pauseContract() external onlyOperatingMultisig {
_pause();
}

// Unpauses the contract
function unPauseContract() external onlyOperatingMultisig {
// Unpauses the boolean pause (deliberate, multisig-only)
function unpauseContract() external onlyOperatingMultisig {
_unpause();
}

/// @notice Fast, auto-expiring emergency halt callable by the Guardian (Hypernative /
/// EOA keys). Mirrors the protocol-wide halt model (tokens, LP, redemption):
/// the Guardian can stop fund movement immediately without assembling the
/// multisig, the pause auto-expires (8h–30d) with a per-pauser cooldown so a
/// wrong/compromised halt self-heals, and resume stays deliberate (multisig).
function pauseContractUntil() external onlyGuardian {
_pauseUntil();
}
Comment thread
0xpanicError marked this conversation as resolved.

function unpauseContractUntil() external onlyOperatingMultisig {
_unpauseUntil();
}

function setPauseUntilDuration(uint256 _pauseUntilDuration) external onlyAdmin {
_setPauseUntilDuration(_pauseUntilDuration);
}

/// @dev Fold the auto-expiring PausableUntil check into OZ's `_requireNotPaused`, so
/// the standard `whenNotPaused` modifier (used on every fund-flow fn) halts on
/// EITHER the boolean pause or the Guardian's auto-expiring pause — consistent
/// with the rest of the protocol, no bespoke modifier needed.
function _requireNotPaused() internal view override {
_requireNotPausedUntil();
super._requireNotPaused();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Override blocks boolean pause while PausableUntil is active

Medium Severity

The _requireNotPaused() override checks _requireNotPausedUntil() first, which reverts when the PausableUntil pause is active. Since OZ's _pause() uses the whenNotPaused modifier internally, calling pauseContract() while the Guardian's auto-expiring halt is active will revert. This means the multisig cannot "promote" the temporary halt to a permanent boolean pause without first unpausing PausableUntil — creating a potential gap window between the two operations where the contract is momentarily unpaused and fund-moving transactions could execute.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 019e84f. Configure here.


// INTERNAL functions

/// @dev Convert wei to gwei for rate-limiter buckets, with overflow check.
Expand Down
2 changes: 1 addition & 1 deletion src/restaking/interfaces/IEtherFiRestaker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ interface IEtherFiRestaker {
function transferStETH(address recipient, uint256 amount) external;
function lido() external view returns (ILido);
function pauseContract() external;
function unPauseContract() external;
function unpauseContract() external;
}
Loading