Skip to content

P0 security hardening for #385: oracle price-move cap + restaker pause#445

Open
seongyun-ko wants to merge 6 commits into
yash/cleanup-tasksfrom
security/p0-solvency-authority-bounds
Open

P0 security hardening for #385: oracle price-move cap + restaker pause#445
seongyun-ko wants to merge 6 commits into
yash/cleanup-tasksfrom
security/p0-solvency-authority-bounds

Conversation

@seongyun-ko
Copy link
Copy Markdown
Contributor

@seongyun-ko seongyun-ko commented May 29, 2026

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.

  • 200 bps is a constant for now. If you'd rather make it adjustable, say so and I'll wire it as a governance setting with a fixed upper bound.
  • A full report-level test belongs in the oracle test suite (it has the report builders). The arithmetic is simple; happy to add it if you point me at the harness.

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) calls withdrawEther() 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 test_guardianPause_halts_fund_movement confirms 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

  • New test passes on a mainnet fork.
  • The pre-existing 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.rebase now rejects positive reward accrual above a fixed 25 bps of pre-rebase TVL (RebaseExceedsPositiveCap). EtherFiAdmin adds a separate per-report negative (slashing) cap (default 3 bps, overridable via setMaxNegativeRebaseBps behind 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 EtherFiRestaker use whenNotPaused. The contract inherits PausableUntil: Guardian pauseContractUntil, multisig boolean pause/unpause, and _requireNotPaused also enforces the timed halt. PausableUntil._pauseUntil falls back to MIN_PAUSE_DURATION when 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. TestSetup relaxes negative cap for generic suites via setMaxNegativeRebaseBps(10_000).

Reviewed by Cursor Bugbot for commit 019e84f. Bugbot is set up for automated code reviews on this repo. Configure here.

- 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>
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/restaking/EtherFiRestaker.sol
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>
@seongyun-ko seongyun-ko changed the title P0 security hardening for #385: rebase abs-cap, restaker pause, blacklist bound P0 security hardening for #385: oracle price-move cap + restaker pause May 29, 2026
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>
Comment thread src/oracle/EtherFiAdmin.sol Outdated
// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Let's set a min value per report to 3 bps which represents the max initial slashing penalty

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread src/oracle/EtherFiAdmin.sol Outdated
// 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@0xpanicError 0xpanicError changed the base branch from pankaj/feat/security-upgrades to yash/cleanup-tasks May 29, 2026 15:53
Comment thread src/restaking/EtherFiRestaker.sol Outdated
/// 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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why not use pausable until here?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

having the same function being called by 2 tiers of roles break the purpose of those roles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

seongyun-ko and others added 2 commits May 29, 2026 12:16
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>
Comment thread src/restaking/EtherFiRestaker.sol
Comment thread src/core/LiquidityPool.sol Outdated

// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread src/restaking/EtherFiRestaker.sol Outdated
/// @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 {
Copy link
Copy Markdown

@0xpanicError 0xpanicError May 29, 2026

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread src/restaking/EtherFiRestaker.sol Outdated

// Unpauses the contract
// Unpauses the boolean pause (deliberate, multisig-only)
function unPauseContract() external onlyOperatingMultisig {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can you rename this: unpauseContract()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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();
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants