feat: cut production over to EpochedQueueModule (Renzo-style epoch qu… - #13
Merged
Conversation
…eue) Harden EpochedQueueModule (dynamic-cap bug fix, oldestUnfundedEpochId cursor, direct interface calls, new test suite) and make it the sole production queue-settlement mechanism, replacing the FIFO/keeper-scanned QueueModule. Rewires VaultUpkeep, FixedMaturityVaultUpkeep, CoreVaultLens, SelectorLib/SelectorRegistry, FeeCollector auto-harvest, DeployTypes, and every deploy script to route through EpochedQueueModule instead. QueueModule.sol itself is intentionally left in place for now: the existing test harness (CoreHarness/TestDeployer) still wires it alongside EpochedQueueModule so the pre-existing ~40-file test suite keeps passing unmodified. IQueueModule is a superset interface (epoch-model + legacy members) to support both during the transition. Deleting QueueModule and porting its dependent invariant/stress suites to epoch-model semantics is deferred as follow-up work. Full forge build + forge test suite green (773+ tests, 0 failures). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes src/core/modules/QueueModule.sol and its dead dependents (ExitEngineLib's QueueStorage-taking calculateCapRemaining overload, stray QueueStorage imports in CoreVault/FixedMaturityHarness/one integration test) now that every caller has migrated to EpochedQueueModule. QueueStorage.sol itself is kept, unused, purely as a permanently-reserved EIP-7201 slot. Shrinks src/interfaces/IQueueModule.sol back to epoch-only methods, and fixes one lingering test call site (CoreVault_DiamondLite_Routing) still hitting the retired requestClaim selector. Rewrites docs/queue-mechanics.md for the epoch model and brings modules.md, architecture.md, exit-engine.md, and audit-scope.md back in sync with the current implementation. Full forge build + test suite green (773 tests, 0 failures); invariant suites re-verified at 200 runs x 150 depth.
…pochedqueue-cutover
exit, strategy deploy, or NAV drop could leave an already-"Funded" epoch's claimants unpaid. EpochQueueStorage now tracks reservedForClaims/closedPendingAssets, and fundEpoch/_canInstant/ claimEpochAssets/batchClaimEpochAssets all respect the reservation. - minClaimAmount was silently unenforced after the QueueModule cutover, reopening a dust-claim griefing vector against outstandingClaimCount; re-enforced in _requestEpochWithdrawal. - EpochQueueStorage.SLOT was hand-typed and didn't match its own EIP-7201 formula; corrected, and EIP7201Compliance.t.sol now covers it. - LiquidityOpsModule's queue-safety staticcall targeted a selector no module has routed since the cutover (ClaimsMixin is dead code); now reads EpochQueueStorage directly. canDeploy/_deployInternal also exclude reservedForClaims from deployable surplus. - VaultUpkeep and FixedMaturityVaultUpkeep both gave EPOCH_FUND unconditional priority with no fall-through, so one persistently underfunded epoch starved every other op forever; added stall tracking with a backoff, mirroring the existing failure-backoff pattern. - CoreVaultLens valued pending claims/withdrawals at live PPS instead of each epoch's locked ppsAtClose; fixed, with pendingWithdrawals now exact and O(1) via the new reservation fields.
The first reservation pass guarded fundEpoch, _canInstant and the strategy deploy path, then declared FUNDED a real claim on assets. A systematic sweep of every path that moves the underlying out of the vault found two more consumers that never learned the rule. forceWithdraw and forceWithdrawAll size their payout off the raw hot balance. In OpenEnded mode force exits and the epoch queue coexist, so after a NAV drop a force exit could spend past the reservation and leave an already-funded claimant unpayable at their locked ppsAtClose. Both now work against free liquidity: forceWithdrawAll degrades to a partial fill through the existing proportional-burn logic, while forceWithdraw asks for an exact amount and so reverts explicitly instead of hitting a bare ERC20 balance error. BufferManager.plan sized needDeploy off raw hot as well. This one is invisible to any module-level check: warm adapters pull the underlying straight out of the vault under the standing allowance granted by CoreVault.approveWarmAdapters, so no module transfer is involved at all. Worse, warm cash cannot be recovered for an epoch that is already Funded, because fundEpoch refuses to run its refill waterfall a second time. plan now nets the reservation out of hot, which shrinks needDeploy and grows needRefill, both in the safe direction. The read degrades to zero when the queue selector is not routed, so a core without the epoch queue is unaffected. The FixedMaturity lifecycle moves capital out in states where the queue is gated off, so activateFixedMaturityCycle and refundClaim legitimately ignore the reservation. That only holds while no claim can survive the mode switch, and claimEpochAssets has no FixedMaturity gate, so setVaultModeFixedMaturity now refuses to flip with claims outstanding. Adds ReservationConsumers, covering the two new consumers plus the release half of the lifecycle that the first pass never asserted: ten full cycles, cancellations, empty epochs and the batch path, all checking assets actually delivered rather than counters.
The stall accounting sat inside the success branch of the EPOCH_FUND handler, so a fundEpoch call that reverted was swallowed by the try/catch and left epochFundStallCount and lastEpochFundTargetId untouched. checkUpkeep's stalled-check compares against both, so it could never become true on that path: EPOCH_FUND kept unconditional priority every cycle and starved CRYSTALLIZE, REBALANCE, DEPLOY, REALIZE and RECONCILE indefinitely. What engaged instead was the global failure backoff, which idles the whole keeper rather than letting another op through, which is the opposite of what the stall backoff is for. A revert is a stall, not a non-event. The bookkeeping now runs on both outcomes; only the success/failure counters stay branch-specific. FixedMaturityVaultUpkeep had the same hole from the other side: it called fundEpoch outside any try/catch, so a revert unwound the whole performUpkeep and the accounting below was never reached. The call is now wrapped, with the reason spelled out at the call site. Also strengthens the existing stall test, whose central assertion sat inside an `if (needed2)` guard and would have passed green on an idle keeper -- exactly the failure it is meant to catch. It now asserts positively that the pending CRYSTALLIZE is what gets scheduled once EPOCH_FUND yields.
oldestUnfundedEpochId advances with a bounded scan, so a long run of out-of-order-funded epochs can leave it parked on an epoch that is itself already FUNDED. fundEpoch reverted EpochAlreadyFunded on such a target, and the cursor only ever moved inside a successful fundEpoch of the cursor epoch, so the state was terminal: a keeper reading the cursor reverted on every cycle with no administrative reset anywhere. fundEpoch now syncs the cursor and returns when the target is already funded, which restores the "safe to call multiple times: subsequent calls are no-ops if funded" contract its own documentation already claimed. Between an admin reset and self-healing, self-healing wins: the cursor is read by a permissionless keeper on a path that must not depend on a governance action arriving at an unpredictable moment, and post- seal that action could be timelocked. The advance is extracted into syncOldestUnfundedEpoch, exposed as a public permissionless selector so a lagging cursor can also be walked forward without waiting for the next funding, and the scan bound is named MAX_CURSOR_SCAN with the lag-not-wedge property documented on it. Composes with the stall-accounting fix: an already-funded target now makes progress rather than reverting, so the keeper resets its stall counters instead of accumulating them against a phantom stall.
minClaimAmount was checked only on the queue fallback, so an identical sub-floor requestInstantWithdrawal settled while the cap had room and reverted once it was exhausted. The outcome depended on vault state rather than on the caller's input, which breaks the "falls back to the epoch queue (no revert)" contract documented on the FeeCollector call site and makes small AUTO_HARVEST distributions fail unpredictably. Neither obvious option is clean. Checking the floor on both legs is deterministic and closes the dust vector, but it permanently strands fee accruals worth less than the floor, since AUTO_HARVEST has no other exit route. Exempting the fallback honours the contract but reopens the griefing vector: an attacker can exhaust the epoch cap themselves and then spam sub-floor claims through the fallback, which is exactly what the floor was reinstated to prevent. Taking a third route instead: enforce the floor on both legs, and exempt feeCollector. The floor bounds per-caller dust griefing against outstandingClaimCount, and feeCollector is a single trusted protocol address already capped at one outstanding claim per share token by its own pendingHarvestShares bookkeeping, so it cannot spam. Users get deterministic, input-driven behaviour matching the retired QueueModule; the fee path keeps the contract it was written against. The internal request helper takes a flag so the instant route does not pay for the asset conversion twice on the fallback leg.
reservedForClaims is not an exact round trip: the reservation is taken on the epoch total while releases are per claim, so per-claim truncation leaves under one asset-unit behind once an epoch fully drains. Measured at 1 wei per multi-claim epoch, which is 1e-6 USDC. Chasing it would cost more than it is worth and no claimant is ever short-paid by it, but it does make the counter monotonically non-zero, so the field now says so and tells the next reader not to write reservedForClaims == 0 as an invariant. closedPendingAssets was flagged as over-reporting for an epoch that closes and never funds. On re-examination it does not: Closed is terminal until funding succeeds, since claiming requires Funded and cancelling requires Open, so that liability genuinely is still outstanding. Documented as monotone-by-design rather than changed, with the reasoning recorded so it is not "fixed" later by mistake. MockQueueEpochParamsProvider hardcoded minClaimAmount to zero with no setter, so every test written against that harness silently skipped the floor. Adding the setter and pointing the getter at it makes the floor reachable; the default stays zero, so existing tests are unaffected. Re-running the dust-claim proofs through it with a floor configured now reverts ClaimTooSmall, which is the intended behaviour and the reason the gap was worth closing.
Brings in the decimals guard and the SystemSealer GlobalConfig binding. Merged rather than rebased: this branch already integrated main once through a merge commit, and replaying eleven commits (two of them merges) would rewrite history already published on the pull request for no benefit. The FixedMaturityModule import clash resolved itself, since the epoch-queue work had already added its own import alongside the two main keeps. One semantic casualty that merged clean but did not compile: SystemSealer_DecimalsGuard is new on main and wires QueueModule, which this branch deletes. Repointed at EpochedQueueModule, matching every other harness. Nothing else in the incoming diff overlaps the queue cutover: the sealer binding, the oracle valuation library and the execution-memory rescale are all in subsystems this branch leaves alone.
Warm adapters pull the underlying out of the vault with transferFrom under a standing allowance, so a compromised or buggy adapter could take the entire balance. reservedForClaims does not defend against this: it is enforced when a deploy is sized, not when the token actually moves. Three options were on the table. Approving only the planned deploy amount, or approving just-in-time and zeroing afterwards, both need the vault to grant the allowance at deploy time -- but the deploy is driven from BufferManager, so either would require a new privileged vault entry point callable by the buffer manager. That is more attack surface than the hole being closed, and it lands the trust boundary on whoever can set the adapter list, which is the very guard still missing on the other side. Both also break on the fallback loop in _deployToAdapters, which tries adapters in order and does not know which one will succeed, so a single-adapter approval would leave every fallback unfunded. A configurable ceiling changes only the grant. No new entry points, no callback, no change to the keeper rebalance, the buffer refill, the strategy deploy or the FixedMaturity cycles, since none of them consult the allowance and none of them pull. Gas is unchanged: still one forceApprove per adapter. The trade is that the allowance depletes as it is spent and does not renew, so warm deploys through an adapter stop once it has pulled its budget and governance has to top it up. That is the point -- a visible, deliberate budget rather than an open tap -- and it composes with the adapter-list guard rather than depending on it: even an attacker who gets a malicious adapter listed is capped at the ceiling. The deploy script now passes a cap, overridable by env var, defaulting well above the planned vault size so the first deploy is not immediately starved.
SkimMixin is inherited by nothing. CoreVault does not extend it, no module does, and the only test file that mentions skim has the whole block commented out with a note that the function was removed when the architecture went modular. Left in the tree it is a loaded gun: _canSkim defaults to allowing every token, including the vault's own underlying, and _assertSkimRole is abstract. Whoever wires it next inherits a governance-callable path that transfers the entire balance of any token out of the vault, bypassing the claim reservation and every exit gate. That is a bigger hole than any it would close, and the recovery use case it was written for is not in use. Deleting rather than hardening the default: a mixin with no consumers has no behaviour to preserve, and a corrected default would still be dead code inviting the same mistake.
Two defects, each of which bricked fee distribution for a share token with no recovery short of a governance mode change. distribute() required pendingHarvestShares to be zero before queuing a fallback harvest, so a second one for the same token reverted outright and a single epoch that never funded blocked that token for good. Simply restoring the accumulation main used is not safe here: the bookkeeping was a single (epochId, claimId) slot, so the second claim would overwrite the first one's coordinates and strand its shares in vault escrow permanently. The claims are now held in a per-token list, and harvestQueued drains whichever ones are ready, leaving unfunded epochs queued and retryable instead of blocking the others. The list is bounded purely as a backstop; in normal operation it holds zero or one entry, since an entry only appears when an instant harvest cannot settle. The second defect: an instant harvest whose payout rounded down to zero took the fallback branch, because the branch tested settledImmediately AND out > 0. requestInstantWithdrawal returns (0, 0) for the epoch and claim ids when it settles inline, so that sentinel was recorded as a real claim handle; harvestQueued then called claimEpochAssets(0, 0), reverted NotClaimOwner every time, and left pendingHarvestShares permanently non-zero. The routing decision now keys on settledImmediately alone, with a dust settlement emitting an event and returning rather than queuing anything. Adds FeeCollectorHarvestQueue against the real collector and vault, asserting on underlying that actually reaches the treasury.
A fundEpoch() attempt that left the epoch CLOSED emitted nothing at all. During shadow mainnet, monitoring is manual, so a funding failure was invisible: the first signal was a user reporting they had not been paid. Adds EpochFundingShortfall with the epoch, what it owes, the free liquidity actually available to it (net of other funded epochs' reservations, which is the number that governs whether it can ever be funded) and the remaining gap. EpochFundAttempt was emitted twice per call, first with hotAfter zeroed and then with hotBefore zeroed. An indexer reading either line on its own got a fabricated balance, and nothing in the log said the two had to be merged. Now one event per call with both balances populated. Events.EpochRolled refers to the withdrawal-cap window, not the settlement queue, but the name sits next to EpochOpened, EpochClosed and EpochFunded and anyone writing subgraph mappings would reasonably group them. Renamed it rather than the queue trio: it has a single emit site, no test or off-chain consumer in the repo, and the queue names are the ones the frontend and subgraph are being built against. Now WithdrawalCapEpochRolled. The docs table also claimed a two-argument signature the event never had; corrected to match.
requestEpochWithdrawal, claimEpochAssets, batchClaimEpochAssets and requestInstantWithdrawal took the vault's reentrancy flag; cancelEpochWithdrawal, fundEpoch and closeCurrentEpoch did not, with no stated reason for the split. fundEpoch is the one that matters. It calls out to the buffer manager and the strategy router mid-body and then re-reads the hot balance to decide whether to mark the epoch FUNDED, so a reentrant call landing between the pull and that read is precisely the shape the guard exists to exclude. closeCurrentEpoch refreshes warm NAV and batch-transfers fee shares out before writing the epoch's locked price; cancelEpochWithdrawal moves shares back to the caller. closeCurrentEpoch was not on the list but is the same case, and leaving one entry point unguarded while aligning its neighbours would just move the inconsistency. All three now enter and exit the guard, including on fundEpoch's already-funded early return.
…fall Three gaps, each hiding the state space where a bug had already been found once. Every existing share-price-collapse test closes the epoch AFTER the loss, which locks ppsAtClose at the already-reduced price -- the harmless direction. The order the epoch model introduces is the reverse: close first, so the price is locked high, then remove the assets backing it. That is where the unpayable-claimant bug lived. Added a test for it, asserting the funded claimant is paid in full out of the reservation even after a 50% collapse and a competing force exit. The invariant handlers fused close, fund and claim into a single action and bailed out unless the open epoch was closeable and non-empty, so the fuzzer could never reach a state with one epoch closed-but-unfunded while another closed. Split into independent closeEpoch, fundSomeEpoch and claimReady actions, with funding targeting an arbitrary epoch so out-of-order funding and repeated failed attempts are reachable too. Added invariant_reservationIsAlwaysBacked to both stateful suites, which is the property that only becomes interesting once a backlog exists. The fundEpoch gas characterization printed a number and asserted nothing, so the flat-cost claim could regress unnoticed; it now asserts a ceiling. It also pre-funded the vault, which short-circuits fundEpoch before its liquidity waterfall runs, so the redeem path had no coverage at all. Added a case that deploys hot into a strategy through the real path and forces the epoch to be funded by the router redeem. That new case surfaced a live post-merge interaction: the decimals guard makes StrategyRouter.executeRedeemBatch value the asset through OracleValuationLib, which reverts when the asset has no oracle configured or the quote is older than the staleness window -- for 6dp USDC as much as 18dp. fundEpoch swallows that revert, so the waterfall silently no-ops and the epoch stays CLOSED. MockParamsProvider gains an oracle setter, defaulting to unset so existing suites are unaffected.
storage-layout, access-control, fee-policy, deployment and testing still described QueueModule as the live module, with line references pointing into a file this branch deleted. Section 5 of storage-layout documented the retired FIFO layout as production storage, down to the Claim struct packing and the compaction routine. Section 5 now documents EpochQueueStorage: the epoch and claim records, the reservation and escrow counters, and the two invariants the stateful suites assert. Records that reservedForClaims is not an exact round trip so nobody writes an == 0 invariant against it. The access matrix and the per-module storage tables are rebuilt around the epoch entry points. access-control replaces the retired selector list. fee-policy points at the current crystallization site and states plainly that ppsAtClose is snapshotted gross of any pending performance fee, with nothing ordering endEpochCrystallize against closeCurrentEpoch. Two further corrections found while checking: queue-mechanics claimed there is no anti-spam gate on the request path, which stopped being true when minClaimAmount was reinstated, and it is worth stating what the griefing now costs; modules described the FeeCollector's harvest bookkeeping as a single claim slot, which is no longer how it works. Adds a Manual Post-Deploy Parameters section to deployment for the three settings no script writes: the deposit cap, which defaults to 10M USDC and will happily accept that much on a vault meant to launch at 20,000; the asset oracle, which is a hard dependency for the strategy-redeem leg of epoch funding and whose absence is silent because fundEpoch swallows the revert; and queueStressThreshold, whose default only makes sense against a large vault. The warm adapter allowance cap is listed alongside them, with verification commands for all four.
_requestEpochWithdrawal took a boolean that disabled a security check based on what the caller claimed to have already done, and the check itself exempted core.feeCollector by address. Both are the kind of thing that becomes a bug the day a third call site passes true by mistake, or the day the carve-out gets widened by one more address. Going back to enforcing the floor only on the queue path -- the original design, whose reasoning was correct about the target, since the griefing vector is outstandingClaimCount and only the queue touches it -- was considered and rejected. It reopens the defect it was changed to fix: a fee accrual below the floor makes the fallback revert and takes the whole distribute() call with it. That is not an edge case. At a 50 bps exit fee on a vault capped at 20,000 USDC, roughly the entire vault has to exit once before the collector holds 100 USDC of fees, so accruals sit below a 100 USDC floor for most of the vault's life. The old QueueModule had a single entry point and could put one unconditional check at the top. This module has two, which is what created the choice in the first place. So: the check lives unconditionally in _requestEpochWithdrawal, the one choke point where a claim enters the queue, and no future entry point can create one without passing through it. requestInstantWithdrawal calls the same helper up front so its outcome tracks the caller's input rather than vault state. The fallback route pays for one extra conversion; that is cheaper than a flag that can be passed wrong. The carve-out moves to the side that actually needs it: distribute() wraps the vault call in try/catch and emits HarvestDeferred, leaving the shares in place to accumulate. This also covers every other reason the vault might refuse -- a paused withdrawal, an exhausted cap -- not just the floor.
MAX_PENDING_HARVEST_CLAIMS was a hard require, so reaching it put the revert back exactly where it had just been removed from: one more unfunded epoch and distribute() fails again for that token. The bound moved the wall, it did not take it down. Hitting the cap now emits HarvestDeferred and returns. The shares stay with the collector and are picked up by the next distribute() once harvestQueued has drained the list, so a saturated queue delays a distribution rather than blocking it. The check stays BEFORE the vault call, deliberately. Deferring after it would mean the claim already exists in vault escrow with nowhere to record its coordinates -- untracked shares that nobody can ever pull, which is precisely the failure the original single-slot bookkeeping was guarding against. The price is that a harvest which would have settled inline is also deferred while the list is full; that is the conservative direction. The constant keeps its value but is now documented as a storage backstop rather than a workload limit: an entry costs a full epoch of simultaneous cap exhaustion and funding failure, one successful harvestQueued drains every ready entry at once, and 64 is roughly two months of that at a daily cadence -- far past the point where the funding failure itself, visible via EpochFundingShortfall, would have been dealt with.
Turning the EpochAlreadyFunded revert into a cursor-syncing no-op kept the keeper alive but threw away the diagnostic with it: an integrator pointing at the wrong epoch now got silence, and so did an operator watching a stale cursor repair itself. EpochFundSkipped carries the epoch plus the cursor either side of the sync, which separates the two cases without needing a reason string. A cursor that moved is the self-heal doing its job; a cursor that did not is a caller with nothing to do here. Checked for noise rather than assumed: the keeper targets oldestUnfundedEpochId, which points at a CLOSED epoch whenever a backlog exists and equals currentEpochId when it does not, and checkUpkeep only schedules EPOCH_FUND in the former case, so the normal cycle never reaches this branch. A test asserts that directly by recording logs across a full close-and-fund and requiring zero occurrences.
stefanobotticelli
approved these changes
Aug 15, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Consolidate to a single queue module: EpochedQueueModule replaces QueueModule
Why
We had two parallel withdrawal-queue implementations in the codebase:
QueueModule(live, FIFO array, keeper-scanned batch settlement) andEpochedQueueModule(built in an earlier sprint, modeled on Renzo ezETH's withdrawal queue, but never wired into production). This PR retiresQueueModuleand makesEpochedQueueModulethe sole queue-settlement mechanism.Why epoch-batched over the traditional FIFO queue:
QueueModule'ssettleFeesAndProcessQueuescanned and settled claims one at a time, so keeper gas scaled with how many claims were pending.EpochedQueueModulebatches all claims submitted in a window into one epoch; a singlefundEpoch()call pulls liquidity for the entire epoch regardless of how many claims it contains. We measured this directly:fundEpoch()gas came out flat at ~39k across queue depths of 100, 500, and 1000 claims.ppsAtClose) for every claim in an epoch, once, atcloseCurrentEpoch(). Verified against a share-price-collapse scenario: claims settle correctly at the post-collapse price, not a stale pre-collapse one.claimEpochAssets()means one user's claim never blocks another's.Funded, any user can self-serve their claim immediately — no keeper action required on the payout side, only on close/fund.The tradeoff we accepted:
EpochedQueueModuledropsQueueModule's per-user anti-spam throttle (cooldownPerClaim/maxClaimsPerUserPerEpoch). That throttle existed to bound keeper gas against an unbounded queue; since settlement cost no longer scales with claim count, the original DoS rationale is substantially weaker. Flagged explicitly indocs/exit-engine.md§9 as a deliberate, not accidental, simplification.What changed
CoreVault,VaultUpkeep,FixedMaturityVaultUpkeep,CoreVaultLens,SelectorLib/SelectorRegistry,FeeCollectorauto-harvest,DeployTypes, and every deploy script now route exclusively throughEpochedQueueModule.outstandingClaimCountnow persists across epoch closes instead of resetting with per-epochclaimCount), added anoldestUnfundedEpochIdkeeper cursor, converted internal self-calls to direct interface calls, and added a new dedicated unit suite (test/unit/core/EpochedQueueModule.t.sol).QueueModule's API were ported to the epoch model, including redesigns of the stateful invariant/fuzz suites (CoreVault_ClaimsQueue_Invariants,CoreVault_System_Invariants,CoreVault_Adversarial_Invariants) and the heaviest integration/stress suites (ExitEngine_StressTest,ExitEngine_ForkSuite,ExitEngine_AuditEdgeCases,CoreEngine_Integration_Hardening) — not mechanical renames, but genuine close→fund→claim sequencing in place of the old push-settle model.ExitEngineLib'sQueueStorage-takingcalculateCapRemainingoverload, stray imports).QueueStorage.solitself is kept — unused — purely as a permanently-reserved EIP-7201 slot, since repurposing a namespaced slot that may have held live data is unsafe regardless of whether the owning contract is still deployed.queue-mechanics.mdrewritten from scratch for the epoch model;modules.md,architecture.md,exit-engine.md,audit-scope.mdbrought back in sync.Test plan
forge buildclean (0 errors)forge test: 773/773 passingfundEpoch()cost across queue depths 100/500/1000