Skip to content

feat: savings donation-attack guard + parallelizer pause refactor - #27

Merged
FabienCoutant merged 33 commits into
devfrom
feat/savings-donation-and-pause-refactor
Jul 13, 2026
Merged

FabienCoutant merged 33 commits into
devfrom
feat/savings-donation-and-pause-refactor

Conversation

@FabienCoutant

@FabienCoutant FabienCoutant commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Aggregation branch ("code to audit") combining two features, received via PR:

Changes

Savings (donation-attack guard)

  • storedAssets tracks the real backing; totalAssets() projects storedAssets instead of the raw balanceOf → neutralizes donation/inflation attacks.
  • recoverSurplus(to) recovers the untracked surplus (scope limited to asset()).
  • togglePause split into pause()/unpause() with idempotence guards (AlreadyPaused/NotPaused). pause() settles yield up to the pause moment; unpause() drops the paused interval (pausing halts emission).
  • ISavings aligned (pause/unpause, estimatedAPY, recoverSurplus).
  • initializeStoredAssets() reinitializer(2) to seed on upgrade — restricted and bounded by a lastUpdate freshness window.

Parallelizer (pause refactor)

  • togglePause(collateral, ActionType) split into explicit pause/unpause.

Shared foundation

  • SECONDS_PER_YEAR constant, AlreadyPaused/NotPaused errors, default via_ir = false.

Audit remediation (Cyfrin — Parallel Savings Fix)

Final report (v2.0, 2026-06-09): all findings Resolved/Acknowledged.

Code changes addressing the report's findings:

  • [L-1] Pre-upgrade donation via initializeStoredAssets — initializeStoredAssets() now reverts with StaleAccrual() when block.timestamp - lastUpdate > MAX_STORED_ASSETS_INIT_STALENESS (30 min). With a fresh accrual there is no unaccrued-interest delta to compound against an inflated storedAssets. Operational step: refresh accrual (setRate) immediately before upgrading.
  • [L-2] Pause did not halt yield — unpause() now advances lastUpdate to the unpause timestamp instead of _accrue()-ing the paused span, so the paused interval is dropped and never minted. Pausing now genuinely halts emission.
  • [I-3] initializeStoredAssets access control — added the restricted modifier; only authorized governance can seed storedAssets, independent of whether the upgrade runs atomically.
  • [I-4] initialize did not set lastUpdate — initialize now sets lastUpdate = uint40(block.timestamp), bounding the first accrual window to "time since deployment" rather than "time since Unix epoch".

Operational guidance (no code defect — process to follow):

  • [I-1] Decommission ordering — _accrue() mints TokenP, so it reverts once the AccessManager revokes the vault's minter role. When winding down, call setRate(0) (final _accrue() succeeds) before revoking the minter role — never after, or the disabling call traps the rate.
  • [I-2] Never set Savings as a surplus payee — the parallelizer's surplus distribution pays payees by direct safeTransfer. After the donation guard, USDp transferred in is excluded from totalAssets() and is sweepable by recoverSurplus — it never reaches savers. Saver yield is delivered only by minting at setRate. Savings must never appear in updatePayees/ts.payees.

