Skip to content

test: address deep review of #470 (I3/P1 vacuity, I11/I12 non-vacuity gates, I7 scope)#475

Merged
seongyun-ko merged 2 commits into
seongyun/fuzz/security-upgradesfrom
seongyun/fix/review-470-round2
Jun 16, 2026
Merged

test: address deep review of #470 (I3/P1 vacuity, I11/I12 non-vacuity gates, I7 scope)#475
seongyun-ko merged 2 commits into
seongyun/fuzz/security-upgradesfrom
seongyun/fix/review-470-round2

Conversation

@seongyun-ko

@seongyun-ko seongyun-ko commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Addresses the full hand-review of the #470 invariant suite. Test/spec only — zero src/ changes. Every fix was re-run and verified.

HIGH — I3/P1 was a dead-code assertion (no-op)

WithdrawalSolvencyHandler.finalizeRequests pre-filtered lockAmount > inLpBefore and then re-checked the same condition inside the lock's success branch — unreachable. ghost_finalizeExceededLiquidity could never be set, so invariant_i3_finalized_backed_by_liquidity was structurally true regardless of contract behaviour. The suite refused to feed _lockEth (LiquidityPool.sol:598) the exact over-bound input P1 claims to test.

Fix (mirrors the correct I13 doFinalize pattern):

  • Removed the pre-filter; attempt the lock unconditionally and classify the outcome by revert selector. A liquidity-guard rejection of a genuinely unbacked lock is now a positive observation (ghost_lockBoundEnforced); a liquidity revert while the range was backed trips ghost_lockRejectedWhileBacked.
  • Added probeOverLiquidityLock — deliberately attempts a lock > totalValueInLp and asserts rejection. Self-contained, so the non-vacuity gate survives Foundry sequence-shrinking.
  • Dual-direction P1 invariant + non-vacuity gate ghost_lockBoundEnforced > 0.
  • Verified: lockBoundEnforced=26, finalizeBoundChecks=7, 7/7 pass, 0 reverts (the guard was provably dead before).

MEDIUM — I11 (ValidatorLifecycle) missing non-vacuity gate + targetSelector

Only suite with no afterInvariant and no targetSelector: the fuzzer wasted budget on view getters, and both invariant_I11_* could pass trivially if no link/re-link ever fired.

Fix: targetSelector(doLink, doRelinkAttack) + afterInvariant asserting link_ok > 0 and link_already_revert + relink_attempt > 0. Verified: link_ok=15, relink_attempt=37, 3/3 pass.

LOW — I12 monotonic-decrease guard untested

doClaim always uses newCum = preCum + delta (delta ≥ 1), so postCum < preCum was impossible and the contract's preclaimed >= cumulativeAmount => NothingToClaim guard (L113) was never driven.

Fix: doLowerCumulativeClaim — bootstraps a claim, then attempts a strictly-lower cumulative and asserts rejection (self-contained for shrink-safety) + non-vacuity gate. Verified: lower_rejected=7, lower_unexpected_ok=0, 3/3 pass.

LOW — I7 Certora deposit filter narrower than the modifier

nonDecreasingRate guards _deposit, reached by deposit(), deposit(address), deposit(address,address), and depositToRecipient — but the I7 filter only listed deposit(address,address). Widened to match I8.

Re-verified on the cloud proverSUCCESS across all four deposit variants + withdraw + both burn paths. (report)


