Repository navigation
Add claim upkeep - #20
Merged
Merged
Conversation
…uest Implements "Withdrawal Model — Economic Exit at Request". Once a standard or instant withdrawal request is accepted, the user is a creditor with a fixed claim. The epoch no longer prices anything; it is only a settlement bucket. Core changes - _crystallizeExit: single shared helper for standard + instant requests. Order: solvency check -> NAV validity gate -> fee split -> assetsOwed = convertToAssets(net) (rounded down) -> fee shares to FeeCollector -> burn net shares -> totalOwed += assetsOwed. Instant fallback stores the same assetsOwed; never re-prices or re-applies a fee. - Accounting primitives on CoreVault (single source of truth): grossAssets(), totalOwed(), liabilityIndex() (pure, unstored), isInsolvent(), liabilityState(). totalAssets() = max(0, gross - totalOwed), saturating. reservedForClaims only earmarks liquidity and is not subtracted from NAV. - Removed: cancelEpochWithdrawal, ppsAtClose, escrowedShares, closedPendingAssets, totalEscrowedShares, withdrawal minimum (ClaimTooSmall). - Claims pay assetsOwed x liabilityIndex; each claim releases only its own epoch's earmark. - Insolvency (gross < owed): deposits/mints/new requests revert; close, fund and claims continue. fundEpoch funds at nominal x index (not the impossible nominal) so claims pay the same recovery ratio in any order. Events: InsolvencyEntered/Exited, syncInsolvencyState(). - Insolvency funding tolerates <=10 bps shortfall (insolvency only): found on the Arbitrum fork, real lending adapters cannot return the last wei of a position. - NAV validity gate (CoreVault.navStatus, StrategyRouter.navValidity): a request is rejected unless the warm cache is complete and <=15 min old (age tested explicitly), every enabled strategy's valuation is readable and OK in the health registry, and any configured oracle is live/fresh/consistent. No soft refresh. - Class-A consumers (BufferManager, StrategyRouter, LiquidityOps, VaultUpkeep, lens) now read grossAssets(); checkOracleFreshness body moved to an internal function to keep StrategyRouter under EIP-170. - Zero-equity (gross == owed) blocks deposits/mints/requests like insolvency; forceWithdrawAll returns 0 without burning when totalAssets() == 0. Deviations from the spec (need sign-off, see docs/economic-exit.md section 4) - _crystallizeExit takes an extra ExitMode parameter for the fee tier. - An instant request that falls back to the queue pays the instant fee tier. - Instant cap is measured on the net assetsOwed. - Funding at nominal x index instead of nominal (spec 7.2) — the spec's nominal target strands the last epoch in insolvency. Tests - EconomicExit_Spec (W-1..W-15 + section 11 scenarios), EconomicExit_Invariants (stateful), AdapterHack_Scenarios (adapter-loss timing cases, 30-vs-50 owed), EconomicExit_ArbitrumFork (17 tests + real Fluid adapter hack, real oracle, health registry, warm adapters). - ~25 existing test files migrated to the new model; cancel/minClaimAmount tests replaced. MockBufferManagerForTests gains an always-fresh mode. Docs: docs/economic-exit.md (consumer audit, deviations, deployment plan for a new CoreVault, live-system findings). Older docs carry a "superseded" banner. Requires a NEW CoreVault deployment (totalAssets lives in the non-proxied shell). Live finding: BufferManager.navRefreshInterval is 6h vs the 15-min gate; keeper cadence (I-10) must be fixed before go-live.
EpochedQueueModule.fundEpoch(): stop stranding a solvent epoch on adapter withdrawal slippage. It previously asked strategies for exactly the deficit, so each retry shrank the residue by the same factor and stalled a unit or two short (a 1-unit ask can return 0). It now asks for deficit + 0.5% buffer + 1 (STRATEGY_REDEEM_BUFFER_BPS), floored at MIN_STRATEGY_REDEEM (10,000 units) so a tiny ask's rounding can't look like a loss-cap breach and revert the whole redeem. Beyond the router's own loss cap, funding still refuses atomically and stays Closed until governance raises the cap or uses emergencyRedeemBatch — unchanged, documented behavior, not papered over. New tests, run against the rebased fix_withdraw_request branch: - AdapterShortfall.t.sol (7 tests): a real StrategyRouter + a strategy that pays a fixed % short (not a hack — slippage/rounding). Covers: funds in one call within the buffer; retries converge to the last unit beyond it; beyond the loss cap the router refuses atomically and unblocks via cap raise or emergency redeem; insolvent + short-paying together still converges to the right recovery ratio; two claimants both paid nominal while solvent. - EconomicExit_Gaps.t.sol (12 tests): the NAV gate closing during a hack doesn't trap users (force exit still pays; accepted claims still settle); fuzzed claim pro-rata across any loss size and claim order (256 runs); the top-up-on-recovery path reverts InsufficientFreeLiquidity rather than paying short, then pays full once liquidity returns; perf-fee crystallization is a clean no-op in insolvency; deposits work again after recovery at the recovered price; 25 dust claims in deep insolvency drain totalOwed/reservedForClaims/outstandingClaimCount to exactly zero; two successive hacks across funded and unfunded epochs settle correctly in mixed claim order; four adversarial cases (donation-before-exit is a loss for the donor, front-run of a known loss pays the fee, 150 dust requests don't block real claims, a top-up race never pays a later claimant short). - EconomicExit_ArbitrumFork.t.sol (+1 test): a real strategy whose withdraw()/withdrawAll() start reverting (paused/hacked market) makes fundEpoch() fail safe — epoch stays Closed, nothing earmarked, claim reverts EpochNotFunded — and recovers on its own once the strategy works again, realising through the real router. Full suite incl. Arbitrum fork tests: 1002/1003 passing. The one failure, test_Isolation_Euler, is pre-existing and unrelated (live Euler market headroom at the fork's "latest" block).
…ng gaps Second review round (Stefano + Pier) on the economic-exit branch. Fixes everything raised except one open product decision (documented, not implemented — see below). EpochedQueueModule - _crystallizeExit: soft-refresh the warm NAV before the strict freshness gate (same pattern as deposit/mint), instead of only ever reverting on a cache nobody happened to poke recently. Every existing stale-NAV test now forces the refresh itself to fail, so the fix can't make them unreachable. - _crystallizeExit: revert ZeroAmount when assetsOwed rounds to 0. Not a withdrawal minimum -- stops a worthless claim from occupying a slot in outstandingClaimCount, the dynamic-cap queue-depth signal. - requestInstantWithdrawal: decide the fee tier BEFORE crystallizing, via a conservative pre-fee gross estimate against cap and liquidity, then crystallize exactly once. A fallback into the queue now settles at the standard fee, never the instant one. - requestInstantWithdrawal / requestEpochWithdrawal: deposit lock is now a hard revert (DepositLockActive) on both paths, checked before any crystallization. Previously only the instant path consulted it, and only as a soft "fall back to the queue" signal -- since a request crystallizes regardless of path, that silently let a locked user's shares be burned during the lock window. Force exit remains the deliberate bypass. - requestInstantWithdrawal: instant cap base is now snapshotted at cap-epoch rollover (CoreStorage.capBaseSnapshot) instead of read live, so standard-queue activity (which shrinks totalAssets() via totalOwed) cannot shrink the supposedly-independent instant bucket mid-epoch. - _canInstant: liquidity waterfall is hot -> one warm refill attempt -> queue fallback, never a strategy redeem, matching the buffer architecture. - fundEpoch: now tops up an already-Funded epoch when liabilityIndex has recovered past what's earmarked, via the same pull waterfall as a first-time fund. Previously fundEpoch on a Funded epoch was an unconditional no-op, so a recovered claim could revert InsufficientFreeLiquidity forever with no permissionless way to add the extra cash in. - MIN_STRATEGY_REDEEM replaced with _minStrategyRedeem(), derived from the asset's decimals instead of hardcoded to 6. CoreStorage / CoreVault / lens - Single MAX_WARM_NAV_AGE constant (CoreStorage), sourced by CoreVault, ERC4626Module and EpochedQueueModule instead of three separate literals. - New capBaseSnapshot field + CoreVault.capBaseSnapshot() getter. - CoreVaultLens.calculateCapImmediateRemaining reads the same snapshot (falling back to live totalAssets() only if nothing has been snapshotted yet) so the preview matches what's actually enforced. Params / config - IParamsProvider.WithdrawalParams.minClaimAmount and GlobalConfig.WithdrawalConfig.minClaimAmount marked @dev DEPRECATED (unused for exits under spec §6.4) rather than removed -- 16 deploy/ops files, including a dedicated SetMinClaimAmount.s.sol, read or assert on it; removal is a follow-up PR that touches that tooling. Tests - New: W-12 fallback-pays-standard-fee proof, dust-tolerance exact-drift test, five-claimant insolvency pro-rata, real-router + stale-oracle + scarce-hot independence test, live deposit-lock fork test. - Rewrote every test that used lockPeriod as a trick to force the instant-fallback branch (four of them) -- that trick no longer works now that lock hard-reverts; switched to cap exhaustion or a drained hot balance instead. - Rewrote the stale-NAV tests (unit + fork) to force the refresh itself to fail, for the same reason. - Fork test helper: deposits now clear the live 1-day deposit lock (warp + re-refresh NAV) so existing scenarios aren't accidentally testing lock behaviour; added a dedicated lock test using the real, unmodified state. - EpochedQueueModule's internal size-gate target raised 16KB -> 20KB (measured 16,915B; ~7.6KB of margin below the real EIP-170 limit) -- the module's scope grew well past "small, stateless" this round. Docs (docs/economic-exit.md §8) - Full writeup of every fix above, plus the one item NOT fixed: Pier's creditor-parity point (a claimant paid during a dip is worse off than one who waits out a later recovery, even though W-14 still holds at any single instant). Traced and confirmed real; two possible resolutions scoped (cohort-crystallized recovery vs. per-claim residual entitlement) but not decided here -- flagged for Multyr. - §8.3: 5 fork tests + the pre-existing, unrelated test_Isolation_Euler fail only on the free public RPC's archive-state limits when the real UsdcMultiLendingVault strategy rebalances across its own sub-adapters mid-withdrawal -- traced with -vvvv, confirmed outside src/ entirely. Full suite: 1002/1008 passing; all 6 failures are the RPC limitation above.
…e remaining instant-cap coupling Resolves the two items from PR #19's second-round review reply (Option A for creditor parity, and the cap-coupling follow-up). Option A — crystallized recovery ratio per insolvency cohort -------------------------------------------------------------- - EpochData gains `recoveryIndex` (WAD), set exactly once when fundEpoch() first transitions a cohort Closed -> Funded, after its realize waterfall (hot -> warm -> strategy) has run: reserve/realize first, crystallize second, not at the first block grossAssets < totalOwed is observed. - The haircut, if any, is written out of totalOwed in the same instant (EpochRecoveryCrystallized), so an already-settled cohort stops inflating totalOwed/NAV/isInsolvent() for as long as it happens to stay unclaimed. - claimEpochAssets/batchClaimEpochAssets pay assetsOwed * epoch.recoveryIndex, never the live cross-epoch liabilityIndex(). recoveryIndex is immutable: fundEpoch() on an already-Funded epoch is a pure no-op again (the top-up- on-recovery path from the prior round is removed — it directly contradicted finality). A recovery after crystallization flows to remaining shareholders instead of the settled cohort. Cap coupling, fully closed --------------------------- - _epochCapRemaining()/CoreVaultLens no longer read outstandingClaimCount at all: standard queue depth now has zero effect on the instant bucket (an enabled DynamicCapParams just pins the cap at maxBps). - New permissionless rollCapEpochIfNeeded() lets a keeper snapshot capBaseSnapshot right at the cap-epoch boundary, instead of it being captured lazily by whichever instant withdrawal happens to arrive first (which standard-queue activity could already have shrunk). Audit fixes ----------- - CoreVaultLens: fixed a lens/module dynamic-cap sentinel mismatch (0 vs type(uint16).max for "unlimited") that could make the lens report zero instant capacity while the contract itself allowed unlimited withdrawals. - Formally deprecated DynamicCapParams/DynamicCapConfig (@dev DEAD / DEPRECATED, same treatment as minClaimAmount): its only remaining signal is gone, so an enabled config no longer scales anything toward minBps. Selectors: +rollCapEpochIfNeeded (QUEUE_MODULE_SELECTORS 9 -> 10). Tests updated for the new per-cohort semantics; full non-fork suite green (979/979). docs/economic-exit.md §8 updated to record both resolutions.
…tion contract Enables auto-claim without requiring every user to submit their own transaction, while leaving self-claim as the permissionless fallback. EpochedQueueModule ------------------- - New keeperSettleClaims(epochId, claimIds): pays each claim directly to claim.user regardless of caller (unlike claimEpochAssets/ batchClaimEpochAssets, which require msg.sender == claim.user). Atomic per batch, not try/catch-per-claim internally -- swallowing a failed transfer there would let claim.claimed be set without the money moving, a fund-loss bug. A claim settled by either this or the existing self-claim path is a no-op for the other (idempotent on EpochClaim.claimed). - New selector wired (QUEUE_MODULE_SELECTORS 10 -> 11). Automation ---------- - New src/automation/ClaimSettlementUpkeep.sol: a SEPARATE Chainlink- Automation-compatible contract, deliberately not an added Op on VaultUpkeep. VaultUpkeep's checkUpkeep is a strict single-op-per-tick priority chain already tuned across 8 competing ops; claim settlement is a different shape of work (a per-user backlog needing its own pagination cursor across many ticks, not a single vault-wide state flip) that would either starve at the bottom of that chain or destabilize it at the top. - Scans forward from a persistent (epochId, claimId) cursor, bounded by owner-configurable maxClaimsPerUpkeep/maxScanPerUpkeep. A failed performUpkeep (try/catch, same liveness pattern VaultUpkeep already uses) leaves the cursor untouched and retries next tick. - Owner escape hatch (excludeClaim) for a claim whose owner can't receive the asset -- otherwise blocks its whole batch, atomically, on every retry. Test infra fix --------------- - test/helpers/CoreHarness.sol wires module selectors from a hand-maintained list, not from SelectorLib -- it was also missing rollCapEpochIfNeeded (added earlier this branch), so no test had exercised that as a standalone external call until now. Both gaps closed. 12 new tests (test/unit/automation/ClaimSettlementUpkeep.t.sol), incl. the core property (payout goes to claim.user, not the keeper caller), batching across ticks, cursor skipping an unfunded epoch, idempotency against self-claim in both orders, and the exclusion escape hatch. Full suite: 991/991. EpochedQueueModule 17,521/24,576 bytes (EIP-170). Not deployed/registered with Chainlink Automation -- that, and whether to also wire rollCapEpochIfNeeded into an existing keeper, are separate deploy-time decisions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
added 2 commits
September 24, 2026 16:48
…t spec doc Moves cap-epoch bookkeeping into rollCapEpochIfNeeded (ExitEngineLib), which snapshots totalAssets() into capBaseSnapshot on rollover so the withdrawal cap base is fixed at epoch start rather than recomputed against a moving NAV. Retires DynamicCapConfig's stress-throttle fields to plain compatibility storage (GlobalConfig) now that capPerEpochBps is the only supported static cap knob. Rewrites docs/economic-exit.md from PR-description/deviations-log form into a standing reference doc for withdrawal accounting, valuation gates and settlement, closing out the previously open spec decisions. Updates architecture/audit-scope/fee-policy/governance/modules/recovery docs and the unit/integration/fork/invariant suites to match.
- Size unfunded cohorts from free assets and require valid NAV for haircuts - Snapshot caps before mutations, refresh instant pricing, and ignore dynamic caps - Restrict funding shortfalls to one base unit - Prevent cursor manipulation and revisit skipped epochs - Isolate failed claims, back off retries, and respect claim pauses - Handle keeper-settled FeeCollector claims - Add regression coverage and align documentation with current behavior
|
Addressed the funding and claim-settlement review findings in 8e64fc4. Funding and cap handling
Claim automation and FeeCollector
Documentation and validation
These fixes are currently on this PR's branch. The requested CoreHarness change split into #19 has not been performed; PR history has not been rewritten. |
…core into add_claim_upkeep
…aircuts - Add fundedOutstandingClaimCount to EpochedQueueModule (wired through IQueueModule, SelectorLib/SelectorRegistry, CoreHarness) so ClaimSettlementUpkeep can skip checkUpkeep/performUpkeep entirely when there's nothing funded to settle, instead of burning LINK on empty scans. - Have the settlement scan skip Funded epochs that are already fully claimed, so the keeper cursor doesn't stall re-scanning drained epochs. - fundEpoch() now attempts a soft warm-NAV refresh before crystallizing a shortfall, so a stale warm NAV can't lock in an incorrect recovery ratio; a failed refresh leaves the epoch uncrystallized instead. - Add claim-settlement-operations.md and insolvency-runbook.md; update architecture, queue-mechanics, exit-engine, storage-layout, access-control, and economic-exit docs to match. - Extend ClaimSettlementUpkeep and EconomicExit_Gaps test suites to cover the new counter and NAV-refresh paths.
Persist bounded scan progress and sleep until funded claims change, a retry expires, or owner controls reset the scan. Add excluded-claim, backoff, wake-up and wraparound regression tests, and update settlement operations docs.
An idle scan compared only fundedOutstandingClaimCount. When an epoch funds and a claim settles in the same block, that count returns to the recorded value and the newly funded claims stay unswept. Add fundedEpochCount to EpochQueueStorage.Layout, appended after fundedOutstandingClaimCount so no existing slot moves. fundEpoch increments it on each Closed -> Funded transition and it never decreases. Expose it as a public view and wire the selector. ClaimSettlementUpkeep records both counters in its scan pass and wakes or invalidates the pass when either one changes.
stefanobotticelli
changed the base branch from
fix_withdraw_request
to
main
September 24, 2026 18:55
main carries PR #19 as squash commit 3415af7, whose tree is identical to 88f5d5b, the PR #19 head this branch already contains. Every one of the 14 conflicted files resolves to this branch's version: the main side equals 88f5d5b and the branch side adds only PR #20 changes. Four files merged without a conflict marker are restored to this branch's version as well (docs/access-control.md, docs/architecture.md, docs/fee-policy.md, docs/storage-layout.md): the three-way merge against the older merge base re-added document banners that PR #20 removes. The resulting tree is identical to the pre-merge branch tree.
stefanobotticelli
approved these changes
Sep 24, 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.
Auto-claim via a dedicated keeper
Adds keeper-driven claim settlement on top of
fix_withdraw_request's crystallization work.Until now,
claimEpochAssets/batchClaimEpochAssetswere the only way to get paid, and bothrequire
msg.sender == claim.user— a user (or someone holding their key) had to submit theirown transaction. This PR adds an automatic path without removing that one.
What changed
EpochedQueueModule.keeperSettleClaims(epochId, claimIds)— new function, pays each claimdirectly to
claim.userregardless of who calls it. Self-claim is untouched and remains thepermissionless fallback if no keeper is run, or one lags; a claim settled by either path is a
no-op for the other (idempotent on
EpochClaim.claimed).Deliberately atomic — not try/catch-per-claim internally. Swallowing a failed transfer inside the
loop would let
claim.claimedget set without the money actually moving, which is a fund-lossbug, not a liveness nicety. If any claim in a batch fails, the whole batch reverts; the automation
layer (below) is where liveness/retry belongs.
src/automation/ClaimSettlementUpkeep.sol— new, separate Chainlink-Automation-compatiblecontract. Deliberately not an added
Opon the existingVaultUpkeep:VaultUpkeep.checkUpkeepis a strict single-op-per-tick priority chain (EPOCH_FUND > EPOCH_CLOSE > RECONCILE > CRYSTALLIZE > REBALANCE > STRATEGY_REBALANCE > DEPLOY > REALIZE), eachtier already cooldown/starvation-tuned against the others. Claim settlement is a different shape
of work — not one vault-wide state flip but a per-user backlog needing its own pagination cursor
across many ticks. Slotting it into that chain would either starve it (low priority, rarely
reached on an active vault) or destabilize the existing tuning (high priority). A separate
contract gets its own Chainlink registration and cadence instead, and doesn't touch
VaultUpkeepat all.
(epochId, claimId)cursor, bounded by owner-configurablemaxClaimsPerUpkeep(default 20) /maxScanPerUpkeep(default 200, so a long run ofalready-claimed IDs can't blow the gas budget).
performUpkeepfailure (try/catch — the same liveness patternVaultUpkeepalready uses)leaves the cursor untouched; it retries the same batch next tick.
excludeClaim(epochId, claimId, bool): a claim whose owner can't receivethe asset would otherwise block its whole batch forever, atomically, on every retry. Excluding
it only affects this keeper's scan — the user can still self-claim if their address later works.
Test infra fix:
test/helpers/CoreHarness.solwires module selectors from a hand-maintainedlist, not from
SelectorLib— it was also missingrollCapEpochIfNeeded(added in the parentbranch), so no test had exercised that as a standalone external call until now. Both gaps closed.
Selectors
QUEUE_MODULE_SELECTORS: 10 → 11 (+keeperSettleClaims).Tests
12 new (
test/unit/automation/ClaimSettlementUpkeep.t.sol):claim.user, never the keeper callerperformUpkeepleaves the cursor in place and retriesFull suite: 991/991 passing.
EpochedQueueModule17,521/24,576 bytes (EIP-170 margin: 7,055).Out of scope / follow-up
rollCapEpochIfNeeded(from the parent branch) still isn't wired into any keeper — separateopen decision, not addressed here.