Pre-upgrade deployment sequence (from the report's Post-Audit Recommendations):

  1. Keep the vault paused through the upgrade — do not unpause before it. Unpause only after the upgrade is complete and governance has called initializeStoredAssets.
  2. Call setRate immediately before the upgrade to refresh accrual (lastUpdate ≈ now), so the L-1 staleness guard passes and stale-accrual time is ~0.

Tests: added initializeStoredAssets access-control + staleness coverage and updated the pause/unpause and lastUpdate assertions for the new behavior.

Tests

forge build OK. Full suite green on the source branches (372 Savings tests included). Verification on the merged state in progress.

Note

PR intended for audit — do not merge until the audit is finalized.

🤖 Generated with Claude Code

FabienCoutant and others added 21 commits May 27, 2026 14:10
The audit/eip3009-hotfix-fees branch grew the Storage struct (Surplus
fields) past the point where Yul can route stack variables through
EVM stack slots in a few code paths, surfacing as
"Variable size is 1 too deep in the stack" during compilation.

Disabling via_ir at the default profile sidesteps the issue across
the whole compilation graph. The dev and ci profiles already had
via_ir = false, so this only changes the unprofiled forge invocation
(the one CI and most local runs use).
- SECONDS_PER_YEAR (= 365 days) replaces the hardcoded 31_536_000
  literal in Savings.estimatedAPY().
- AlreadyPaused / NotPaused errors enable explicit pause/unpause
  flows that revert on no-op governance calls instead of silently
  toggling.
Mirrors the Savings refactor from this branch on the per-collateral
governance pause exposed by SettersGuardian. The `togglePause(collateral,
action)` entry point flipped the live flag without checking the current
state, so a misclicked governance call could silently invert the pause
status of a critical action — the only audit signal was the emitted
event.

Changes:
- LibSetters: new `pause(collateral, action)` and `unpause(collateral,
  action)` internals share a private `_setPauseState` helper. Both revert
  with `AlreadyPaused` / `NotPaused` on no-op transitions; the existing
  `PauseToggled` event is preserved for backward compatibility with
  indexers.
- SettersGuardian: replaces the external `togglePause` with `pause` and
  `unpause`.
- ISettersGuardian: interface updated accordingly.
- DiamondInitializer / Test config: previous `togglePause` calls
  enabled mint/burn/redeem at deploy time, so they map cleanly to
  `unpause` (set the live flag from 0 to 1).
- Selector wiring: `tests/utils/ConfigAccessManager.sol` and
  `scripts/SetParallelizerRoles.s.sol` updated to register both new
  selectors under the guardian role.
- Generated dummy diamond implementations regenerated with the new
  signatures.
- Tests updated to use the new API. The two no-op double-toggles in
  Redeem.t.sol that relied on legacy toggle semantics are kept as a
  pause+unpause sequence so they're now explicit about being a no-op.
The audit/eip3009-hotfix-fees branch grew the Storage struct (Surplus
fields) past the point where Yul can route stack variables through
EVM stack slots in a few code paths, surfacing as
"Variable size is 1 too deep in the stack" during compilation.

Disabling via_ir at the default profile sidesteps the issue across
the whole compilation graph. The dev and ci profiles already had
via_ir = false, so this only changes the unprofiled forge invocation
(the one CI and most local runs use).
- SECONDS_PER_YEAR (= 365 days) replaces the hardcoded 31_536_000
  literal in Savings.estimatedAPY().
- AlreadyPaused / NotPaused errors enable explicit pause/unpause
  flows that revert on no-op governance calls instead of silently
  toggling.
The Savings ERC4626 vault was vulnerable to donation/inflation
attacks because totalAssets() read the raw IERC20.balanceOf(self),
allowing any direct USDp transfer to inflate share value pro-rata
to all holders. At low TVL or after long dormancy this turned even
a 1-share position into a captureable windfall.

Mitigation:
- New storedAssets state variable (consumes 1 slot from __gap[48]
  -> [47]) tracks the only legitimate backing: deposits in,
  withdrawals out, accrued interest minted in.
- totalAssets() now projects storedAssets through the existing
  rate formula instead of the raw ERC20 balance.
- _accrue() reads from and writes to storedAssets so dormant
  accruals are properly accounted, never reclassified as surplus.
- _deposit / _withdraw overridden to mirror standard ERC4626
  movements into storedAssets.
- depositWithAuthorization and _redeemWithAuthorization (EIP-3009
  paths that bypass _deposit/_withdraw) update storedAssets
  directly around the receiveWithAuthorization / safeTransfer
  calls so the same backing accounting holds across all entry
  points.
- initializeStoredAssets() reinitializer(2) seeds storedAssets
  from the current balance on upgrade so existing depositors
  remain backed.
- recoverSurplus(to) restricted hatch transfers any
  balanceOf(self) - storedAssets delta (donations, accidental
  transfers) to a destination chosen by governance, with no
  effect on share holders.

Companion refactors in the same surface:
- togglePause split into pause()/unpause() with idempotence checks
  (revert AlreadyPaused / NotPaused) so a misclicked governance
  call cannot silently flip the pause state.
- estimatedAPR renamed to estimatedAPY to match the per-second
  compounding semantics of _computeUpdatedAssets.
- Magic literal 31_536_000 replaced by SECONDS_PER_YEAR constant.

Operational note: the deployment must call upgradeToAndCall with
abi.encodeWithSelector(initializeStoredAssets.selector) atomically.
Skipping the reinitializer would leave storedAssets at 0, blocking
all subsequent withdrawals.
Donation attack invariant fuzz lives next to the existing rate /
deposit / pause fuzz tests in tests/fuzz/Savings.t.sol:
- testFuzz_DonationAttackInflationLockedDown: invariant fuzz over
  donation amount up to 1B USDp — preview and redeem outcomes are
  unchanged by direct ERC20 transfers.

Deterministic coverage moves to tests/units/Savings.t.sol in two
contracts:
- SavingsUpgradeTest groups the existing fresh-deploy + upgradeTo
  cases with the new upgrade-from-legacy path. Cases: donation
  attack succeeds on legacy then is neutralized after upgrade;
  existing depositors still redeem; initializeStoredAssets
  reinitializer cannot be reused; pause/unpause selectors function
  once wired into the access manager.
- SavingsDonationAttackTest covers the storedAssets fix, the
  recoverSurplus governance hatch (drain, zero-address guard,
  unauthorised caller, no-donation case), the dormant-accrual
  accounting and the new pause/unpause idempotence checks.

The upgrade path is exercised through ERC1967Proxy.upgradeToAndCall
backed by a SavingsLegacyMock that mirrors the pre-fix storage
layout and behaviour (BaseSavings + SavingsEIP3009 inheritance,
__gap[48], no storedAssets slot). The mock is confined to
tests/mock/ and never deployed in production paths.
…ause

refacto(parallelizer): split togglePause into pause and unpause
…tack

fix(savings): track storedAssets to prevent donation attacks
…pendence

The redemption penalty is evaluated at entry-time CR on the full amount (Cyfrin I-6) and governance may install non-flat fee curves, so single-path vs split-path outcomes legitimately diverge. Cap per-call redeem CR excursion (<=5% of issuance) and widen path-independence tolerances to 1% (balances) / 0.2% (CR). Validated 25/25 runs green under FOUNDRY_PROFILE=ci.
FabienCoutant and others added 4 commits June 26, 2026 08:30
@FabienCoutant
FabienCoutant merged commit 155e820 into dev Jul 13, 2026
4 checks passed
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.

1 participant