Also worth folding into the #470 PR body (doc-only, reviewer's nits)

  • I6 proves the revokeFast guards + "no other method mutates membership" — it does not prove grantRole/revokeRole/setRole are owner-gated (punted to trusting solady's assembly self-call).
  • I7 is a regression guard (restates the modifier's own predicate under non-revert), not an independent proof.
  • I9 is admitted-tautological (rule_sanity: none) — a lemma, not coverage.
  • Title undercounts: the suite also covers I3 (WithdrawalSolvency) and I5 (OracleIntegrity).
  • OracleIntegrity afterInvariant asserts numRejOther == 0 — strict/brittle by design; flagged for maintainers.

Note

Low Risk
Changes are confined to Foundry invariant handlers, non-vacuity gates, and Certora spec filters; no src/ or runtime protocol logic is modified.

Overview
Test and Certora spec only — no production contract changes. The PR hardens the #470 invariant suite so properties are actually exercised instead of passing vacuously.

I3/P1 (WithdrawalSolvency)finalizeRequests no longer skips locks when lockAmount > totalValueInLp; it always attempts addEthAmountLockedForWithdrawal and classifies success vs InsufficientLiquidity / migration / other reverts. Adds probeOverLiquidityLock to deliberately over-lock, ghosts for wrongly accepting or rejecting backed locks, dual-direction P1 checks, targetSelector on real actions, and an afterInvariant floor that ghost_lockBoundEnforced > 0.

I11 (ValidatorLifecycle) — Fuzzing is limited to doLink / doRelinkAttack; afterInvariant requires at least one successful link and one re-link rejection path.

I12 (RewardsDistributor) — New doLowerCumulativeClaim drives the monotonic-decrease / NothingToClaim guard; setUp seeds a baseline claim + lower-cumulative attempt so gates survive shrinking; afterInvariant asserts lower_rejected > 0.

Certora I7 — The guarded-method filter now includes all nonDecreasingRate deposit entry points (deposit(), deposit(address), depositToRecipient), aligned with I8.

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

…gates, I7 scope)

HIGH — I3/P1 was a dead-code assertion. WithdrawalSolvencyHandler.finalizeRequests
pre-filtered `lockAmount > inLpBefore` then re-checked it inside the success branch,
so ghost_finalizeExceededLiquidity was unreachable and invariant_i3_finalized_backed
_by_liquidity was structurally true. The handler refused to feed _lockEth the very
over-bound input the property claims to test (LiquidityPool.sol:598).
  - Removed the pre-filter; attempt the lock UNCONDITIONALLY (mirrors the correct
    I13 doFinalize pattern). Classify reverts by selector: liquidity-guard rejection
    of an unbacked lock is a POSITIVE observation; a liquidity revert while the range
    was backed trips ghost_lockRejectedWhileBacked.
  - Added probeOverLiquidityLock: deliberately attempts a lock > totalValueInLp and
    asserts rejection — positively drives the guard, self-contained so the gate
    survives sequence-shrinking.
  - Dual-direction P1 invariant + non-vacuity gate ghost_lockBoundEnforced > 0.
    Verified: lockBoundEnforced=26, finalizeBoundChecks=7 (was provably dead).

MEDIUM — I11 (ValidatorLifecycle) had no targetSelector and no afterInvariant gate,
so the fuzzer burned budget on view getters and both invariants could pass trivially.
Added targetSelector(doLink/doRelinkAttack) + afterInvariant asserting link_ok>0 and
(link_already_revert+relink_attempt)>0. Verified non-vacuous: link_ok=15, relink=37.

LOW — I12 monotonic-decrease guard (preclaimed >= cumulativeAmount => NothingToClaim)
was never driven; doClaim only increases cumulative. Added doLowerCumulativeClaim
(self-contained: bootstraps a claim, then attempts a strictly-lower cumulative and
asserts rejection) + non-vacuity gate. Verified: lower_rejected=7, lower_unexpected_ok=0.

LOW — I7 Certora deposit filter only listed deposit(address,address) though
nonDecreasingRate guards _deposit (reached by all 4 deposit variants). Widened to
match I8. Re-verified on the cloud prover: SUCCESS across deposit()/deposit(address)/
deposit(address,address)/depositToRecipient + withdraw + both burn paths.

Test-only: no src/ changes. All four affected suites re-run and pass non-vacuously.
…obust floor)

The afterInvariant gates assert on per-run handler counters that, on some fuzz
seeds, read 0 at evaluation time — not just the new lower_rejected, but the
pre-existing finalize_ok/claim_ok too. On unlucky seeds the no-op-ish selectors
(doWarp/doRoll/doSetClaimDelay) dominate the sequence and the lifecycle never
fires, so the gate flakes red (~1-in-5 with no pinned seed). This was latent in
#470; adding doLowerCumulativeClaim as the 8th selector perturbed the
distribution enough to surface it on seed 5.

Fix (same pattern WithdrawalSolvency uses to guarantee its floor): deterministically
drive one full lifecycle in setUp — handler.doClaim (self-contained: establish ->
finalize -> claim -> replay-rejected, seeds finalize_ok/claim_ok/replay_revert) and
handler.doLowerCumulativeClaim (seeds lower_rejected). Foundry reverts to the
post-setUp snapshot before every run, so these counters are a guaranteed baseline
on EVERY seed. The fuzzer still independently exercises all paths at runtime; this
only establishes the non-vacuity floor.

Verified across seeds 1-8 (incl. the seed-5 that failed pre-fix): 3/3 pass every
time; floor counters non-zero (finalize_ok=32, claim_ok=9, lower_rejected=10 on
seed 5). Confirmed the parent commit FAILS seed 5 (0 passed; 3 failed), so this
commit is what removes the flake rather than masking it.

Also clarified ghost_lockRejectedWhileBacked's docstring: _lockEth emits
InsufficientLiquidity from two sites (entry guard + post-send _checkTotalValueInLp);
the ghost conservatively flags either while backed — both are real solvency
violations, so the name is narrower than what it correctly catches.

Test-only: no src/ changes.
@seongyun-ko seongyun-ko marked this pull request as ready for review June 16, 2026 02:34
@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.

@seongyun-ko seongyun-ko merged commit ec1e07a into seongyun/fuzz/security-upgrades Jun 16, 2026
1 check 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