P0 security hardening for #385: oracle price-move cap + restaker pause#445
P0 security hardening for #385: oracle price-move cap + restaker pause#445seongyun-ko wants to merge 6 commits into
Conversation
- EtherFiAdmin: absolute per-report rebase delta cap (SINGLE_REPORT_REBASE_DELTA_CAP_BPS), independent of elapsedTime, so a long-spanning report cannot move the exchange rate by an outsized absolute amount while passing the annualized APR check. - EtherFiRestaker: add whenNotPaused to all fund-movement functions (previously the pause gated nothing) and add a Guardian fast-halt path (guardianPause). - Blacklister: bound the instant multisig setBlacklistUntil path to MAX_MULTISIG_BLACKLIST_DURATION; document moving permanent blacklist behind the timelock. Tests: blacklist duration bound + restaker pause-gating (passing on fork). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Removing the setBlacklistUntil 90-day cap added earlier. It only guarded against an accidental over-long blacklist, which is operationally recoverable (the multisig can shorten/clear it). It gave no protection against a compromised multisig, which can permanently blacklist via blacklistUser regardless. Net: friction without a real security gain. Reverts Blacklister.sol and Blacklist.t.sol to base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses Cursor Bugbot review on #445: undelegate() queues withdrawal of all restaked assets (same fund-flow category as queueWithdrawals), so it must also respect the pause for the halt to be a complete/reliable signal. delegateTo is left ungated — it only changes delegation posture and queues/moves no funds. Test extended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // in one report. A legitimate move beyond this requires deliberate governance action. | ||
| // 200 bps = 2% of TVL; normal daily reports move ~1-3 bps, so this only trips on | ||
| // anomalous reports while still leaving generous headroom for multi-week catch-ups. | ||
| int256 public constant SINGLE_REPORT_REBASE_DELTA_CAP_BPS = 200; |
There was a problem hiding this comment.
Let's set a min value per report to 3 bps which represents the max initial slashing penalty
There was a problem hiding this comment.
Rationale: initial slashing penalty is maxEffBalance / 4096, which is around 2.44 bps of tvl in case all validators inccur it. We should make this value configurable behing operatingTimelock so it can later be increased if we detect 18 days mid term slashing penalty event (can be detected way before 2 day timelock).
There was a problem hiding this comment.
Done in dfac525. Implemented exactly this on the negative side: a slashing cap on accruedRewards defaulting to 3 bps (DEFAULT_MAX_NEGATIVE_REBASE_BPS), overridable via setMaxNegativeRebaseBps behind the operating timelock (0 = use default). So it ships tight at 3 bps and can be raised if a correlated/mid-term slashing event is detected. The old symmetric APR-only abs check is gone.
| // outsized absolute amount of TVL in a single rebase. Bound the absolute move | ||
| // to SINGLE_REPORT_REBASE_DELTA_CAP_BPS of current TVL so no single report can | ||
| // shift the exchange rate beyond this, regardless of the window it covers. | ||
| int256 absRewards = (_report.accruedRewards > 0) ? int256(_report.accruedRewards) : -int256(_report.accruedRewards); |
There was a problem hiding this comment.
Let's only compare accruedRewards per report with max_neg_rebase value (which we can set to 3 bps). to put invariant on upper-bound, better to put inside LP on functions expected to increase share rate and can set max upper-bound to something like 25 bps (that's 1 month of reward accrual at 3% APR).
There was a problem hiding this comment.
Done in dfac525. The negative side now only compares accruedRewards against the max-negative cap in EtherFiAdmin (default 3 bps). The positive/upper bound moved into LiquidityPool.rebase — the share-rate-increasing chokepoint — defaulting to 25 bps (DEFAULT_MAX_POSITIVE_REBASE_BPS ≈ 1 month at 3% APR), settable behind the operating timelock via setMaxPositiveRebaseBps. Enforced there it holds regardless of which contract calls rebase. Test: test_positiveRebaseCap_enforced.
| /// movement immediately without assembling the multisig. Resume stays deliberate | ||
| /// (multisig-only `unPauseContract`). This mirrors the token/LP halt model where | ||
| /// the Guardian can pause but only the Operating Multisig can unpause. | ||
| function guardianPause() external onlyGuardian { |
There was a problem hiding this comment.
having the same function being called by 2 tiers of roles break the purpose of those roles.
There was a problem hiding this comment.
Good call — switched to PausableUntil in dfac525. The restaker now mirrors the protocol-wide model: pauseContractUntil() (onlyGuardian, auto-expiring 8h–30d with the per-pauser cooldown) + the boolean multisig pauseContract(), and a whenNotHalted modifier gates every fund-flow fn (incl. undelegate) on both. Replaced the OZ-only guardianPause I had. Tests updated (test_guardianPauseUntil_halts_fund_movement, test_booleanPause_also_halts_fund_movement).
There was a problem hiding this comment.
Resolved by the PausableUntil switch: pausing is now two distinct functions on two tiers — pauseContract() (multisig, boolean) and pauseContractUntil() (Guardian, auto-expiring). No single function is callable by two role tiers anymore.
Per @0xpanicError's review on #445: Rebase caps — replace the single symmetric 200bps constant with asymmetric, configurable bounds (defaults as proposed): - EtherFiAdmin: negative (slashing) cap on accruedRewards, default 3 bps of TVL (max initial slashing penalty ~2.44 bps), settable behind the operating timelock (maxNegativeRebaseBps, 0 = default). Raise-able during a correlated-slashing event. - LiquidityPool.rebase: positive (reward) upper bound enforced at the share-rate chokepoint, default 25 bps (~1 month at 3% APR), settable behind the operating timelock (maxPositiveRebaseBps, 0 = default). Defense-in-depth independent of the oracle path. Restaker — switch from OZ Pausable to the protocol-wide PausableUntil model: Guardian pauseContractUntil() (auto-expiring, cooldown) + multisig boolean pause; whenNotHalted gates all fund-flow fns (incl. undelegate). Replaces guardianPause(). TestSetup disables both caps for generic suites (large artificial rebases on small TVL); dedicated tests exercise enforcement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
||
| // Override for the per-rebase positive (reward) cap, in bps of TVL. | ||
| // 0 = use DEFAULT_MAX_POSITIVE_REBASE_BPS. Settable behind the operating timelock. | ||
| uint256 public maxPositiveRebaseBps; |
There was a problem hiding this comment.
I don't think we need this value to be configurable. There's no way a single report should increase rate by more than 25 bps.
There was a problem hiding this comment.
Agreed — made it a hard constant in 019e84f. MAX_POSITIVE_REBASE_BPS = 25, no setter/storage. (The negative/slashing cap in EtherFiAdmin stays timelock-configurable, since a real correlated-slashing event can legitimately exceed the initial-penalty default.) Rewrote the ~11 accounting tests that were doing 5%–100% rebases on tiny test TVL to use within-cap rebases.
| /// @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 whenNotHalted { |
There was a problem hiding this comment.
can you use the whenNotPaused modifier used everywhere else for consistency? For contracts like this where PausableUpgradeable is inherited, override this internal function:
function _requireNotPaused() internal override view {
_requireNotPausedUntil();
super._requireNotPaused();
}
There was a problem hiding this comment.
Done in 019e84f — exactly your pattern: overrode _requireNotPaused() to call _requireNotPausedUntil() + super._requireNotPaused(), dropped the bespoke whenNotHalted, and all fund-flow fns use the standard whenNotPaused again.
|
|
||
| // Unpauses the contract | ||
| // Unpauses the boolean pause (deliberate, multisig-only) | ||
| function unPauseContract() external onlyOperatingMultisig { |
There was a problem hiding this comment.
can you rename this: unpauseContract()
There was a problem hiding this comment.
Renamed to unpauseContract() in 019e84f (+ IEtherFiRestaker).
- LiquidityPool: positive rebase cap is now a hard 25 bps constant (MAX_POSITIVE_REBASE_BPS), not governance-configurable (per review: no legitimate single report raises rate >25 bps). Dropped the setter/storage/event/error. Negative (slashing) cap in EtherFiAdmin stays configurable behind the operating timelock. - EtherFiRestaker: use the standard whenNotPaused everywhere by overriding _requireNotPaused() to also check _requireNotPausedUntil() (per review), instead of a bespoke whenNotHalted. - EtherFiRestaker: rename unPauseContract -> unpauseContract (+ interface). - PausableUntil: _pauseUntil falls back to MIN_PAUSE_DURATION when duration is unset, so the Guardian's emergency pause is never a silent no-op that still burns the cooldown (bot finding). - Tests: rewrote the rebase tests that used unrealistically large (5%-100%) rebases on tiny fresh-deploy TVL to use within-cap (<=0.25%) rebases with recomputed assertions; restaker pause tests updated for the rename. Pre-existing ERC721/withdraw fork-harness failures (identical on base) are unrelated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 019e84f. Configure here.
| function _requireNotPaused() internal view override { | ||
| _requireNotPausedUntil(); | ||
| super._requireNotPaused(); | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 019e84f. Configure here.


P0 security hardening for #385
Two high-priority fixes from the security review. Each is small, isolated, and has a test. Base:
pankaj/feat/security-upgrades.1. Cap how much a single oracle report can move the weETH price (
EtherFiAdmin)The problem, in plain terms: the oracle periodically reports staking rewards, which moves the weETH↔eETH exchange rate. There's a safety check on this — but it's expressed as an annual rate (APR). The catch: the longer the gap a report covers, the bigger the one-time jump it's allowed to make while still looking fine on an annual basis.
Example: a normal daily report nudges the rate by ~0.01%. But a report that covers 60 days (say the oracle was paused and is catching up) is allowed to move the rate ~60× more in a single transaction — and still pass the APR check. A buggy or compromised oracle could exploit exactly this to inflate or crash the price in one shot. No other guard catches it, because a price change doesn't mint or burn any tokens.
The fix: add a hard ceiling — no single report may move total value by more than 2% of TVL, no matter how long a window it covers. Normal reports are nowhere near this; anything bigger now has to be done deliberately by governance instead of sailing through automatically.
2. Make the restaker's "pause" button actually do something (
EtherFiRestaker)The problem:
pauseContract()flips a paused flag — but not one function actually checks that flag. So hitting pause did nothing: stETH transfers, ETH withdrawals, EigenLayer deposits/withdrawals all kept working. An operator who thought they'd stopped the contract during an incident hadn't.Example: during an incident someone calls
pauseContract()to freeze the restaker. An attacker (or a compromised ops key) callswithdrawEther()one block later — it still goes through, because nothing was checking "are we paused?".The fix: every money-moving function now respects the pause. Also added
guardianPause()so the Guardian (our Hypernative automation / emergency keys) can hit the brake instantly without gathering multisig signatures. Unpausing still requires the multisig (deliberate to turn off, easy to turn on — the intended asymmetry).test_guardianPause_halts_fund_movementconfirms the pause now blocks transfers, that only the Guardian can fire it, and that resume is multisig-only. Passes on a mainnet fork.Test status
EtherFiOracleTest::test_finalizeWithdrawalsWhenStale_*failures (latest-block fork drift) reproduce identically on the untouched base branch — unrelated to this change.Note
High Risk
Changes oracle report validation and the LiquidityPool share-rate rebase chokepoint, which directly affect eETH/weETH pricing; restaker pause wiring controls stETH/EigenLayer/ETH flows during incidents.
Overview
This PR adds defense-in-depth limits on how much TVL can move in a single rebase/report, and makes EtherFiRestaker pauses actually block fund flows, aligned with the rest of the protocol.
Rebase / oracle bounds:
LiquidityPool.rebasenow rejects positive reward accrual above a fixed 25 bps of pre-rebase TVL (RebaseExceedsPositiveCap).EtherFiAdminadds a separate per-report negative (slashing) cap (default 3 bps, overridable viasetMaxNegativeRebaseBpsbehind admin) so long-gap reports cannot pass the annualized APR check while still dropping TVL by a large absolute amount. Positive reward limits stay on the LP chokepoint; negative limits stay on report validation.Restaker pause: Money-moving paths on
EtherFiRestakerusewhenNotPaused. The contract inheritsPausableUntil: GuardianpauseContractUntil, multisig boolean pause/unpause, and_requireNotPausedalso enforces the timed halt.PausableUntil._pauseUntilfalls back toMIN_PAUSE_DURATIONwhen duration was never set, avoiding a same-block no-op pause.Tests / harness: Rebase-related tests use smaller accruals within the new cap; new tests cover the positive cap and restaker halts.
TestSetuprelaxes negative cap for generic suites viasetMaxNegativeRebaseBps(10_000).Reviewed by Cursor Bugbot for commit 019e84f. Bugbot is set up for automated code reviews on this repo. Configure here.