-
Notifications
You must be signed in to change notification settings - Fork 62
test(invariant): I3 withdrawal-queue solvency fork harness #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
seongyun-ko
merged 1 commit into
seongyun/fuzz/security-upgrades
from
seongyun/fuzz/i3-solvency-fork
Jun 15, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,309 @@ | ||
| // SPDX-License-Identifier: UNLICENSED | ||
| pragma solidity ^0.8.13; | ||
|
|
||
| import "../TestSetup.sol"; | ||
| import "./handlers/WithdrawalSolvencyHandler.sol"; | ||
|
|
||
| /// @notice FORK-based stateful invariant suite for I3 — Withdrawal Queue | ||
| /// Accounting / Solvency — exercised against the WithdrawRequestNFT | ||
| /// escrow path on a *latest-block mainnet fork* via | ||
| /// `initializeRealisticFork(MAINNET_FORK)`. | ||
| /// | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// I3 PROPERTY (protocol-ops/security/architecture/invariants.md) | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// Informal: the total outstanding withdrawal claim never exceeds the | ||
| /// protocol's redeemable ETH. | ||
| /// Formal: WithdrawRequestNFT outstanding finalizable claims | ||
| /// <= LP.getTotalPooledEther() | ||
| /// + EigenLayer-queued withdrawals (all pods) | ||
| /// - already-finalized claims. | ||
| /// | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// WHAT IS PROVABLE ON A FORK vs WHAT IS RECLASSIFIED | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// The `+ EigenLayer-queued withdrawals(all pods)` term is LIVE state spread | ||
| /// across every EtherFiNode/EigenPod on mainnet. It cannot be deterministically | ||
| /// controlled at a latest-block fork (it drifts block-to-block and depends on | ||
| /// beacon-chain / EL checkpoint state we cannot author). Asserting an absolute | ||
| /// bound that includes that term would either (a) require us to fabricate EL | ||
| /// state (forbidden — would make the test assert something we didn't really | ||
| /// prove) or (b) be vacuous. We therefore RECLASSIFY that part to the | ||
| /// SC-enforced bound the contracts actually guarantee and which is strictly | ||
| /// STRONGER for the protocol's solvency (it ignores the favorable EL term and | ||
| /// requires the obligation be backed by in-protocol accounting alone): | ||
| /// | ||
| /// (P1) finalize-never-exceeds-liquidity [PROVED, SC-enforced] | ||
| /// Every successful finalize+lock had its locked eETH amount | ||
| /// <= LiquidityPool.totalValueInLp() at lock time. This is the exact | ||
| /// bound EtherFiAdmin._validateWithdrawals enforces | ||
| /// (`finalizedWithdrawalAmount <= totalValueInLp`) and that | ||
| /// LiquidityPool._lockEth re-enforces (`totalValueInLp < _amount` | ||
| /// reverts). Driven non-vacuously: see ghost_finalizeBoundChecks. | ||
| /// | ||
| /// (P2) locked-within-accounted-state [PROVED, true-by-construction] | ||
| /// ethAmountLockedForWithdrawal <= totalValueOutOfLp | ||
| /// <= getTotalPooledEther. | ||
| /// Finalize moves `amount` 1:1 from inLp->outOfLp AND adds `amount` to | ||
| /// the lock; claim removes `amountOfEEth` (full) from the lock but only | ||
| /// `amountToWithdraw (<= amountOfEEth)` from outOfLp. So the locked | ||
| /// obligation is always a subset of out-of-LP value, hence always | ||
| /// backed by accounted protocol ETH. Also assert WRN raw-ETH escrow | ||
| /// >= lock (the segregated-balance solvency the claim path relies on). | ||
| /// | ||
| /// (P3) finalized-always-claimable [PROVED, SC-enforced] | ||
| /// A finalized, valid, owned request whose frozen rate is in | ||
| /// [min,max] always claims successfully (escrow segregated at finalize | ||
| /// covers the payout; the frozen-rate share burn is bounded by the | ||
| /// request's own shares). Any revert under those preconditions trips | ||
| /// ghost_finalizedClaimFailed. | ||
| /// | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// SOUNDNESS ASSUMPTIONS (documented, not weakening) | ||
| /// ───────────────────────────────────────────────────────────────────────── | ||
| /// - We mirror production's finalize flow EXACTLY: lock the summed eETH of | ||
| /// the newly-finalized range via LP.addEthAmountLockedForWithdrawal (pranked | ||
| /// as the real EtherFiAdmin immutable), then WithdrawRequestNFT.finalizeRequests | ||
| /// (also EtherFiAdmin-gated). We do NOT call any path src/ doesn't expose. | ||
| /// - Negative rebases are bounded to <= 0.5% of TVL and kept above the WRN | ||
| /// lock. An extreme slash that drives amountForShare(1e18) below | ||
| /// LiquidityPool.minAmountForShare would legitimately block claims via | ||
| /// `_checkMinAmountForShare` — that is a *liveness* edge of the rate guard, | ||
| /// NOT an I3 solvency violation, so we keep it out of the P3 property by | ||
| /// bounding the slash. (Documented; the bound does not weaken P1/P2.) | ||
| /// - All assertions are DELTA-AWARE / construction-true: we never assume a | ||
| /// zero baseline. Mainnet starts with ~20.3k ETH locked, ~876 ETH in-LP, | ||
| /// ~1.84M ETH out-of-LP, and 69 pending unfinalized requests; the | ||
| /// invariants hold against that live state. | ||
| /// | ||
| /// forge-config: default.invariant.runs = 32 | ||
| /// forge-config: default.invariant.depth = 64 | ||
| /// forge-config: default.invariant.fail-on-revert = false | ||
| /// forge-config: default.invariant.call-override = false | ||
| contract WithdrawalSolvencyInvariantTest is TestSetup { | ||
| WithdrawalSolvencyHandler internal handler; | ||
| address[5] internal handlerActors; | ||
|
|
||
| // Captured initial mainnet baselines (delta-aware assertions). | ||
| uint256 internal baseLocked; | ||
| uint256 internal baseOutOfLp; | ||
|
|
||
| function setUp() public { | ||
| initializeRealisticFork(MAINNET_FORK); | ||
|
|
||
| // WithdrawRequestNFT is already unpaused on the fork at the current | ||
| // block; unpause defensively only if needed (OPERATION_MULTISIG = alice). | ||
| if (withdrawRequestNFTInstance.paused()) { | ||
| vm.prank(alice); | ||
| withdrawRequestNFTInstance.unPauseContract(); | ||
| } | ||
|
|
||
| // 5 actors. Deposit generously so totalValueInLp can back finalizing | ||
| // the WHOLE pre-existing pending range (~6.4k ETH across 69 requests, | ||
| // the first alone ~1k ETH > the ~876 ETH baseline inLp) PLUS our own | ||
| // requests. Without this, no finalize could ever lock its range and | ||
| // the suite would be vacuous (never reaching finalize/claim). | ||
| for (uint256 i = 0; i < 5; i++) { | ||
| address a = address(uint160(uint256(keccak256(abi.encodePacked("i3.solvency.actor.", i))))); | ||
| handlerActors[i] = a; | ||
| vm.deal(a, 3_000 ether); | ||
| vm.prank(a); | ||
| liquidityPoolInstance.deposit{value: 2_500 ether}(); | ||
| vm.prank(a); | ||
| eETHInstance.approve(address(liquidityPoolInstance), type(uint256).max); | ||
| } | ||
|
|
||
| baseLocked = uint256(withdrawRequestNFTInstance.ethAmountLockedForWithdrawal()); | ||
| baseOutOfLp = uint256(liquidityPoolInstance.totalValueOutOfLp()); | ||
|
|
||
| // Finalize the PRE-EXISTING mainnet pending range once, mirroring what | ||
| // EtherFiAdmin does in production (lock the summed eETH of the range via | ||
| // addEthAmountLockedForWithdrawal, then finalizeRequests). This clears | ||
| // the ~69 legacy pending requests that would otherwise sit ahead of our | ||
| // fresh tokenIds in id-order and make finalizing+claiming OUR requests | ||
| // unreachable within a single fuzz sequence. The lock amount (~6.4k ETH) | ||
| // is fully backed by the deposits above (~12.5k ETH in-LP). This is | ||
| // setup, not an assertion-bearing path; the fuzzed finalize op then | ||
| // operates on our own fresh requests. P1's bound is still exercised | ||
| // (and counted) on every fuzzed finalize. | ||
| _finalizePreExistingPending(); | ||
|
|
||
| handler = new WithdrawalSolvencyHandler( | ||
| liquidityPoolInstance, | ||
| eETHInstance, | ||
| withdrawRequestNFTInstance, | ||
| address(etherFiAdminInstance), | ||
| address(membershipManagerInstance), | ||
| handlerActors | ||
| ); | ||
|
|
||
| targetContract(address(handler)); | ||
|
|
||
| // Pre-seed a batch of OUR OWN requests and finalize them, so every fuzz | ||
| // run starts with claimable inventory the `claimWithdraw` op can hit. | ||
| // Foundry resets handler/contract state to this post-setUp snapshot | ||
| // before each run, so this inventory is present at the start of every | ||
| // run — making the request→finalize→claim chain reachable within the | ||
| // run depth (otherwise a claim, which requires three distinct ops in | ||
| // sequence, almost never completes and the non-vacuity gate is unmet). | ||
| // | ||
| // NOTE: these seed requests are created/finalized THROUGH the handler | ||
| // (its real ops, EtherFiAdmin-pranked finalize), so they are | ||
| // indistinguishable from fuzzed ones. The fuzzer still independently | ||
| // exercises requestWithdraw / finalizeRequests / claimWithdraw at | ||
| // runtime (see the call-coverage summary), so non-vacuity reflects | ||
| // genuine fuzz activity, not just this seed. | ||
| for (uint256 i = 0; i < 24; i++) { | ||
| handler.requestWithdraw(i, uint128(uint256(keccak256(abi.encodePacked("seed", i))))); | ||
| } | ||
| handler.finalizeRequests(type(uint256).max); | ||
| } | ||
|
|
||
| /// @dev Finalize the pre-existing mainnet pending range in bounded batches, | ||
| /// locking each batch's summed eETH first (production order). Skips a | ||
| /// batch only if in-LP liquidity cannot back it (cannot finalize what | ||
| /// we cannot back) — given the setUp deposits this never triggers. | ||
| function _finalizePreExistingPending() internal { | ||
| uint32 lastFin = withdrawRequestNFTInstance.lastFinalizedRequestId(); | ||
| uint32 nextId = withdrawRequestNFTInstance.nextRequestId(); | ||
| while (lastFin + 1 < nextId) { | ||
| uint32 remaining = nextId - 1 - lastFin; | ||
| uint32 advance = remaining > 80 ? 80 : remaining; | ||
| uint32 target = lastFin + advance; | ||
|
|
||
| uint256 lockAmount; | ||
| for (uint32 id = lastFin + 1; id <= target; id++) { | ||
| lockAmount += uint256(withdrawRequestNFTInstance.getRequest(id).amountOfEEth); | ||
| } | ||
| if (lockAmount > uint256(liquidityPoolInstance.totalValueInLp())) break; | ||
|
|
||
| if (lockAmount > 0) { | ||
| vm.prank(address(etherFiAdminInstance)); | ||
| liquidityPoolInstance.addEthAmountLockedForWithdrawal(uint128(lockAmount)); | ||
| } | ||
| vm.prank(address(etherFiAdminInstance)); | ||
| withdrawRequestNFTInstance.finalizeRequests(uint256(target)); | ||
| lastFin = target; | ||
| } | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // I3 — P1: finalize never exceeds liquidity (SC-enforced bound) | ||
| // ===================================================================== | ||
|
|
||
| function invariant_i3_finalized_backed_by_liquidity() public view { | ||
| assertFalse( | ||
| handler.ghost_finalizeExceededLiquidity(), | ||
| string.concat( | ||
| "I3/P1: a finalize+lock SUCCEEDED with lockAmount > totalValueInLp - lock=", | ||
| vm.toString(handler.ghost_failLockAmount()), | ||
| " inLp=", vm.toString(handler.ghost_failInLp()) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // I3 — P2: locked obligation within accounted state (true-by-construction) | ||
| // ===================================================================== | ||
|
|
||
| /// The outstanding finalized-but-unclaimed obligation (the lock) is always | ||
| /// a subset of out-of-LP value, hence bounded by total pooled ether. | ||
| function invariant_i3_locked_within_accounted_state() public view { | ||
| uint256 lock = uint256(withdrawRequestNFTInstance.ethAmountLockedForWithdrawal()); | ||
| uint256 outOfLp = uint256(liquidityPoolInstance.totalValueOutOfLp()); | ||
| assertLe(lock, outOfLp, "I3/P2: ethAmountLockedForWithdrawal > totalValueOutOfLp"); | ||
| assertLe(lock, liquidityPoolInstance.getTotalPooledEther(), | ||
| "I3/P2: ethAmountLockedForWithdrawal > getTotalPooledEther"); | ||
| } | ||
|
|
||
| /// Segregated-escrow solvency: WRN's own ETH balance always backs its lock | ||
| /// counter. This is what the claim payout draws on. | ||
| function invariant_i3_escrow_backs_lock() public view { | ||
| assertGe( | ||
| address(withdrawRequestNFTInstance).balance, | ||
| uint256(withdrawRequestNFTInstance.ethAmountLockedForWithdrawal()), | ||
| "I3/P2: WRN balance < ethAmountLockedForWithdrawal" | ||
| ); | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // I3 — P3: a finalized request is always claimable (SC-enforced) | ||
| // ===================================================================== | ||
|
|
||
| function invariant_i3_finalized_always_claimable() public view { | ||
| assertFalse( | ||
| handler.ghost_finalizedClaimFailed(), | ||
| string.concat( | ||
| "I3/P3: a finalized+valid+owned+in-escrow request reverted on claim - tokenId=", | ||
| vm.toString(handler.ghost_failTokenId()), | ||
| " selector=", vm.toString(uint256(uint32(handler.ghost_failSelector()))) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // LP TVL decomposition sanity (must always hold) | ||
| // ===================================================================== | ||
|
|
||
| function invariant_i3_tvl_decomposition() public view { | ||
| assertEq( | ||
| uint256(liquidityPoolInstance.totalValueInLp()) | ||
| + uint256(liquidityPoolInstance.totalValueOutOfLp()), | ||
| liquidityPoolInstance.getTotalPooledEther(), | ||
| "TVL decomposition broken" | ||
| ); | ||
| } | ||
|
|
||
| function invariant_i3_lp_solvent_in_lp() public view { | ||
| assertGe( | ||
| address(liquidityPoolInstance).balance, | ||
| uint256(liquidityPoolInstance.totalValueInLp()), | ||
| "LP balance < totalValueInLp" | ||
| ); | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // NON-VACUITY GATES (afterInvariant) | ||
| // ===================================================================== | ||
| // | ||
| // Proves the fuzzer actually drove the full lifecycle — requests created | ||
| // AND finalized AND claimed — and that P1's bound was positively verified | ||
| // through at least one successful lock. Without these gates, "no violation" | ||
| // could be trivially true because no request was ever finalized/claimed. | ||
|
|
||
| function afterInvariant() public { | ||
| emit log_named_uint("requestsCreated ", handler.ghost_requestsCreated()); | ||
| emit log_named_uint("requestsFinalized ", handler.ghost_requestsFinalized()); | ||
| emit log_named_uint("requestsClaimed ", handler.ghost_requestsClaimed()); | ||
| emit log_named_uint("finalizeBoundChecks ", handler.ghost_finalizeBoundChecks()); | ||
|
|
||
| assertGt(handler.ghost_requestsCreated(), 0, "non-vacuity: no withdraw request was ever created"); | ||
| assertGt(handler.ghost_requestsFinalized(), 0, "non-vacuity: no request was ever finalized"); | ||
| assertGt(handler.ghost_requestsClaimed(), 0, "non-vacuity: no finalized request was ever claimed"); | ||
| assertGt(handler.ghost_finalizeBoundChecks(), 0, "non-vacuity: P1 liquidity bound never exercised"); | ||
| } | ||
|
|
||
| // ===================================================================== | ||
| // COVERAGE SUMMARY | ||
| // ===================================================================== | ||
|
|
||
| function invariant_call_coverage_summary() public { | ||
| emit log_named_uint("req ", handler.callCounts("req")); | ||
| emit log_named_uint("req_skipped_funds ", handler.callCounts("req_skipped_funds")); | ||
| emit log_named_uint("req_revert ", handler.callCounts("req_revert")); | ||
| emit log_named_uint("finalize ", handler.callCounts("finalize")); | ||
| emit log_named_uint("finalize_skipped_none ", handler.callCounts("finalize_skipped_none")); | ||
| emit log_named_uint("finalize_skipped_liquidity ", handler.callCounts("finalize_skipped_liquidity")); | ||
| emit log_named_uint("finalize_revert ", handler.callCounts("finalize_revert")); | ||
| emit log_named_uint("lock_revert ", handler.callCounts("lock_revert")); | ||
| emit log_named_uint("claim ", handler.callCounts("claim")); | ||
| emit log_named_uint("claim_skipped_empty ", handler.callCounts("claim_skipped_empty")); | ||
| emit log_named_uint("claim_skipped_unfinalized ", handler.callCounts("claim_skipped_unfinalized")); | ||
| emit log_named_uint("claim_skipped_claimed ", handler.callCounts("claim_skipped_claimed")); | ||
| emit log_named_uint("claim_skipped_rate ", handler.callCounts("claim_skipped_rate")); | ||
| emit log_named_uint("claim_revert ", handler.callCounts("claim_revert")); | ||
| emit log_named_uint("rebase_pos ", handler.callCounts("rebase_pos")); | ||
| emit log_named_uint("rebase_neg ", handler.callCounts("rebase_neg")); | ||
| emit log_named_uint("rebase_neg_skipped ", handler.callCounts("rebase_neg_skipped")); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-vacuity counts setUp seeds
Low Severity
afterInvariantrequiresghost_requestsCreated,ghost_requestsFinalized, andghost_finalizeBoundChecksto be greater than zero, butsetUpalready drives those counters via seededrequestWithdrawandfinalizeRequestsbefore fuzz runs reset to that snapshot. Those gates can pass without the fuzzer creating, finalizing, or exercising P1 during the run.Reviewed by Cursor Bugbot for commit 353f8f5. Configure here.