Skip to content

feat: cut production over to EpochedQueueModule (Renzo-style epoch qu… - #13

Merged
stefanobotticelli merged 23 commits into
mainfrom
kpi4/epochedqueue-cutover
Aug 15, 2026
Merged

feat: cut production over to EpochedQueueModule (Renzo-style epoch qu…#13
stefanobotticelli merged 23 commits into
mainfrom
kpi4/epochedqueue-cutover

Conversation

@kalrashivam

@kalrashivam kalrashivam commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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) and EpochedQueueModule (built in an earlier sprint, modeled on Renzo ezETH's withdrawal queue, but never wired into production). This PR retires QueueModule and makes EpochedQueueModule the sole queue-settlement mechanism.

Why epoch-batched over the traditional FIFO queue:

  • O(1) liquidity pull per epoch, not O(queue depth) per keeper call. QueueModule's settleFeesAndProcessQueue scanned and settled claims one at a time, so keeper gas scaled with how many claims were pending. EpochedQueueModule batches all claims submitted in a window into one epoch; a single fundEpoch() 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.
  • No live-PPS MEV window. The old queue priced each claim at whatever PPS was live when the keeper's scan reached it — a sophisticated keeper could reorder or time settlement to advantage. The epoch model locks one price (ppsAtClose) for every claim in an epoch, once, at closeCurrentEpoch(). Verified against a share-price-collapse scenario: claims settle correctly at the post-collapse price, not a stale pre-collapse one.
  • No head-of-line blocking. In the FIFO model, one claim stuck behind insufficient liquidity stalled every claim behind it in the array. Epoch claims are independent once funded — pull-based claimEpochAssets() means one user's claim never blocks another's.
  • No keeper dependency for users to get paid. The old design required a keeper to eventually reach a user's claim in the scan. Once an epoch is Funded, any user can self-serve their claim immediately — no keeper action required on the payout side, only on close/fund.
  • Business context: the vault is expected to see roughly 20–50 exits/week, which favors batching claims into periodic epochs over per-claim FIFO processing.

The tradeoff we accepted: EpochedQueueModule drops QueueModule'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 in docs/exit-engine.md §9 as a deliberate, not accidental, simplification.

What changed

  • Production cutover: CoreVault, VaultUpkeep, FixedMaturityVaultUpkeep, CoreVaultLens, SelectorLib/SelectorRegistry, FeeCollector auto-harvest, DeployTypes, and every deploy script now route exclusively through EpochedQueueModule.
  • Hardening: fixed a dynamic-cap bypass bug (outstandingClaimCount now persists across epoch closes instead of resetting with per-epoch claimCount), added an oldestUnfundedEpochId keeper cursor, converted internal self-calls to direct interface calls, and added a new dedicated unit suite (test/unit/core/EpochedQueueModule.t.sol).
  • Full test migration: all ~40 test files that exercised 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.
  • QueueModule.sol deleted. Dead dependents cleaned up too (ExitEngineLib's QueueStorage-taking calculateCapRemaining overload, stray imports). QueueStorage.sol itself 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.
  • Docs: queue-mechanics.md rewritten from scratch for the epoch model; modules.md, architecture.md, exit-engine.md, audit-scope.md brought back in sync.

Test plan

  • forge build clean (0 errors)
  • forge test: 773/773 passing
  • Invariant suites re-run at 200 runs × 150 depth: all green
  • Gas characterization test confirms flat fundEpoch() cost across queue depths 100/500/1000

shivam kalra and others added 6 commits August 13, 2026 03:49
…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.
shivam kalra and others added 17 commits August 15, 2026 00:20
  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
stefanobotticelli merged commit ab51167 into main Aug 15, 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.

2 participants