diff --git a/docs/access-control.md b/docs/access-control.md index e8184d4..93a69b0 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -83,7 +83,7 @@ bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE"); bytes32 public constant PARAM_ROLE = keccak256("PARAM_ROLE"); ``` -This contract uses a `governor` address + OpenZeppelin `AccessControl`-style `bytes32` role constants. It is **NOT** imported by `AdminModule`, `QueueModule`, `ERC4626Module`, or any active module on branch `reorg/runbook-docs-consolidate-01a.3`. The production role system is the `roleOf[selector]` mapping described above. +This contract uses a `governor` address + OpenZeppelin `AccessControl`-style `bytes32` role constants. It is **NOT** imported by `AdminModule`, `EpochedQueueModule`, `ERC4626Module`, or any active module. The production role system is the `roleOf[selector]` mapping described above. --- @@ -184,9 +184,13 @@ Key permissionless functions: | Function | Module | Notes | |----------|--------|-------| | `deposit` | ERC4626Module | Subject to pause checks | -| `requestClaim` | QueueModule | Subject to pause + anti-spam checks | -| `settleFeesAndProcessQueue` | QueueModule | Callable by anyone (keeper pattern) | -| `compactQueue` | QueueModule | Cleanup, always callable | +| `requestEpochWithdrawal` | EpochedQueueModule | Subject to pause + `minClaimAmount` floor | +| `requestInstantWithdrawal` | EpochedQueueModule | Same floor; falls back to the queue when the cap or free liquidity blocks it | +| `cancelEpochWithdrawal` | EpochedQueueModule | Claim owner only, and only while the epoch is `Open` | +| `closeCurrentEpoch` | EpochedQueueModule | Callable by anyone once the epoch duration has elapsed (keeper pattern) | +| `fundEpoch` | EpochedQueueModule | Callable by anyone; no-op when the epoch is already funded | +| `claimEpochAssets` / `batchClaimEpochAssets` | EpochedQueueModule | Claim owner only, self-service, no keeper required | +| `syncOldestUnfundedEpoch` | EpochedQueueModule | Cursor maintenance, always callable | | `acceptOwnership` | AdminModule | Must be `pendingOwner` (checked internally) | | `markMatured` | FixedMaturityModule | Any address, once maturityTs reached | | `markFundingFailed` | FixedMaturityModule | Any address, once deadline + net < min | @@ -245,7 +249,7 @@ struct Layout { These functions bypass normal ERC-20 transfer logic and directly adjust share balances. They are used by: -- **QueueModule** — burns user shares during queue settlement, mints fee shares to `feeCollector` +- **EpochedQueueModule** — burns escrowed shares as each claim is paid, transfers fee shares to `feeCollector` in one batch at epoch close, mints perf-fee shares on crystallization - **FixedMaturityModule** — mints/burns during lifecycle transitions (e.g., `_applyFinalPerformanceFee`) Access check: diff --git a/docs/architecture.md b/docs/architecture.md index 8ff799c..3a7ff3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ The core protocol is designed with three guiding principles: **Non-custodial**: At no point does the protocol hold user funds outside of the vault smart contracts. All assets flow between the vault's hot buffer, warm adapters, and strategy adapters under deterministic, publicly-verifiable rules. -**Queued exits**: Standard withdrawals and redemptions are non-atomic. `withdraw()` and `redeem()` always revert. Users submit exit requests via `requestClaim()`, which either settles instantly (if epoch cap allows) or enters a FIFO queue settled by keepers. This design eliminates the synchronous MEV attack surface present in standard ERC-4626 vaults. +**Queued exits**: Standard withdrawals and redemptions are non-atomic. `withdraw()` and `redeem()` always revert. Users submit exit requests via `requestInstantWithdrawal()` (cap-eligible fast path) or `requestEpochWithdrawal()`, which enters an epoch-bucketed queue settled via close → fund → pull-claim (see §6.4). This design eliminates the synchronous MEV attack surface present in standard ERC-4626 vaults. **Modular logic (Diamond-lite)**: All economic logic — deposits, exits, fee crystallization, admin governance — is implemented in separate module contracts. `CoreVault` delegates every call to the appropriate module via `delegatecall`. This permits module-level upgrades (with timelock) without redeploying the vault. @@ -54,7 +54,7 @@ graph TD CV["CoreVault (ERC4626 entry)"] SR["SelectorRegistry\n(immutable, no storage)"] EM["ERC4626Module\n(deposit/mint/forceWithdraw)"] - QM["QueueModule\n(requestClaim/settle/crystallize)"] + QM["EpochedQueueModule\n(requestEpochWithdrawal/close/fund/claim/crystallize)"] AM["AdminModule\n(timelock governance)"] LOM["LiquidityOpsModule\n(deploy/realize/rebalance)"] FM["FixedMaturityModule\n(FM lifecycle)"] @@ -185,7 +185,7 @@ The routing table is populated during deployment by `setModulesBatch()`. Typical | Selector Group | Module | Role | |---|---|---| | `deposit`, `mint`, `redeem`, `withdraw`, `forceWithdraw` | ERC4626Module | ROLE_PUBLIC | -| `requestClaim`, `cancelClaim`, `settleFeesAndProcessQueue`, `compactQueue` | QueueModule | ROLE_PUBLIC | +| `requestEpochWithdrawal`, `cancelEpochWithdrawal`, `closeCurrentEpoch`, `fundEpoch`, `claimEpochAssets`, `requestInstantWithdrawal` | EpochedQueueModule | ROLE_PUBLIC | | `submitFeeParams`, `acceptFeeParams`, `setParams`, `setRouter`, ... | AdminModule | ROLE_OWNER | | `setVaultModeFixedMaturity`, `configureFixedMaturity`, `startFixedMaturityCycle` | FixedMaturityModule | ROLE_OWNER | | `markMatured`, `refundClaim`, `autoCloseFunding` | FixedMaturityModule | ROLE_PUBLIC | @@ -204,7 +204,7 @@ function processorSpendAllowance(address owner_, address spender, uint256 amount ``` `_requireModuleAccess()` (`src/core/CoreVault.sol:673-679`) authorizes two patterns: -1. **Delegatecall context**: `msg.sender == address(this)` — QueueModule, AdminModule, LiquidityOpsModule call these from within delegatecall, so `msg.sender` is the vault itself. +1. **Delegatecall context**: `msg.sender == address(this)` — EpochedQueueModule, AdminModule, LiquidityOpsModule call these from within delegatecall, so `msg.sender` is the vault itself. 2. **Authorized external module**: `isAuthorizedModule[msg.sender]` — ERC4626Module executes as an external call (not delegatecall), so it must be registered via `authorizeModule()`. --- @@ -321,48 +321,62 @@ The protocol defines three exit modes (`src/core/libraries/ExitEngineLib.sol:28- ```solidity enum ExitMode { - STANDARD, // requestClaim(false) — witBps only - INSTANT, // requestClaim(true) — witBps + immediateExitPenaltyBps + STANDARD, // requestEpochWithdrawal() — witBps only + INSTANT, // requestInstantWithdrawal() — witBps + immediateExitPenaltyBps FORCE // forceWithdraw() — witBps + forceExitPenaltyBps } ``` | Mode | Path | Epoch Cap | Lock Period | Fee | |---|---|---|---|---| -| STANDARD | `requestClaim(false)` | Not consumed | Enforced | `witBps` | -| INSTANT | `requestClaim(true)` | Consumed | Enforced | `witBps + immediateExitPenaltyBps` | +| STANDARD | `requestEpochWithdrawal()` | Not consumed | Enforced | `witBps` | +| INSTANT | `requestInstantWithdrawal()` | Consumed | Enforced | `witBps + immediateExitPenaltyBps` | | FORCE | `forceWithdraw()` | Not consumed | Bypassed | `witBps + forceExitPenaltyBps` | -### 6.3 requestClaim() — INSTANT vs QUEUED +### 6.3 requestInstantWithdrawal() / requestEpochWithdrawal() — INSTANT vs QUEUED -`requestClaim(bool immediate, uint256 shares)` (`src/core/modules/QueueModule.sol:81-169`) is the primary exit function. Its behavior depends on the `immediate` parameter and three conditions: +`requestInstantWithdrawal(uint256 shares)` (`src/core/modules/EpochedQueueModule.sol:698-749`) is the fast-path exit function; `requestEpochWithdrawal(uint256 shares)` (`:212-289`) is the explicit queued path. Instant settlement depends on three conditions checked by `_canInstant()`: -**INSTANT settlement conditions** (`src/core/modules/QueueModule.sol:529-553`): +**INSTANT settlement conditions** (`src/core/modules/EpochedQueueModule.sol:917-945`): 1. Lock period has passed: `block.timestamp >= lastDepositTs[user] + lockPeriod` -2. Epoch cap not exhausted: `grossAssets <= calculateCapRemaining()` +2. Epoch cap not exhausted: `grossAssets <= _epochCapRemaining()` 3. Sufficient hot liquidity: `hot >= grossAssets` -If all three conditions are met and `immediate == true`, settlement is atomic in the same transaction: +If all three conditions are met, `requestInstantWithdrawal` settles atomically in the same transaction: 1. Fee shares transferred to `feeCollector` (TRANSFER, not mint — no dilution). -2. User shares burned via `processorBurn`. +2. User shares burned. 3. Net assets transferred to user. 4. Epoch cap consumed via `ExitEngineLib.consumeEpochCap()`. - -If any condition fails (or `immediate == false`), the claim is queued: -1. Shares transferred to vault as escrow. -2. `QueueStorage.Claim` created with `immediate = false` regardless of the caller's preference (fallback INSTANT→QUEUED drops the epoch cap flag). -3. Claim ID assigned and pushed to `queue[]`. - -### 6.4 Queue Processing - -The queue is a FIFO array of claim IDs (`QueueStorage.Layout.queue`). Settlement is triggered by `settleFeesAndProcessQueue(maxClaims)` (keeper) or `processQueuedRedemptions(maxClaims)` (public). - -Settlement algorithm (`src/core/modules/QueueModule.sol:358-521`): -1. **Bounded pre-scan**: scan up to `maxClaims * 2` entries, stop after 32 consecutive ineligible claims. -2. **Warm refill**: attempt `BufferManager.refill()` if hot < required. -3. **Settle loop**: iterate `[head, scanWindowEnd)`, settle eligible claims at cached PPS snapshot. - -Deterministic pricing: `cachedTA` and `cachedTS` (totalAssets, totalSupply) are snapshotted once per `settleFeesAndProcessQueue` call. All claims in the batch use the same PPS, preventing intra-batch arbitrage. +5. Returns `(settledImmediately=true, epochId=0, claimId=0)`. + +If any condition fails, `requestInstantWithdrawal` falls back to the exact same path as +`requestEpochWithdrawal` — the claim is queued into the current open epoch: +1. ALL gross shares transferred to the vault as escrow (not just the net portion). +2. An `EpochClaim` is recorded under `(currentEpochId, claimId)`, `claimed = false`. +3. Returns `(settledImmediately=false, epochId, claimId)` — the caller needs this pair to + later cancel (`cancelEpochWithdrawal`) or claim (`claimEpochAssets`) it. + +### 6.4 Queue Processing — Epoch Close, Fund, Claim + +The queue is epoch-bucketed, not a flat FIFO array: claims submitted while an epoch is open +share one locked price and one liquidity pull. Settlement is a three-step, epoch-wide process +(`src/core/modules/EpochedQueueModule.sol:327-513`) — see `docs/queue-mechanics.md` for the +full state machine: + +1. **`closeCurrentEpoch()`** (permissionless, gated on a minimum epoch duration): locks + `ppsAtClose = totalAssets/totalSupply` for every claim in the epoch, batch-transfers + accumulated fee shares to `feeCollector`, and opens the next epoch immediately. +2. **`fundEpoch(epochId)`** (permissionless, repeatable): pulls liquidity for the epoch's + *entire* net liability in one call — warm refill first, then strategy redeem for any + remaining gap. Transitions to `Funded` only once `hot >= totalNetAssets`. +3. **`claimEpochAssets(epochId, claimId)`** (pull-based, per claimant): once `Funded`, each + user calls in to receive their `netShares * ppsAtClose` — no keeper required. + +Deterministic pricing: `ppsAtClose` is snapshotted once per epoch at `closeCurrentEpoch()` — +every claim in that epoch, whenever it's actually claimed, uses that same price. This removes +the live-PPS MEV window that existed in the old per-batch-snapshot design (a batch's price +could still be influenced by transactions between batches; an epoch's price is fixed the +moment it closes and never revisited). ### 6.5 forceWithdraw — Guaranteed Exit @@ -388,7 +402,7 @@ Four fee parameters are stored in `FeeStorage.InternalFeeParams` (`src/core/stor |---|---|---| | `depBps` | uint16 | Deposit — deducted from deposited assets | | `witBps` | uint16 | All exits — base withdrawal fee | -| `immediateExitPenaltyBps` | uint16 | Instant exits (`requestClaim(true)`) — additive | +| `immediateExitPenaltyBps` | uint16 | Instant exits (`requestInstantWithdrawal()`) — additive | | `forceExitPenaltyBps` | uint16 | Force exits (`forceWithdraw`) — additive | All values are in basis points (1 bp = 0.01%). Maximum values are capped by `GlobalConfig` via `IParamsProvider` (governance-configurable, not hardcoded). @@ -429,7 +443,7 @@ feeAssets = profit * perfRateX feeShares = convertToShares(feeAssets) ``` -Source: `src/core/modules/QueueModule.sol:763-803`. Parameters: +Source: `src/core/modules/EpochedQueueModule.sol:576-653`. Parameters: - `perfRateX`: scaled performance fee rate (`FeeStorage.Layout.perfRateX`). - `highWaterMark`: PPS at last crystallization (`FeeStorage.Layout.highWaterMark`). - `minCrystallizeInterval`: minimum time between crystallizations. @@ -546,8 +560,8 @@ Operations are gated by the current state via free functions in `src/core/storag | Operation | Allowed in states | |---|---| | `deposit()` | `Funding` (OpenEnded: always) | -| `requestClaim()` | `Matured` (OpenEnded: always) | -| `settleFeesAndProcessQueue()` | `Matured` (OpenEnded: always) | +| `requestEpochWithdrawal()` / `requestInstantWithdrawal()` | `Matured` (OpenEnded: always) | +| `closeCurrentEpoch()` | `Matured` (OpenEnded: always) | | `forceWithdraw()` | `Active` (OpenEnded: always) | | `deployToStrategies()` | OpenEnded only | @@ -677,10 +691,13 @@ This table covers the 30 most important functions. For the complete selector reg | `redeem(...)` | ERC4626Module | PUBLIC | — | `AsyncWithdrawalRequired` (always) | | `forceWithdraw(...)` | ERC4626Module | PUBLIC | `ForceWithdrawExecuted`, `ForceExit` | `Paused`, `ZeroAmount`, `EmptyPlan`, `InsufficientLiquidity` | | `forceWithdrawAll(address,uint256)` | ERC4626Module | PUBLIC | `ForceWithdrawAllExecuted`, `ForceExit` | `Paused`, `ZeroAmount`, `SlippageExceeded` (F-03) | -| `requestClaim(bool,uint256)` | QueueModule | PUBLIC | `ClaimRequested`, `InstantExit` or `ClaimQueued` | `ZeroAmount`, `ClaimTooSmall`, `ClaimCooldownActive` | -| `cancelClaim(uint256)` | QueueModule | PUBLIC | `ClaimCancelled`, `SharesUnfrozen` | `NotClaimOwner`, `AlreadySettled` | -| `settleFeesAndProcessQueue(uint256)` | QueueModule | PUBLIC | `ClaimSettled`, `EpochRolled`, `VaultPpsSnapshot` | `ZeroAmount` | -| `endEpochCrystallize()` | QueueModule | PUBLIC | `Crystallized`, `PerfFeeMinted`, `NavSmoothUpdated` | — | +| `requestInstantWithdrawal(uint256)` | EpochedQueueModule | PUBLIC | `InstantExit` or `EpochWithdrawalRequested` | `ZeroAmount` | +| `requestEpochWithdrawal(uint256)` | EpochedQueueModule | PUBLIC | `EpochWithdrawalRequested` | `ZeroAmount`, `EpochNotOpen` | +| `cancelEpochWithdrawal(uint256,uint256)` | EpochedQueueModule | PUBLIC | `EpochWithdrawalCancelled` | `NotClaimOwner`, `ClaimAlreadySettled` | +| `closeCurrentEpoch()` | EpochedQueueModule | PUBLIC | `EpochClosed`, `EpochOpened`, `FeePaid` | `EpochNotOpen`, `EpochTooYoung` | +| `fundEpoch(uint256)` | EpochedQueueModule | PUBLIC | `EpochFundAttempt`, `EpochFunded` | `EpochNotClosed`, `EpochAlreadyFunded` | +| `claimEpochAssets(uint256,uint256)` | EpochedQueueModule | PUBLIC | `EpochAssetsClaimed` | `EpochNotFunded`, `NotClaimOwner`, `ClaimAlreadySettled` | +| `endEpochCrystallize()` | EpochedQueueModule | PUBLIC | `Crystallized`, `PerfFeeMinted`, `NavSmoothUpdated` | — | | `deployToStrategies(...)` | LiquidityOpsModule | PUBLIC | `DeployedToStrategies` | `ReentrancyGuardLocked` | | `realizeForQueue(uint256)` | LiquidityOpsModule | PUBLIC | — | — | | `submitFeeParams(...)` | AdminModule | OWNER | `FeeParamsSubmitted` | `FeeTooHigh`, `PendingParamsNotResolved` | @@ -714,7 +731,7 @@ Invariants enforced by the protocol. For formal verification results see `docs/i | I3 | `epochWithdrawn <= cap` (INSTANT only) | `ExitEngineLib.consumeEpochCap()`, cap check before settle | `test/unit/ExitEngine*.t.sol` | | I4 | `simulateExit == runtime execution` | `ExitEngineLib.simulateExit()` mirrors production formulas | `test/unit/ExitEngine*.t.sol` (parity assertions) | | I5 | `forceWithdraw` does NOT consume epoch cap | `src/core/modules/ERC4626Module.sol:325` (no cap consumption) | `test/unit/ERC4626Module.t.sol` | -| I6 | Fee shares always from owner/escrow via TRANSFER (not mint) | `processorTransfer` in all exit paths | `test/unit/ERC4626Module.t.sol` + `QueueModule.t.sol` | +| I6 | Fee shares always from owner/escrow via TRANSFER (not mint) | `processorTransfer` in all exit paths | `test/unit/ERC4626Module.t.sol` + `test/unit/core/EpochedQueueModule.t.sol` | | I7 | `maxWithdraw(address) == 0` always | `src/core/CoreVault.sol:544-547` (pure) | `test/unit/CoreVault*.t.sol` | | I8 | `maxRedeem(address) == 0` always | `src/core/CoreVault.sol:549-553` (pure) | `test/unit/CoreVault*.t.sol` | | I9 | Deposits blocked when warmNavValid=false | `_depositsAreCurrentlyAllowed()` checks `warmNavState()` | `test/unit/ERC4626Module.t.sol` (warmNav gate) | @@ -733,10 +750,10 @@ All external calls made by the vault system and their safety properties: | `warmNavState()` | `CoreVault._depositsAreCurrentlyAllowed()` | `BufferManager` | ~3K | View-only; no trust needed | | `refreshWarmNav()` | `ERC4626Module._ensureFreshWarmNav()` | `BufferManager` | ~200K | try/catch; non-blocking | | `totalStrategyAssetsSafe()` | `CoreVault._totalAssetsBreakdown()` | `StrategyRouter` | ~50K | Safe view; returns 0 on error | -| `getDepositLimits()`, `getWithdrawalParams()` | `ERC4626Module`, `QueueModule` | `IParamsProvider` | ~5K | View; trust required (admin-set) | +| `getDepositLimits()`, `getWithdrawalParams()` | `ERC4626Module`, `EpochedQueueModule` | `IParamsProvider` | ~5K | View; trust required (admin-set) | | `forceRedeemForWithdraw()` | `ERC4626Module._forcePullAllLiquidity()` | `StrategyRouter` | ~300K | Best-effort; called last | | `executeDepositBatch()` | `LiquidityOpsModule` | `StrategyRouter` | ~500K | Bounded by plan | -| `bm.refill()` | `QueueModule._settleScan()` | `BufferManager` | ~200K best case; up to ~200K × 8 × adapter count worst case[^gas1] | try/catch; non-blocking | +| `bm.refill()` | `EpochedQueueModule.fundEpoch()` | `BufferManager` | ~200K best case; up to ~200K × 8 × adapter count worst case[^gas1] | try/catch; non-blocking | | `bm.forceRefill()` | `ERC4626Module._forcePullAllLiquidity()` | `BufferManager` | ~200K best case; up to ~200K × 8 × adapter count worst case[^gas1] | Best-effort | | `inc.onDeposit()` | `ERC4626Module._notifyIncentivesDeposit()` | `IIncentives` | ~50K | try/catch; non-blocking | | `eng.onDeposit/onExit()` | `ERC4626Module` | `IIncentivesEngine` | ~50K | try/catch; non-blocking | @@ -755,9 +772,9 @@ Before `seedDeadDeposit()` is called, `totalSupply == 0`. In this state `convert If all warm adapters report 0 NAV (freshly deployed or empty), `warmNavState()` returns `(0, ts, true)`. The warm component of totalAssets is 0. Deposits are still admitted if `valid=true` and within TTL. -### 16.3 requestClaim INSTANT fallback to queue +### 16.3 requestInstantWithdrawal fallback to the epoch queue -If an instant claim fails the cap or lock period check, it is queued with `immediate = false` — the epoch cap is NOT pre-consumed. This means the queued claim will be settled as STANDARD (no cap consumption at settlement). This design avoids the scenario where a user reserves cap space by queuing an instant claim. +If an instant withdrawal fails the cap or lock period check, `requestInstantWithdrawal` internally calls the same path as `requestEpochWithdrawal` — the epoch cap is NOT pre-consumed, and the claim settles as STANDARD (no cap consumption) once its epoch is closed and funded. This design avoids the scenario where a user reserves cap space merely by attempting an instant exit. ### 16.4 Oracle staleness (FixedMaturity) @@ -765,7 +782,7 @@ In `FixedMaturity/Active` state, `markMatured()` is callable by anyone once `blo ### 16.5 Max uint values and dust -- Epoch cap: if `capPerEpochBps == 0`, `calculateCapRemaining()` returns `type(uint256).max` (uncapped). Source: `src/core/libraries/ExitEngineLib.sol:129`. +- Epoch cap: if `capPerEpochBps == 0`, `_epochCapRemaining()` returns `type(uint256).max` (uncapped). Source: `src/core/modules/EpochedQueueModule.sol:946-969`. - `maxDeposit(receiver)`: if both vault and user caps are 0, returns `type(uint256).max`. Source: `src/core/CoreVault.sol:555-583`. - Dust: minimum deposit enforced by `DepositBelowMinimum` if `minDepositAmount > 0`. @@ -802,15 +819,15 @@ In `FixedMaturity/Active` state, `markMatured()` is callable by anyone once `blo 9. `safeTransferFrom(caller, address(this), assets)` — USDC pulled 10. Update `lastDepositTs[receiver]` and `_opsNavCache` -**Canonical exit flow** (INSTANT path): -1. User calls `requestClaim(immediate=true, shares)` → QueueModule +**Canonical exit flow** (INSTANT path, cap-eligible): +1. User calls `requestInstantWithdrawal(shares)` → EpochedQueueModule 2. Lock period check: `block.timestamp >= lastDepositTs[user] + lockPeriod` -3. Epoch rollover: `ExitEngineLib.rollEpochIfNeeded()` if `block.timestamp >= epochStart + epochDuration` -4. Cap check: `ExitEngineLib.calculateCapRemaining()` — epoch cap still available +3. Cap-epoch rollover: `ExitEngineLib.rollEpochIfNeeded()` if `block.timestamp >= epochStart + epochDuration` (the CAP epoch — distinct from the settlement epoch, see `docs/queue-mechanics.md` §6) +4. Cap check: `_epochCapRemaining()` — cap-epoch cap still available 5. Fee shares computed: `ExitEngineLib.computeFeeShares(INSTANT, ...)` — rounded UP -6. Fee shares transferred to feeCollector, remaining shares escrowed (transferred to `address(this)`) -7. Claim stored in QueueStorage with `immediate=true` -8. At settlement: assets calculated from escrowed shares, USDC transferred to user +6. Fee shares transferred to feeCollector (not escrowed — paid immediately), user shares burned +7. USDC transferred to user in the same transaction; `consumeEpochCap()` updates the cap epoch +8. If any of steps 2-4 fail instead: ALL gross shares are escrowed and a standard `EpochClaim` is recorded — settled later via `closeCurrentEpoch()` → `fundEpoch()` → `claimEpochAssets()` (pull, by the user) ### Cross-Links @@ -836,5 +853,5 @@ In `FixedMaturity/Active` state, `markMatured()` is callable by anyone once `blo - `docs/09-audit/architecture.md` — auditor expectation coverage **Discrepancies found** (code vs. old source .md): -- [^1]: Some source .md files (pre-dating v9 refactor) describe `redeem()` / `withdraw()` as synchronous exit paths. Code confirms these always revert `AsyncWithdrawalRequired` (ERC4626Module.sol:140-157). Users must use `requestClaim()`. +- [^1]: Some source .md files (pre-dating v9 refactor) describe `redeem()` / `withdraw()` as synchronous exit paths. Code confirms these always revert `AsyncWithdrawalRequired` (ERC4626Module.sol:140-157). Users must use `requestInstantWithdrawal()` / `requestEpochWithdrawal()`. - [^2]: `V10_ENGINE_REPORT.md` treats "V10 Portfolio-Grade Allocation Engine" as a future proposal. The code shows `rebalancePolicy`, `rebalanceGuard`, `executionMemory` fields already present in `CoreStorage.Layout:100-105`, with wiring modules `RouterAllocationPolicy.sol`, `RouterRebalanceGuard.sol`, `src/core/modules/ExecutionMemory.sol` existing in `src/core/modules/`. The feature is partially implemented but optional (strict mode toggleable via `strictExecutionMemory`). diff --git a/docs/audit-scope.md b/docs/audit-scope.md index 4e4a619..789639a 100644 --- a/docs/audit-scope.md +++ b/docs/audit-scope.md @@ -46,7 +46,7 @@ graph TD |--------|------|------| | `CoreVault` | `src/core/CoreVault.sol:36` | Diamond-lite proxy; delegatecalls to modules | | `ERC4626Module` | `src/core/modules/ERC4626Module.sol:166` | Deposit/mint/withdraw/redeem; ERC-4626 compliance | -| `QueueModule` | `src/core/modules/QueueModule.sol:207` | Async withdrawal queue; settlement; anti-spam | +| `EpochedQueueModule` | `src/core/modules/EpochedQueueModule.sol` | Async withdrawal queue; epoch batching; settlement | | `AdminModule` | `src/core/modules/AdminModule.sol:73` | Governance, timelocks, pause, ecosystem wiring | | `LiquidityOpsModule` | `src/core/modules/LiquidityOpsModule.sol:32` | Deploy/rebalance/realize liquidity to/from strategies | | `FeeCollector` | `src/core/modules/FeeCollector.sol:17` | Fee distribution to treasury/ops/safety reserve | @@ -60,7 +60,7 @@ graph TD | `ExitFeeLib` | `src/core/libraries/ExitFeeLib.sol:29` | Fee computation for all 3 exit modes | | `CoreStorage` | `src/core/storage/CoreStorage.sol:38` | EIP-7201 namespaced core storage | | `FeeStorage` | `src/core/storage/FeeStorage.sol:55` | Fee params + perf fee storage | -| `QueueStorage` | `src/core/storage/QueueStorage.sol:24` | Queue head/claims/pendingShares | +| `EpochQueueStorage` | `src/core/modules/EpochedQueueModule.sol:29` | Epoch/claim state, escrowedShares, outstandingClaimCount (retired `QueueStorage.sol` kept only as a reserved EIP-7201 slot, no longer in scope for live logic) | | `BatchGuardrails` | `src/core/modules/BatchGuardrails.sol:20` | Batch call validation (peripheral) | ### 2.2 Core Protocol — FixedMaturity Extension @@ -78,7 +78,7 @@ The FM extension adds state machine governance on top of OpenEnded CoreVault. Th **Minimal guards in existing modules** (not new logic — just early-return gates): - `ERC4626Module.sol` (3 gating calls) -- `QueueModule.sol` (2 gating calls) +- `EpochedQueueModule.sol` (2 gating calls) - `LiquidityOpsModule.sol` (2 gating calls) **Architectural constraint**: FM extension must not modify any OpenEnded execution path. All FM logic is isolated in FM files. @@ -146,7 +146,7 @@ src/core/ │ ├── FeeCollector.sol │ ├── FixedMaturityModule.sol │ ├── LiquidityOpsModule.sol -│ ├── QueueModule.sol +│ ├── EpochedQueueModule.sol │ ├── StrategyHealthRegistry.sol │ ├── StrategyRouter.sol │ └── VaultUpkeep.sol # Keeper (out of scope) @@ -154,7 +154,7 @@ src/core/ ├── CoreStorage.sol # EIP-7201 main ├── FeeStorage.sol # EIP-7201 fee ├── FixedMaturityStorage.sol # EIP-7201 FM - └── QueueStorage.sol # EIP-7201 queue + └── QueueStorage.sol # EIP-7201 -- retired/reserved slot only, EpochQueueStorage lives inside EpochedQueueModule.sol ``` Total: 51 `.sol` files in `src/core/`. @@ -172,8 +172,8 @@ Total: 51 `.sol` files in `src/core/`. | L1 | **Owner key is single point of control** | Design choice | No on-chain DAO. Mitigated by vetoer + timelock. Deployment to multi-sig (Safe) is recommended. | | L2 | **`FLAG_SYSTEM_SEALED` does not freeze `roleOf[selector]`** | Known gap (AC8) | Owner can change per-function roles post-seal. Timelock provides recourse window. | | L3 | **`forceWithdrawAll` is best-effort (F-03: resolved)** | Design choice, mitigated | Delivers `min(hot, targetAssets)` — still no guarantee of full liquidity. Previously this could silently deliver an arbitrarily small fill (up to ~90%+ of value if strategies were frozen/illiquid); now a mandatory `minAssetsOut` parameter reverts the whole call (`SlippageExceeded`, no state change) if the fill falls short. See `test/sprint-test/ForceWithdrawAll_SlippagePOC.t.sol`. | -| L4 | **Settlement loop is partial** | Design choice | Gas safety exit at `gasleft() > 150_000`. Queue resumes in next call. Settlement is not atomic for large queues. | -| L5 | **INSTANT fallback stores `immediate=false`** | Design choice | INSTANT requests that fall back to queue are re-classified as standard queue entries (no epoch cap). Fixed in shadow report (BUG 6). | +| L4 | **Settlement is epoch-wide, pull-based** | Design choice (superseded L4) | `EpochedQueueModule` replaced the retired per-claim FIFO settle loop (gas-bounded `_settleLoop`) with `closeCurrentEpoch()`/`fundEpoch()` (epoch-wide, O(1) in claim count) and `claimEpochAssets()` (pull-based, per user). No gas-safety partial-exit is needed since no single call iterates over claims. | +| L5 | **INSTANT fallback always becomes a standard epoch claim** | Design choice | `requestInstantWithdrawal` requests that fail `_canInstant()` fall back to the exact same code path as `requestEpochWithdrawal` — there is no `immediate` flag on `EpochClaim` to mis-set (the BUG 6 class in the retired `QueueModule` is eliminated by construction, not by a fix). | | L6 | **`preMaturityForceExitPenaltyBps` max 50%** | Design constraint | Hard cap at 5000 bps validated in `configureFixedMaturity`. | | L7 | **`BatchGuardrails.sol` is peripheral** | Design choice | Not part of CoreVault module dispatch. Separate validation layer, not enforced at core level. | | L8 | **`Roles.sol` and `Ownable2StepMixin.sol` are legacy** | Legacy artifact | pragma 0.8.24, NOT imported by active modules. Present in codebase but not deployed. | @@ -184,6 +184,11 @@ Total: 51 `.sol` files in `src/core/`. The Final Shadow Readiness Report (2026-04-10) found 7 bugs during a 1950-test, 10K-operation fork replay. All 7 were fixed before the first audit. Regression tests for each are in `test/unit/core/AuditFix_Regression.t.sol`. +> **Note**: BUG 4, 6, and 7 below refer to `QueueModule.sol`, which has since been deleted and +> replaced by `EpochedQueueModule.sol` (epoch-batched settlement) — see §9 for why the new +> module's settlement path should be treated as fresh audit surface rather than a pure +> regression check against these historical findings. + | Bug | Severity | File fixed | What was wrong | |-----|---------|------------|---------------| | BUG 1 | P0 | `ERC4626Module.sol` | `previewDeposit` returned gross instead of net (OZ non-compliance) | @@ -213,7 +218,7 @@ These are known and accepted before the first audit: | Component | Unit | Invariant | Fuzz | Fork | Halmos | Echidna | |-----------|------|-----------|------|------|--------|---------| | CoreVault deposit/withdraw | ✅ | ✅ SI-1..6 | ✅ | ✅ | — | ✅ | -| QueueModule settlement | ✅ | ✅ QI-1..6 | ✅ | ✅ | — | — | +| EpochedQueueModule settlement | ✅ | ✅ QI-1..6 | ✅ | ✅ | — | — | | AdminModule governance | ✅ | ✅ GI-1..7 | — | — | — | — | | Fee computation | ✅ | ✅ SI-4 | ✅ | ✅ | — | ✅ | | FixedMaturity lifecycle | ✅ | ✅ FM-inv | — | — | — | — | @@ -238,7 +243,7 @@ Cross-referenced to test files for auditor traceability: | SI-2 | `totalSupply * sharePrice ≈ totalAssets` | `test/invariants/CoreVault_System_Invariants.t.sol` | | SI-3 | No user can withdraw more than deposited (minus fees) | `test/invariants/CoreVault_System_Invariants.t.sol` | | SI-4 | Fees never exceed configured maximums | `test/invariants/CoreVault_System_Invariants.t.sol` | -| SI-5 | `queueLength() == number of open claims` | `test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol` | +| SI-5 | `outstandingClaimCount() == number of unclaimed claims across all epochs` | `test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol` | | SI-6 | NAV is consistent with underlying balances | `test/invariants/CoreVault_System_Invariants.t.sol` | | QI-1 | No claim in queue has `grossAssets < minClaimAmount` | `test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol` | | QI-4 | A claim cannot be settled twice | `test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol` | @@ -262,10 +267,10 @@ Cross-referenced to test files for auditor traceability: |---|---------------|------|-----------|---------------| | T1 | **Owner key compromise** | HIGH | Timelock delay + vetoer revoke window. `freezeParams` makes fees immutable post-hardening. | GI-3,4; governance unit tests | | T2 | **Share price manipulation via donation** | HIGH | Fixed in shadow report (BUG 3: non-dilutive fee transfer). Dead deposit seeds baseline. | SI-2; `AuditFix_Regression`; `SharePriceCollapse_Security.t.sol` | -| T3 | **Queue exhaustion / gas DoS** | MEDIUM | Gas safety exit at 150K remaining. Partial settlement resumes in next call. `compactQueue` callable by anyone. | SI-5; `ExitEngine_StressTest`; e2e | +| T3 | **Queue exhaustion / gas DoS** | MEDIUM | `fundEpoch()` liquidity pull is O(1) regardless of claim count (single call per epoch, not a per-claim scan). Settlement is pull-based (`claimEpochAssets`) so an individual user's claim cost never depends on other users' claims. | SI-5; `ExitEngine_StressTest`; `Hardening_GasAndChaos` gas characterization | | T4 | **Reentrancy via strategy callback** | MEDIUM | `FLAG_REENTRANCY_LOCKED` guards `deployToStrategies`, `rebalanceStrategies`. W2 rule: external calls are try/catch. | `CoreVault_DiamondLite_Reentrancy`; `ForceWithdraw_Reentrancy` | | T5 | **EIP-7201 storage slot collision** | HIGH (mitigated) | 4 namespaces verified via `EIP7201Compliance.t.sol` (5 tests). Fixed post-FINDING-OOS-03. | `test/security/EIP7201Compliance.t.sol` | -| T6 | **Unauthorized processorMint/Burn** | CRITICAL | `isAuthorizedModule[addr]` gate — only QueueModule and FixedMaturityModule authorized at deploy. | `CoreVault_DiamondLite_AccessControl` | +| T6 | **Unauthorized processorMint/Burn** | CRITICAL | `isAuthorizedModule[addr]` gate — only EpochedQueueModule and FixedMaturityModule authorized at deploy. | `CoreVault_DiamondLite_AccessControl` | | T7 | **FixedMaturity capital lock** | MEDIUM | `markMatured()` is permissionless; `preMaturityForceExitPenaltyBps ≤ 50%` hard cap. `forceWithdrawAll` available, now with a `minAssetsOut` floor (F-03). | FM invariant tests; `ForceWithdraw_*` | | T8 | **Fee parameter ratchet via short timelock** | MEDIUM | H3: post-seal min delay floor 1 day. Guardian can pause while vetoer revokes. | `CoreVault_ParamTimelock`; `Governance_Seal_Invariants` | @@ -275,7 +280,7 @@ Cross-referenced to test files for auditor traceability: Priority ranking based on value at risk and complexity: -1. **Settlement loop and share accounting** (CRITICAL): `QueueModule._settleLoop`, `_convertToAssetsCached`, `_crystallize`. Four shadow-report HIGH bugs were found here. The PPS must remain exact across all settlement sequences. +1. **Settlement and share accounting** (CRITICAL): `EpochedQueueModule.closeCurrentEpoch`, `fundEpoch`, `claimEpochAssets`, `_crystallize`. This is a substantially rewritten implementation (epoch-batched, not per-claim FIFO) that replaced the retired `QueueModule` post-first-audit — it has NOT yet been through the same shadow-report fork-replay process that found the four HIGH bugs listed in §5.1 for the old module. `_crystallize` was ported verbatim and inherits BUG-4-equivalent regression coverage; the epoch close/fund/claim path is new and should be treated as fresh audit surface, not a regression check. PPS must remain exact — locked once at `closeCurrentEpoch()`, applied identically to every claim in that epoch. 2. **ERC-4626 compliance at boundaries** (HIGH): `previewDeposit`, `previewWithdraw`, `mint` fee symmetry with `deposit`. Boundary conditions (totalAssets=0, totalSupply=0) and mint/deposit equivalence. @@ -293,13 +298,15 @@ Priority ranking based on value at risk and complexity: The following specific checks are recommended based on the internal shadow report and architecture review: -**Settlement loop** (`src/core/modules/QueueModule.sol:407`): -- [ ] Verify `_convertToAssetsCached` uses snapshot BEFORE `_burn` (BUG 4 regression) -- [ ] Verify `immediate=false` for all queued claims (BUG 6 regression) +**Settlement: close / fund / claim** (`src/core/modules/EpochedQueueModule.sol:327-513`): +- [ ] Verify `ppsAtClose` is computed BEFORE any shares are burned, and never recomputed after (BUG 4-equivalent regression, new code path) +- [ ] Verify every `EpochClaim` is treated identically regardless of how it was created (`requestEpochWithdrawal` vs. `requestInstantWithdrawal` fallback) — there is no `immediate` flag to mis-set (BUG 6 class eliminated by construction, verify no reintroduction) - [ ] Verify `feeShares` rounded UP (rounding in favour of protocol) -- [ ] Verify `cachedTA / cachedTS` snapshot is set once per batch (deterministic PPS) -- [ ] Verify `gasleft() > 150_000` guard prevents out-of-gas in settle loop -- [ ] Verify INSTANT cap consumption: only for INSTANT-mode, never STANDARD/FORCE +- [ ] Verify `ppsAtClose` is locked once per epoch at `closeCurrentEpoch()` and used identically by every claim in that epoch regardless of claim order (deterministic PPS) +- [ ] Verify `fundEpoch()` only transitions to `Funded` when `hot >= totalNetAssets` — no partial-funding state can be marked `Funded` +- [ ] Verify `outstandingClaimCount` persists across `closeCurrentEpoch()` (does not reset like per-epoch `claimCount`) — dynamic-cap bypass class, already fixed pre-cutover, verify no regression +- [ ] Verify INSTANT cap consumption: only for the settled-immediately path in `requestInstantWithdrawal`, never for STANDARD claims at any point in their lifecycle +- [ ] Verify `_notifyIncentivesExit` gas cost per claim stays well under keeper gas limits (BUG 7 was a QueueModule/IncentivesEngine issue — confirm the same call pattern in `EpochedQueueModule` doesn't reintroduce it) **Deposit and mint** (`src/core/modules/ERC4626Module.sol:166`): - [ ] Verify `previewDeposit(X) == deposit(X).shares` (ERC-4626 compliance, BUG 1 regression) @@ -352,7 +359,7 @@ The following specific checks are recommended based on the internal shadow repor |--------|------|-------| | `CoreVault` | `src/core/CoreVault.sol:36` | Diamond-lite proxy; delegatecall dispatch | | `ERC4626Module` | `src/core/modules/ERC4626Module.sol:166` | Deposit/withdraw; BUG 2+3 fixed here | -| `QueueModule._settleLoop` | `src/core/modules/QueueModule.sol:407` | Settlement; BUG 4+6 fixed here | +| `EpochedQueueModule` close/fund/claim | `src/core/modules/EpochedQueueModule.sol:327-513` | Settlement; supersedes retired `QueueModule._settleLoop` where BUG 4+6 were fixed | | `AdminModule` | `src/core/modules/AdminModule.sol:73` | Governance; all timelocks | | `FeeCollector` | `src/core/modules/FeeCollector.sol:17` | Immutable governor; GI-2 | | `FixedMaturityModule` | `src/core/modules/FixedMaturityModule.sol:52` | FM state machine; FM audit scope | diff --git a/docs/deployment.md b/docs/deployment.md index 300f5ba..884a8e8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -106,7 +106,7 @@ require(block.chainid == 42161, "WRONG_CHAIN: DeployCoreSystem is Arbitrum-only flowchart TD A["Phase 1: Infrastructure
VaultFactory + GlobalConfig + FeeCollector
PriceOracle + StrategyHealthRegistry
(script/DeployCoreSystem.s.sol:228)"] --> B B["Phase 2: Security
SelectorRegistry + SystemSealer
(script/DeployCoreSystem.s.sol:278)"] --> C - C["Phase 3: Core + Modules
CoreVault (PAUSED) + QueueModule
AdminModule + ERC4626Module + LiquidityOpsModule
(script/DeployCoreSystem.s.sol:299)"] --> D + C["Phase 3: Core + Modules
CoreVault (PAUSED) + EpochedQueueModule
AdminModule + ERC4626Module + LiquidityOpsModule
(script/DeployCoreSystem.s.sol)"] --> D D["Phase 4: Ecosystem Base
BufferManager + StrategyRouter
+ WarmAdapters (opt) + Incentives (opt) + VaultUpkeep (opt)
(script/DeployCoreSystem.s.sol:356)"] --> E E["Phase 5: Wiring
Module routing + Ecosystem config
Oracle config + Fees + Perf params
Dead deposit + ComponentsTimelock
Ownership transfer → ROOT_TIMELOCK
(script/DeployCoreSystem.s.sol:450)"] --> F F["Phase 6: Inline Assertions
17 MUST-pass postconditions
(script/DeployCoreSystem.s.sol:188)"] @@ -145,7 +145,7 @@ flowchart TD |----------|----------|-------| | `CoreVault` | `multyr-core/script/DeployCoreSystem.s.sol:304` | Starts **PAUSED** — invariant verified at `multyr-core/script/DeployCoreSystem.s.sol:313` | | Factory registration | `multyr-core/script/DeployCoreSystem.s.sol:316` | **Immediate** after deploy (subgraph event ordering) | -| `QueueModule` | `multyr-core/script/DeployCoreSystem.s.sol:333` | Stateless; handles deposit/withdrawal queue | +| `EpochedQueueModule` | `multyr-core/script/DeployCoreSystem.s.sol` | Stateless delegatecall target; the sole withdrawal-queue mechanism | | `AdminModule` | `multyr-core/script/DeployCoreSystem.s.sol:337` | Stateless; handles admin operations | | `ERC4626Module` | `multyr-core/script/DeployCoreSystem.s.sol:340` | Stateless; implements ERC-4626 vault interface | | `LiquidityOpsModule` | `multyr-core/script/DeployCoreSystem.s.sol:344` | Stateless; handles liquidity operations | @@ -203,7 +203,7 @@ Configured via `_configureModuleRouting()` (`multyr-core/script/DeployCoreSystem | Module | Selectors | Role | |--------|-----------|------| -| `QueueModule` | write + view selectors | `ROLE_PUBLIC` | +| `EpochedQueueModule` | write + view selectors | `ROLE_PUBLIC` | | `AdminModule` | owner selectors | `ROLE_OWNER` | | `AdminModule` | view selectors | `ROLE_PUBLIC` | | `ERC4626Module` | all selectors | `ROLE_PUBLIC` | @@ -365,7 +365,7 @@ For partial re-deployments (e.g., after migration or upkeep contract failure): | `DeployVaultUpkeep.s.sol` | `multyr-core/script/DeployVaultUpkeep.s.sol` | Redeploy VaultUpkeep + wire to BufferManager | | `DeployBufferManager.s.sol` | `multyr-core/script/DeployBufferManager.s.sol` | Redeploy BufferManager + migrate warm adapters | | `DeployStrategyRouter.s.sol` | `multyr-core/script/DeployStrategyRouter.s.sol` | Redeploy StrategyRouter + re-register strategies | -| `DeployQueueModule.s.sol` | `multyr-core/script/DeployQueueModule.s.sol` | Redeploy QueueModule + update module routing | +| `DeployQueueModule.s.sol` | `multyr-core/script/DeployQueueModule.s.sol` | Redeploy EpochedQueueModule + update module routing | | `DeployWarmAdapters.s.sol` | `multyr-core/script/DeployWarmAdapters.s.sol` | Redeploy Aave + Morpho warm adapters | > **Note**: All standalone scripts require `VAULT_ADDRESS` and `TIMELOCK_ADDRESS` env vars. @@ -441,6 +441,35 @@ After `DeployCoreSystem.s.sol` completes (`multyr-core/script/DeployCoreSystem.s --- +## Manual Post-Deploy Parameters + +Three settings are NOT written by any deploy script. The system deploys and +seals without them, so nothing fails loudly at deploy time; each one is either a +risk control that silently sits wide open, or a hard dependency for queue +settlement. Set all three before the vault takes real deposits. + +| Parameter | Where | Script default | Why it matters | +|---|---|---|---| +| Vault deposit cap | `GlobalConfig.setVaultDepositLimits(vault, cap, userCap, minDeposit)`, read via `IParamsProvider.getDepositLimits` | **10,000,000e6 (10M USDC)** from `defaultVaultDepositCap` | No script narrows it. A vault intended to launch at 20,000 USDC will accept 10M until governance says otherwise. | +| Asset oracle | `GlobalConfig` oracle config for the vault's asset, read via `oracleConfigFor(asset, vault)` | **unset** | `StrategyRouter.executeRedeemBatch` values the asset through `OracleValuationLib` and reverts `OracleNotConfigured` without it — for 6-decimal USDC as much as an 18-decimal asset. `fundEpoch` swallows that revert, so with no oracle the strategy-redeem leg of the funding waterfall silently does nothing and epochs stay `Closed`. The quote must also be fresher than the configured staleness window at the moment `fundEpoch` runs, which for a multi-day epoch means the keeper has to refresh it near funding time, not at close. | +| `queueStressThreshold` | `GlobalConfig` dynamic-cap config, read via `IParamsProvider.getDynamicCapParams` | **100 claims** | Drives `WithdrawalCapLib.calculateDynamicCapBps`: once `outstandingClaimCount` reaches it, the instant-exit cap collapses to `minBps` for everyone. At the default, and with `minClaimAmount` at its own 100 USDC default, pinning the cap at its floor costs roughly 100 x 100 = 10,000 USDC of refundable capital. On a 20,000 USDC vault that is half the deposit cap; on a 10M vault it is negligible. Tune it against the real cap. | + +Also worth setting deliberately rather than accepting the default: + +- **Warm adapter allowance cap** — `CoreVault.approveWarmAdapters(adapters, cap)` takes an explicit ceiling instead of granting an unlimited allowance. `DeployCoreSystem` passes `WARM_ADAPTER_ALLOWANCE_CAP`, defaulting to 1,000,000e6. The allowance depletes as adapters pull and does not renew, so an undersized cap eventually stalls warm deploys, and an oversized one weakens the bound. Size it against the deposit cap and expected warm cycling. + +### Verification + +```bash +cast call $GLOBAL_CONFIG "getDepositLimits(address)" $VAULT --rpc-url $RPC +cast call $GLOBAL_CONFIG "oracleConfigFor(address,address)" $ASSET $VAULT --rpc-url $RPC +cast call $GLOBAL_CONFIG "getDynamicCapParams(address)" $VAULT --rpc-url $RPC +cast call $ASSET "allowance(address,address)" $VAULT $WARM_ADAPTER --rpc-url $RPC +``` + +A zero oracle address in the second call means queue settlement cannot pull from +strategies. Watch `EpochFundingShortfall` for the runtime symptom. + ## Next Steps (Modular Path B) ``` diff --git a/docs/exit-engine.md b/docs/exit-engine.md index 36ae51e..cddaa6b 100644 --- a/docs/exit-engine.md +++ b/docs/exit-engine.md @@ -1,32 +1,36 @@ --- title: Exit Engine category: multyr-core -version: "1.0" -commit: c39f9462 -updated: 2026-05-15 +version: "2.0" +commit: f7e3544 +updated: 2026-08-13 status: final -tags: [exit-engine, withdrawal, queue, force-exit, epoch-cap] +tags: [exit-engine, withdrawal, queue, force-exit, epoch-cap, epoch-queue] --- # Exit Engine -> **Source of truth**: `src/core/libraries/ExitEngineLib.sol:202` @ `c39f9462` -> **ADR-015 workflow applied**: full code read before drafting. +> **Source of truth**: `src/core/libraries/ExitEngineLib.sol` + `src/core/modules/EpochedQueueModule.sol` @ `f7e3544` +> **Supersedes**: v1.0 of this document, which described the exit engine's `QueueModule` +> integration. `QueueModule.sol` has been deleted; `EpochedQueueModule` is the sole production +> queue-settlement mechanism. See `docs/queue-mechanics.md` for the full epoch-queue writeup — +> this document focuses on `ExitEngineLib`'s mode routing, fee computation, and cap accounting, +> which are largely unchanged by the queue migration. --- ## 1. Overview -The exit engine is the single library that coordinates all withdrawal paths in a Multyr vault. It is implemented in `src/core/libraries/ExitEngineLib.sol:202` (279L) and delegates fee computation to `src/core/libraries/ExitFeeLib.sol:29` (77L). +The exit engine is the single library that coordinates all withdrawal paths in a Multyr vault. It is implemented in `src/core/libraries/ExitEngineLib.sol` and delegates fee computation to `src/core/libraries/ExitFeeLib.sol`. Two modules consume the exit engine: | Module | File | Exit paths | |--------|------|------------| -| `QueueModule` | `src/core/modules/QueueModule.sol:81` | `requestClaim` (STANDARD + INSTANT), `settleFeesAndProcessQueue` | +| `EpochedQueueModule` | `src/core/modules/EpochedQueueModule.sol` | `requestEpochWithdrawal` (STANDARD), `requestInstantWithdrawal` (INSTANT), `closeCurrentEpoch`/`fundEpoch`/`claimEpochAssets` (settlement) | | `ERC4626Module` | `src/core/modules/ERC4626Module.sol:166` | `forceWithdraw`, `forceWithdrawAll` | -`ExitEngineLib` is a pure library — it holds no storage. It reads from `CoreStorage.Layout` and `QueueStorage.Layout` via storage pointers passed by the calling module (delegatecall context; `address(this)` is the vault). +`ExitEngineLib` is a pure library — it holds no storage. It reads from `CoreStorage.Layout` via storage pointers passed by the calling module (delegatecall context; `address(this)` is the vault). Unlike the retired `QueueModule` integration, `ExitEngineLib`'s exported functions no longer take a `QueueStorage.Layout` pointer — `EpochedQueueModule` computes its own "queue depth" dynamic-cap signal internally (`_epochCapRemaining()`, `EpochedQueueModule.sol:946`) rather than routing through `ExitEngineLib.calculateCapRemaining()`. Three responsibilities: @@ -38,29 +42,30 @@ Three responsibilities: ```mermaid flowchart TD - User -->|requestClaim| QM[QueueModule] + User -->|requestEpochWithdrawal| QM[EpochedQueueModule] + User -->|requestInstantWithdrawal| QM User -->|forceWithdraw / forceWithdrawAll| ERC[ERC4626Module] - Admin -->|settleFeesAndProcessQueue| QM + Keeper -->|closeCurrentEpoch / fundEpoch| QM + User -->|claimEpochAssets| QM - QM -->|computeFeeShares\nrollEpochIfNeeded\ncalculateCapRemaining\nconsumeEpochCap| EEL[ExitEngineLib] + QM -->|computeFeeShares\nrollEpochIfNeeded\nconsumeEpochCap| EEL[ExitEngineLib] ERC -->|computeFeeShares\nrollEpochIfNeeded| EEL EEL -->|computeExitFee\nexitFeeBps| EFL[ExitFeeLib] - QM -->|reads| CS[CoreStorage] - QM -->|reads| QS[QueueStorage] + QM -->|reads/writes| CS[CoreStorage] + QM -->|reads/writes| EQS[EpochQueueStorage] ERC -->|reads| CS ERC -->|reads| FS[FixedMaturityStorage] EEL -->|reads/writes| CS - EEL -->|reads| QS ``` --- ## 2. Three Exit Modes -The `ExitMode` enum (`ExitEngineLib.sol:L8`) defines three settlement paths: +The `ExitMode` enum (`ExitEngineLib.sol:28`) defines three settlement paths — unchanged by the queue migration: ```solidity // src/core/libraries/ExitEngineLib.sol:28 @@ -75,31 +80,32 @@ enum ExitMode { STANDARD is the baseline path for all queued withdrawals. -- Entry: `requestClaim(false, shares)`, or `requestClaim(true, shares)` when instant settlement is not possible -- Shares are escrowed to `address(this)` (the vault contract) -- A `Claim` struct is stored with `immediate = false` regardless of user intent — the queue always records the claim as STANDARD once fallback triggers -- Settles in a future `settleFeesAndProcessQueue(maxClaims)` call -- Subject to `lockPeriod` check at settlement time (not at request time) +- Entry: `requestEpochWithdrawal(shares)`, or `requestInstantWithdrawal(shares)` when instant settlement is not possible (internal fallback to the same code path) +- ALL gross shares (not just the fee-deducted portion) are escrowed to `address(this)` (the vault contract) — fee shares are only separated out and transferred at `closeCurrentEpoch()` +- An `EpochClaim` is recorded under `(currentEpochId, claimId)` — there is no `immediate` flag on the claim struct itself; the fallback path and the direct `requestEpochWithdrawal` path both simply create a standard epoch claim +- Settles via a future `closeCurrentEpoch()` → `fundEpoch()` → `claimEpochAssets()` sequence — the last step is pull-based, called by the user, not a keeper +- Subject to `lockPeriod` check at *request* time (not settlement time — a difference from the retired queue, whose lock check ran at settlement) - Fee: `witBps` only -- **`netAssets` in `simulateExit()` is INDICATIVE** — PPS at settlement may differ from PPS at request +- **`netAssets` in `simulateExit()` is INDICATIVE** — `ppsAtClose` (locked at `closeCurrentEpoch()`) may differ from PPS at request time ### 2.2 INSTANT INSTANT is the immediate path, available when three conditions hold simultaneously. -- Entry: `requestClaim(true, shares)` when `_canSettleInstant()` returns `true` -- Three-gate check (`src/core/modules/QueueModule.sol:528`): +- Entry: `requestInstantWithdrawal(shares)` when `_canInstant()` returns `true` +- Three-gate check (`src/core/modules/EpochedQueueModule.sol:917`): 1. **Lock period**: `block.timestamp >= core.lastDepositTs[msg.sender] + lockPeriod` - 2. **Epoch cap**: `grossAssets <= calculateCapRemaining(core, q, totalAssets, vault)` + 2. **Epoch cap**: `grossAssets <= _epochCapRemaining()` (the withdrawal-cap epoch — see §4) 3. **Hot liquidity**: `IERC20(_asset()).balanceOf(address(this)) >= grossAssets` -- Settles atomically in the same transaction — no queue enqueue +- Settles atomically in the same transaction — no queue entry created - Fee: `witBps + immediateExitPenaltyBps` - Consumes epoch cap via `consumeEpochCap(core, grossAssets)` - **`netAssets` is EXACT** — PPS computed and applied in the same block +- Returns `(settledImmediately=true, epochId=0, claimId=0)` ### 2.3 FORCE -FORCE is the emergency withdrawal path. It bypasses the epoch cap and lock period. +FORCE is the emergency withdrawal path. It bypasses the epoch cap and lock period. Unchanged by the queue migration — still implemented in `ERC4626Module`. - Entry: `forceWithdraw(assets, receiver, owner, plan, maxShares)` or `forceWithdrawAll(receiver, minAssetsOut)` - Requires vault to be in OpenEnded mode, or FixedMaturity/Active state (`_checkForceExitAllowed()` in `FixedMaturityStorage.sol`) @@ -113,10 +119,10 @@ FORCE is the emergency withdrawal path. It bypasses the epoch cap and lock perio ```mermaid flowchart TD - A[requestClaim immediate=false] --> Q1[Enqueue as STANDARD] - B[requestClaim immediate=true] --> C{_canSettleInstant?} + A[requestEpochWithdrawal] --> Q1[Enqueue as STANDARD\ninto current open epoch] + B[requestInstantWithdrawal] --> C{_canInstant?} C -->|yes| D[Settle INSTANT in-place] - C -->|no| E[Enqueue with immediate=false → STANDARD] + C -->|no| E[Fallback: enqueue into\ncurrent open epoch as STANDARD] F[forceWithdraw / forceWithdrawAll] --> G[Settle FORCE in-place] style D fill:#c8e6c9 @@ -125,7 +131,9 @@ flowchart TD style E fill:#fff9c4 ``` -The queue at settlement time re-evaluates mode: a claim with `c.immediate = true` uses INSTANT mode (cap check), one with `c.immediate = false` uses STANDARD mode (lock check). See `_settleLoop` (`src/core/modules/QueueModule.sol:407`). +Unlike the retired queue, there is no per-claim mode re-evaluation at settlement time — an +epoch claim is always STANDARD once created (the `immediate=true→false` downgrade concept no +longer applies since instant settlement, when it happens, never creates a claim at all). --- @@ -133,14 +141,17 @@ The queue at settlement time re-evaluates mode: a claim with `c.immediate = true Entry points for each mode: -| Function | Module | Selector | Mode | -|----------|--------|----------|------| -| `requestClaim(bool immediate, uint256 shares)` | QueueModule | — | STANDARD or INSTANT | -| `cancelClaim(uint256 claimId)` | QueueModule | — | — (reversal) | -| `settleFeesAndProcessQueue(uint256 maxClaims)` | QueueModule | — | settles STANDARD + INSTANT from queue | -| `forceWithdraw(uint256 assets, address receiver, address owner, Pull[] plan, uint256 maxShares)` | ERC4626Module | `0x439fdeb4` | FORCE | -| `forceWithdrawAll(address receiver, uint256 minAssetsOut)` | ERC4626Module | `0xe375b48f` | FORCE (all shares, reverts below `minAssetsOut` — F-03) | -| `simulateExit(uint256 shares, bool immediate, bool isForce, address vault)` | ExitEngineLib (view) | — | preview only | +| Function | Module | Mode | +|----------|--------|------| +| `requestEpochWithdrawal(uint256 shares)` | EpochedQueueModule | STANDARD | +| `requestInstantWithdrawal(uint256 shares)` | EpochedQueueModule | INSTANT (falls back to STANDARD) | +| `cancelEpochWithdrawal(uint256 epochId, uint256 claimId)` | EpochedQueueModule | — (reversal, epoch must still be Open) | +| `closeCurrentEpoch()` | EpochedQueueModule | locks PPS for a full epoch of STANDARD claims | +| `fundEpoch(uint256 epochId)` | EpochedQueueModule | pulls liquidity for a Closed epoch | +| `claimEpochAssets(uint256 epochId, uint256 claimId)` / `batchClaimEpochAssets(...)` | EpochedQueueModule | pull-based settlement of a Funded epoch's claim(s) | +| `forceWithdraw(uint256 assets, address receiver, address owner, Pull[] plan, uint256 maxShares)` | ERC4626Module | FORCE | +| `forceWithdrawAll(address receiver, uint256 minAssetsOut)` | ERC4626Module | FORCE (all shares, reverts below `minAssetsOut` — F-03) | +| `simulateExit(uint256 shares, bool immediate, bool isForce, address vault)` | ExitEngineLib (view) | preview only | Note: `withdraw(uint256, address, address)` and `redeem(uint256, address, address)` always revert (`AsyncWithdrawalRequired`) — see Invariant E1. @@ -148,10 +159,9 @@ Note: `withdraw(uint256, address, address)` and `redeem(uint256, address, addres ## 3. ExitResult Struct -`simulateExit()` (`src/core/libraries/ExitEngineLib.sol:202`) returns an `ExitResult` struct that mirrors the state written by actual settlement: +`simulateExit()` (`src/core/libraries/ExitEngineLib.sol`) returns an `ExitResult` struct that mirrors the state written by actual settlement — unchanged by the queue migration: ```solidity -// src/core/libraries/ExitEngineLib.sol:34 struct ExitResult { uint256 grossAssets; // assets before fees uint256 netAssets; // assets user receives @@ -164,7 +174,7 @@ struct ExitResult { } ``` -**Precision contract** (src/core/libraries/ExitFeeLib.sol:29, src/core/libraries/ExitEngineLib.sol:151): +**Precision contract** (`src/core/libraries/ExitFeeLib.sol`, `src/core/libraries/ExitEngineLib.sol`): | Field | Rounding | Direction | Reason | |-------|----------|-----------|--------| @@ -174,28 +184,28 @@ struct ExitResult { | `feeShares` | `mulBpsUp` | ceiling | favors protocol; prevents sub-1-share dust leakage | | `userShares` | `grossShares - feeShares` | — | derived | -`simulateExit()` is call-equivalent to the runtime path for INSTANT and FORCE. For STANDARD, `netAssets` is indicative because PPS changes between request and settlement. +`simulateExit()` is call-equivalent to the runtime path for INSTANT and FORCE. For STANDARD, `netAssets` is indicative because `ppsAtClose` is set later, at `closeCurrentEpoch()`, and may differ from PPS at request time. --- ## 4. Epoch Cap Engine -The epoch cap limits aggregate INSTANT withdrawals per time window. It does not apply to STANDARD (lazy) or FORCE (emergency) paths. +The withdrawal-cap epoch limits aggregate INSTANT withdrawals per time window. It does not apply to STANDARD (queued) or FORCE (emergency) paths. **This is a distinct concept from the settlement epoch** used by `EpochedQueueModule` for claim batching — see `docs/queue-mechanics.md` §6 for the full distinction. They have independent storage, independent durations, and roll on different triggers. ### 4.1 Storage fields -Fields in `CoreStorage.Layout` (`src/core/storage/CoreStorage.sol:38`): +Fields in `CoreStorage.Layout`: | Field | Type | Description | |-------|------|-------------| -| `epochStart` | `uint64` | Timestamp of current epoch start | +| `epochStart` | `uint64` | Timestamp of current cap-epoch start | | `epochDuration` | `uint64` | Duration in seconds (1d–30d) | -| `epochWithdrawn` | `uint256` | Cumulative INSTANT assets withdrawn this epoch | -| `maxWithdrawPerEpoch` | `uint256` | Static cap (used when WithdrawalCapLib not wired) | +| `epochWithdrawn` | `uint256` | Cumulative INSTANT assets withdrawn this cap epoch | +| `maxWithdrawPerEpoch` | `uint256` | Static cap (used when `WithdrawalCapLib` not wired) | ### 4.2 Epoch roll -`rollEpochIfNeeded(CoreStorage.Layout storage core)` (`src/core/libraries/ExitEngineLib.sol:77`): +`rollEpochIfNeeded(CoreStorage.Layout storage core)` (`src/core/libraries/ExitEngineLib.sol`): ``` if block.timestamp >= epochStart + epochDuration: @@ -205,42 +215,40 @@ if block.timestamp >= epochStart + epochDuration: Multi-epoch skips (vault was inactive for N epochs) are handled by iterating: `epochStart` is advanced in multiples of `epochDuration` until it is within one duration of `block.timestamp`. -Constants (`ExitEngineLib.sol:L15`): +Constants: - `MIN_EPOCH_DURATION = 1 days` - `MAX_EPOCH_DURATION = 30 days` ### 4.3 Cap computation -`calculateCapRemaining(core, q, totalAssets, vault)` (`src/core/libraries/ExitEngineLib.sol:106`): +`EpochedQueueModule._epochCapRemaining()` (`src/core/modules/EpochedQueueModule.sol:946`) replaces the old `ExitEngineLib.calculateCapRemaining(core, q, ...)` wrapper (which took a `QueueStorage.Layout` pointer and has been deleted along with `QueueModule`): -1. Calls `rollEpochIfNeeded` (writes `epochStart`, `epochWithdrawn` if epoch expired) -2. If `WithdrawalCapLib` is wired: `cap = WithdrawalCapLib.computeCap(totalAssets, ...)` -3. Else: `cap = core.maxWithdrawPerEpoch` +1. Calls `rollEpochIfNeeded` (writes `epochStart`, `epochWithdrawn` if the cap epoch expired) +2. If `WithdrawalCapLib` dynamic cap is enabled: computes a stress-adjusted cap using `outstandingClaimCount()` (the epoch-model "queue depth" signal — see `docs/queue-mechanics.md` §2.1) in place of the old flat-array `queue.length - head` +3. Else: `cap = wp.capPerEpochBps` (or `maxWithdrawPerEpoch` fallback) 4. Returns `max(0, cap - core.epochWithdrawn)` ### 4.4 Cap consumption -`consumeEpochCap(core, grossAssets)` (`src/core/libraries/ExitEngineLib.sol:258`): +`consumeEpochCap(core, grossAssets)` (`src/core/libraries/ExitEngineLib.sol`): ```solidity core.epochWithdrawn += grossAssets; ``` -Called in two places: -- `requestClaim()` INSTANT path: immediately after settlement in the same tx -- `_settleLoop()` in `QueueModule`: for `c.immediate = true` claims processed from queue +Called only in one place now: `requestInstantWithdrawal()`'s settled-immediately path, in the same transaction as settlement. The old second call site — `QueueModule._settleLoop()` re-checking `c.immediate=true` claims at settlement time — no longer exists, because an epoch claim is never subject to the cap at settlement; only the atomic instant path ever consumes it. **FORCE never calls `consumeEpochCap`.** The `epochWithdrawn` counter is unaffected by `forceWithdraw` and `forceWithdrawAll`. ### 4.5 INSTANT fallback -When `requestClaim(true, shares)` fails `_canSettleInstant()` (any one of: cap exhausted, lock active, insufficient hot), the claim is stored with `immediate = false`: +When `requestInstantWithdrawal(shares)` fails `_canInstant()` (any one of: cap exhausted, lock active, insufficient hot), it internally calls the same code path as `requestEpochWithdrawal`: ``` -Claim{user, ts, immediate=false, settled=false, shares=N} +EpochClaim{user, netShares, feeShares, claimed=false} // recorded under (currentEpochId, claimId) ``` -At settlement time, this claim is treated as STANDARD — it is **never subject to the epoch cap** regardless of remaining capacity. This prevents a starvation scenario where a user who requested immediate but was downgraded to queue is blocked by a full cap on settlement day. +This claim is settled via the normal close → fund → claim cycle and is **never subject to the epoch cap**, regardless of remaining capacity at settlement time. This prevents a starvation scenario where a user who requested immediate but was downgraded to the queue is blocked by a full cap at settlement. ### 4.6 Epoch cap timeline example @@ -248,9 +256,9 @@ At settlement time, this claim is treated as STANDARD — it is **never subject Day 0: epochStart=T0, epochWithdrawn=0, cap=100_000 USDC tx1: INSTANT 40_000 → epochWithdrawn=40_000, capRem=60_000 tx2: INSTANT 60_000 → epochWithdrawn=100_000, capRem=0 - tx3: INSTANT 1_000 → _canSettleInstant() false → queued as STANDARD + tx3: INSTANT 1_000 → _canInstant() false → falls back, queued into current settlement epoch -Day 1: rollEpochIfNeeded() → epochStart=T0+1d, epochWithdrawn=0 +Day 1: rollEpochIfNeeded() → epochStart=T0+1d, epochWithdrawn=0 (cap epoch only — unrelated to the settlement epoch's own lifecycle) tx4: INSTANT 1_000 → capRem=99_000 ✓ ``` @@ -258,41 +266,49 @@ Day 1: rollEpochIfNeeded() → epochStart=T0+1d, epochWithdrawn=0 ## 4.7 Storage layout (exit-engine-relevant fields) -Fields read or written by ExitEngineLib during exit processing (`src/core/storage/CoreStorage.sol:38`): +Fields read or written by `ExitEngineLib` during exit processing (`src/core/storage/CoreStorage.sol`): -| Field | Type | Slot offset | Access | -|-------|------|-------------|--------| -| `epochStart` | `uint64` | packed in slot 3 | read/write (rollEpochIfNeeded) | -| `epochDuration` | `uint64` | packed in slot 3 | read | -| `epochWithdrawn` | `uint256` | slot 4 | read/write | -| `maxWithdrawPerEpoch` | `uint256` | slot 5 | read | -| `lastDepositTs[user]` | `mapping(address → uint64)` | derived slot | read (INSTANT lock check) | -| `paramMinDelay` | `uint64` | packed in slot 6 | read (AdminModule fee timelock) | -| `packedFlags` | `uint256` | slot 7 | read/write (reentrancy guard) | +| Field | Type | Access | +|-------|------|--------| +| `epochStart` | `uint64` | read/write (`rollEpochIfNeeded`) | +| `epochDuration` | `uint64` | read | +| `epochWithdrawn` | `uint256` | read/write | +| `maxWithdrawPerEpoch` | `uint256` | read | +| `lastDepositTs[user]` | `mapping(address → uint64)` | read (INSTANT lock check) | +| `paramMinDelay` | `uint64` | read (AdminModule fee timelock) | +| `packedFlags` | `uint256` | read/write (reentrancy guard) | -Fields in `QueueStorage.Layout` (`src/core/storage/QueueStorage.sol:24`, SLOT `0x20afa2de...`): +Fields in `EpochQueueStorage.Layout` (`src/core/modules/EpochedQueueModule.sol:59`, a namespace entirely separate from the retired `QueueStorage.Layout`): | Field | Type | Description | |-------|------|-------------| -| `queue` | `uint256[]` | Ordered list of active claim IDs | -| `head` | `uint256` | Index into `queue` of first unsettled entry | -| `nextClaimId` | `uint256` | Monotonic counter for claim IDs | -| `pendingShares` | `uint256` | Total shares in escrow across all active claims | -| `claims[id]` | `mapping(uint256 → Claim)` | Full claim data per ID | - -`Claim` struct memory layout (2 storage slots): -``` -slot 0: [address user (20B)] [uint64 ts (8B)] [bool immediate (1B)] [bool settled (1B)] -slot 1: uint256 shares +| `currentEpochId` | `uint256` | The currently open settlement epoch | +| `epochs[epochId]` | `mapping(uint256 → EpochData)` | Per-epoch aggregate (state, `ppsAtClose`, totals) | +| `claims[epochId][claimId]` | `mapping(uint256 → mapping(uint256 → EpochClaim))` | Per-claim data | +| `nextClaimId[epochId]` | `mapping(uint256 → uint256)` | Per-epoch claim ID counter (starts at 1) | +| `escrowedShares` | `uint256` | Total shares in vault escrow across all epochs | +| `outstandingClaimCount` | `uint256` | Total unclaimed claims across all epochs — dynamic-cap signal | +| `oldestUnfundedEpochId` | `uint256` | Keeper cursor — oldest Closed-not-yet-Funded epoch | + +`EpochClaim` struct (2 storage slots, `EpochedQueueModule.sol:52`): +```solidity +struct EpochClaim { + address user; + uint256 netShares; + uint256 feeShares; + bool claimed; +} ``` +Note: unlike the retired `Claim` struct, there is no `immediate` field — every `EpochClaim` is, by construction, a STANDARD claim. + --- ## 5. Fee Path Per Mode ### 5.1 Fee parameters -Fee parameters are stored in `FeeStorage.Layout` (`src/core/storage/FeeStorage.sol:12`): +Fee parameters are stored in `FeeStorage.Layout` — unchanged by the queue migration: ```solidity struct InternalFeeParams { @@ -304,10 +320,12 @@ struct InternalFeeParams { } ``` -In FixedMaturity/Active vaults, `FixedMaturityStorage.Layout.preMaturityForceExitPenaltyBps` (`src/core/storage/FixedMaturityStorage.sol:47`) is fetched and added to FORCE fee only. +In FixedMaturity/Active vaults, `FixedMaturityStorage.Layout.preMaturityForceExitPenaltyBps` is fetched and added to FORCE fee only. ### 5.2 Fee computation chain +Unchanged — `ExitEngineLib`/`ExitFeeLib` compute fees identically regardless of which queue module calls them: + ``` ExitEngineLib.computeFeeShares(shares, mode, fee) ↓ @@ -328,24 +346,24 @@ ExitEngineLib.computeFeeShares: → userShares = grossShares - feeShares ``` -(`ExitFeeLib.sol:L20–L60`; `ExitEngineLib.sol:L155–L175`) - ### 5.3 Fee disposition Fee shares are **transferred** from the existing supply to `feeCollector`, never minted: -| Path | Escrow source | Transfer call | -|------|--------------|---------------| -| INSTANT in `requestClaim` | `msg.sender` (user holds shares) | `_transferShares(msg.sender, feeCollector, feeShares)` | -| STANDARD/INSTANT in `_settleLoop` | `address(this)` (vault escrow) | `_transferShares(address(this), feeCollector, feeShares)` | -| FORCE in `forceWithdraw` | `owner_` | `_transferShares(owner_, feeCollector, feeShares)` | -| FORCE in `forceWithdrawAll` | `msg.sender` | `_transferShares(msg.sender, feeCollector, feeShares)` | +| Path | Escrow source | Transfer call | When | +|------|--------------|---------------|------| +| INSTANT in `requestInstantWithdrawal` | `msg.sender` (user holds shares) | `_transferShares(msg.sender, feeCollector, feeShares)` | Same transaction, atomic | +| STANDARD in `closeCurrentEpoch` | `address(this)` (vault escrow) | `_transferShares(address(this), feeCollector, epoch.totalFeeShares)` | ONE batched transfer per epoch, not per claim | +| FORCE in `forceWithdraw` | `owner_` | `_transferShares(owner_, feeCollector, feeShares)` | Same transaction | +| FORCE in `forceWithdrawAll` | `msg.sender` | `_transferShares(msg.sender, feeCollector, feeShares)` | Same transaction | **Invariant**: `totalSupply` is never increased by any fee operation on the exit path. The fee is taken from the exiting user's allocation — no new shares are created. +The STANDARD-path batching (one `_transferShares` call for the whole epoch's accumulated fee shares, at close time) replaces the retired per-claim fee transfer inside `QueueModule._settleLoop` — a direct consequence of settling a whole epoch at once instead of scanning individual claims. + ### 5.3.1 Fee parameter timelock -Fee parameters (`witBps`, `immediateExitPenaltyBps`, `forceExitPenaltyBps`) are changed through a two-step timelock in `AdminModule` (`src/core/modules/AdminModule.sol:73`): +Unchanged by the queue migration. Fee parameters (`witBps`, `immediateExitPenaltyBps`, `forceExitPenaltyBps`) are changed through a two-step timelock in `AdminModule`: ``` submitFeeParams(depBps, witBps, immediateExitPenaltyBps, forceExitPenaltyBps, treasury) @@ -360,13 +378,13 @@ revokeFeeParams() → deletes pendingFee ``` -`paramMinDelay` is itself subject to a separate timelock (`submitParamDelay` / `acceptParamDelay`). The `MAX_WINDOW = 7 days` (`AdminModule.sol:L45`) ensures stale pending params cannot be applied indefinitely. +`paramMinDelay` is itself subject to a separate timelock (`submitParamDelay` / `acceptParamDelay`). `MAX_WINDOW = 7 days` ensures stale pending params cannot be applied indefinitely. --- ### 5.4 Performance fee (crystallization) -Performance fee is independent from exit fees. It is triggered in `settleFeesAndProcessQueue` via `_crystallize()` (`src/core/modules/QueueModule.sol:763`): +Performance fee is independent from exit fees, and — as of this migration — explicitly documented as independent from queue settlement too (see `docs/queue-mechanics.md` §7). It is triggered via `endEpochCrystallize()` → `_crystallize()` (`src/core/modules/EpochedQueueModule.sol:576`), ported verbatim from the retired `QueueModule`: ``` pps = totalAssets / totalSupply (WAD-scaled) @@ -378,104 +396,98 @@ if pps > highWaterMark: highWaterMark = new pps ``` +`endEpochCrystallize()` can be called at any time, independent of whether any settlement epoch is open, closed, or funded — it has zero dependency on `EpochQueueStorage`. + Note: `_mint` is called **only for perf fee crystallization** — not during standard/instant/force exits. --- ## 6. Critical Invariants -Six invariants enforced across `ExitEngineLib`, `QueueModule`, and `ERC4626Module`: +Six invariants enforced across `ExitEngineLib`, `EpochedQueueModule`, and `ERC4626Module`: | ID | Invariant | Where enforced | |----|-----------|---------------| | **E1** | `withdraw()` and `redeem()` always revert with `AsyncWithdrawalRequired` | `ERC4626Module.sol` — unconditional revert on both functions | -| **E2** | `epochWithdrawn ≤ epochCap` after any INSTANT settlement | `consumeEpochCap` called only after `_canSettleInstant()` confirms cap available | +| **E2** | `epochWithdrawn ≤ epochCap` after any INSTANT settlement | `consumeEpochCap` called only after `_canInstant()` confirms cap available | | **E3** | `totalSupply` never increases on any exit path | exit fees are transfer-only; `_mint` is perf-fee only | | **E4** | `feeShares` transferred from user/escrow to feeCollector, not minted | `_transferShares()` call site, not `_mint()` | | **E5** | `simulateExit()` result == runtime for INSTANT and FORCE | Same code path in ExitEngineLib ← ExitFeeLib, identical rounding | | **E6** | FORCE exits do not consume epoch cap | `consumeEpochCap` absent from both `forceWithdraw` and `forceWithdrawAll` | -Test coverage: `test/unit/core/ExitEngineLib.t.sol`, `test/unit/core/QueueModule.t.sol`, `test/unit/core/ERC4626Module.t.sol` (commit `c39f9462`). +Test coverage: `test/unit/core/ExitEngineLib.t.sol`, `test/unit/core/EpochedQueueModule.t.sol`, `test/unit/core/ERC4626Module.t.sol`, `test/unit/core/ExitEngine_StressTest.t.sol`, `test/unit/core/ExitEngine_ForkSuite.t.sol`, `test/unit/core/ExitEngine_AuditEdgeCases.t.sol`. --- -## 6.1 Settlement loop architecture +## 6.1 Settlement architecture -`settleFeesAndProcessQueue(maxClaims)` orchestrates the full settle cycle (`src/core/modules/QueueModule.sol:207`): +Settlement is no longer a single-function batch scan — it is a three-call, epoch-wide sequence (`src/core/modules/EpochedQueueModule.sol:327-513`). See `docs/queue-mechanics.md` §4 for the full breakdown; summary: ```mermaid flowchart TD - Start([settleFeesAndProcessQueue]) --> FM[FM gate\n_checkSettlementAllowed] - FM --> ER[Epoch roll\nrollEpochIfNeeded] - ER --> SNAP[Snapshot\ncachedTA = totalAssets\ncachedTS = totalSupply] - SNAP --> CAP[calculateCapRemaining] - CAP --> SCAN[_settleScan] - - SCAN --> NAV[_trySoftRefreshWarmNav\nbest-effort, try/catch] - NAV --> PRE[_boundedPreScan\nfind eligible entries\nmaxEntries = maxClaims × 2\nstop at 32 consecutive ineligible] - PRE --> REFILL[bm.refill if bm != address 0] - REFILL --> LOOP[_settleLoop] - - LOOP --> CHECK{gasleft > 150_000?} - CHECK -->|no| END([return]) - CHECK -->|yes| ELIG{claim eligible?\nIMMEDIATE: gross <= capRem\nSTANDARD: lockPeriod passed} - ELIG -->|no| SKIP[skip, continue] - SKIP --> CHECK - ELIG -->|yes| HOT{hot >= gross?} - HOT -->|no| SKIP2[emit QueueClaimSkippedInsufficientHot\nskip, continue] - SKIP2 --> CHECK - HOT -->|yes| FEE[computeFeeShares\ntransferShares to feeCollector\nburn userShares\nsafeTransfer net to user] - FEE --> CAPU[if c.immediate:\n consumeEpochCap\n capRem -= gross] - CAPU --> NEXT[advance head if settled] - NEXT --> CHECK - - LOOP --> COMMIT[batch commit storage:\nepochWithdrawn\npendingShares] - COMMIT --> CRYSTAL[_crystallize\nperfFee if pps > hwm] - CRYSTAL --> NAVSMOOTH[_updateNavSmooth] - NAVSMOOTH --> EMIT[emit VaultPpsSnapshot] - EMIT --> END + CLOSE([closeCurrentEpoch]) --> FM[FM gate\n_checkSettlementAllowed] + FM --> AGE{block.timestamp >=\nopenedAt + minEpochDuration?} + AGE -->|no| REVERT([revert EpochTooYoung]) + AGE -->|yes| SNAP[Snapshot ppsAtClose = totalAssets/totalSupply\nONCE for the whole epoch] + SNAP --> FEEBATCH[Batch-transfer epoch.totalFeeShares\nto feeCollector in ONE call] + FEEBATCH --> NEXTEPOCH[Open next epoch immediately] + NEXTEPOCH --> CLOSED([epoch state: Closed]) + + CLOSED --> FUND([fundEpoch]) + FUND --> HOTCHK{hot >= totalNetAssets?} + HOTCHK -->|no| WARM[try bm.refill deficit] + WARM --> STRAT[try router.executeRedeemBatch\nfor remaining gap] + STRAT --> HOTCHK + HOTCHK -->|yes| FUNDED([epoch state: Funded]) + + FUNDED --> CLAIM([claimEpochAssets, per user, pull-based]) + CLAIM --> ASSETS[assets = netShares * ppsAtClose / WAD] + ASSETS --> BURN[burn netShares, transfer assets] ``` -Key gas safety: `gasleft() > 150_000` guard exits the loop before gas exhaustion — the batch commits partial progress to storage before returning. +Key gas property: `fundEpoch()` is **O(1) regardless of how many claims the epoch contains** — one liquidity pull covers the entire epoch's net liability, unlike the retired per-batch keeper scan whose cost scaled with `min(maxClaims, queueDepth)`. Empirically measured flat at ~39k gas across queue depths of 100/500/1000 claims (`test/unit/core/Hardening_GasAndChaos.t.sol:test_gasCharacterization_queue100/500/1000`). --- ## 7. Events -Events emitted on exit paths (defined in `src/core/libraries/Events.sol:7`): +Events emitted on exit paths: | Event | Module | Trigger | |-------|--------|---------| -| `ClaimQueued(claimId, user, shares, immediate)` | QueueModule | `requestClaim` → queue path | -| `ClaimSettled(claimId, user, netAssets)` | QueueModule | `_settleLoop` per claim | -| `ClaimCancelled(claimId, user, shares)` | QueueModule | `cancelClaim` | -| `QueueClaimSkippedInsufficientHot(id, hot, gross)` | QueueModule | `_settleLoop` — hot < gross | -| `FeePaid(user, feeCollector, feeShares)` | QueueModule | Each fee transfer in settle loop | -| `VaultPpsSnapshot(pps, ts)` | QueueModule | End of `settleFeesAndProcessQueue` | -| `Crystallized(oldHwm, newHwm, feeAssets)` | QueueModule | Performance fee crystallization | -| `PerfFeeMinted(oldHwm, ppsBefore, feeShares, ppsAfter)` | QueueModule | Perf fee mint | +| `EpochWithdrawalRequested(epochId, claimId, user, grossShares, netShares, feeShares)` | EpochedQueueModule | `requestEpochWithdrawal` / instant fallback | +| `EpochWithdrawalCancelled(epochId, claimId, user, grossShares)` | EpochedQueueModule | `cancelEpochWithdrawal` | +| `EpochClosed(epochId, ppsAtClose, totalNetShares, totalNetAssets, totalFeeShares)` | EpochedQueueModule | `closeCurrentEpoch` | +| `EpochFundAttempt(epochId, needed, hotBefore, hotAfter)` | EpochedQueueModule | `fundEpoch` (emitted twice) | +| `EpochFunded(epochId, totalNetAssets)` | EpochedQueueModule | `fundEpoch` success | +| `EpochAssetsClaimed(epochId, claimId, user, assets, netShares)` | EpochedQueueModule | `claimEpochAssets` / `batchClaimEpochAssets` | +| `InstantExit(user, shares, netAssets, feeShares)` | EpochedQueueModule | `requestInstantWithdrawal` settled-immediately path | +| `FeePaid(user, feeCollector, feeShares)` | EpochedQueueModule | `closeCurrentEpoch` batched fee transfer | +| `Crystallized(oldHwm, newHwm, feeAssets)` | EpochedQueueModule | Performance fee crystallization | +| `PerfFeeMinted(oldHwm, ppsBefore, feeShares, ppsAfter)` | EpochedQueueModule | Perf fee mint | | `WithdrawFeeTaken(user, feeShares)` | ERC4626Module | FORCE fee transfer | | `ForceExitPenaltyApplied(user, penaltyAssets)` | ERC4626Module | When `penaltyAssets > 0` | | `ForceWithdrawExecuted(user, assets, shares, feeShares)` | ERC4626Module | `forceWithdraw` completion | | `ForceWithdrawAllExecuted(user, assets, shares, feeShares)` | ERC4626Module | `forceWithdrawAll` completion | | `ForceExit(owner, receiver, assets)` | ERC4626Module | Both FORCE paths | -| `EpochRolled(epochStart, epochDuration)` | ExitEngineLib | On epoch boundary roll | +| `WithdrawalCapEpochRolled(newEpochStart)` | ExitEngineLib | On cap-epoch boundary roll. Renamed from `EpochRolled`: the old name invited indexers to merge the withdrawal-cap window with the settlement queue's `EpochOpened`/`EpochClosed`/`EpochFunded`, which are unrelated | --- ## 8. External Calls -All external calls on the exit path follow the **W2 rule** (never block exits — `src/core/modules/QueueModule.sol:628`): +All external calls on the exit path follow the **W2 rule** (never block exits): | Call | Context | Failure policy | |------|---------|----------------| | `bm.refreshWarmNav()` | `_trySoftRefreshWarmNav()` | `try/catch` — silent failure; exits proceed with stale NAV | -| `bm.refill(required)` | `_settleScan` warm refill step | Called only when `bm != address(0)`; failure propagates only if not try/catch wrapped | +| `bm.refill(deficit)` | `fundEpoch()` warm-refill step | `try/catch`; emits `QueueWarmRefillFailed` on failure, falls through to strategy redeem | | `eng.onExitLight(user, assets × 1e12)` | `_notifyIncentivesExit()` | `try/catch` — silent failure; exit never blocked | +| `router.planRedeem` / `executeRedeemBatch` | `fundEpoch()` strategy-redeem step | `try/catch`; emits `RealizedForQueue` on success, epoch stays `Closed` on failure for later retry | | `router.executeRedeemBatch(plan)` | `_sourceLiquidityForForceWithdraw` | Reverts propagate to `forceWithdraw` caller | | `router.forceRedeemForWithdraw(amount)` | `_forcePullAllLiquidity` | Reverts propagate to `forceWithdrawAll` caller | -The `bm.refill` in `_settleScan` is the sole hot-balance replenishment path during batch settlement. If `bm == address(0)`, settlement proceeds with whatever idle balance the vault holds — claims requiring more hot than available are skipped with `QueueClaimSkippedInsufficientHot`. +The liquidity waterfall inside `fundEpoch()` (warm refill, then strategy redeem) replaces the retired `bm.refill` call inside `QueueModule._settleScan` — it now runs once per epoch instead of once per settle batch, and additionally attempts strategy redemption if warm refill alone doesn't close the gap (the old settle path only attempted warm refill). --- @@ -485,12 +497,16 @@ The `bm.refill` in `_settleScan` is the sole hot-balance replenishment path duri |--------|-----------| | **Cap drain via repeated INSTANT exits** | Epoch roll resets `epochWithdrawn`; cap consumed atomically before settlement in same tx | | **FORCE griefing via dust extraction** | `_checkWithdrawalLimitsForForce` enforces minimum assets for force path; fee applies | -| **Reentrancy during settlement** | `_enterNonReentrant` / `_exitNonReentrant` use `CoreStorage.FLAG_REENTRANCY_LOCKED`; guards on `requestClaim` and `settleFeesAndProcessQueue` | -| **Stale NAV price manipulation** | W2 soft refresh; stale NAV allows settlement but does not block it — attacker cannot force stale-NAV settlement advantageously since they cannot control when NAV was last updated | -| **Queue spam / DoS** | Anti-spam: `cooldownPerClaim`, `maxClaimsPerUserPerEpoch` per epoch; `MAX_CONSECUTIVE_INELIGIBLE = 32` in pre-scan bounds gas per settle call | +| **Reentrancy during settlement** | `_enterNonReentrant` / `_exitNonReentrant` use `CoreStorage.FLAG_REENTRANCY_LOCKED`; guards on `requestEpochWithdrawal`, `requestInstantWithdrawal`, `claimEpochAssets`, `batchClaimEpochAssets` | +| **Stale NAV price manipulation** | W2 soft refresh; stale NAV allows settlement but does not block it | +| **Dynamic-cap bypass via epoch-close timing** | `outstandingClaimCount()` persists across epoch boundaries — a claim landing before an epoch closes still counts against the dynamic-cap "queue depth" signal after the close, closing a bug window that existed in an earlier version of this module (fixed pre-cutover; regression-tested in `EpochedQueueModule.t.sol`) | | **Fee rounding theft (sub-1-share dust)** | `feeShares` rounded UP (ceiling) — ensures protocol never receives 0 shares on a non-zero-fee exit | -| **Force exit in restricted FM state** | `_checkForceExitAllowed()` (`src/core/storage/FixedMaturityStorage.sol:111`) reverts for Funding/Starting/Closed/FundingFailed states | -| **Cross-epoch cap evasion (timer manipulation)** | Epoch boundary is computed as `epochStart + epochDuration * n` — cannot be advanced by caller; `block.timestamp` read-only | +| **Force exit in restricted FM state** | `_checkForceExitAllowed()` reverts for Funding/Starting/Closed/FundingFailed states | +| **Cross-epoch cap evasion (cap-epoch timer manipulation)** | Cap-epoch boundary is computed as `epochStart + epochDuration * n` — cannot be advanced by caller | +| **Settlement epoch closed prematurely** | `closeCurrentEpoch()` reverts `EpochTooYoung` until `minEpochDuration` has elapsed since the epoch opened; permissionless but time-gated | +| **Epoch funded while under-collateralized** | `fundEpoch()` only transitions to `Funded` when `hot >= totalNetAssets` — an explicit check, not an assumption | + +**Discontinued mitigation (flagged, not silently dropped)**: the retired `QueueModule` enforced per-user anti-spam via `cooldownPerClaim`/`maxClaimsPerUserPerEpoch` (`_checkQueueAntiSpam`). `EpochedQueueModule` has no equivalent per-user rate limit on `requestEpochWithdrawal`/`requestInstantWithdrawal`. This is a deliberate simplification enabled by the architecture change: since settlement cost no longer scales with the number of individual claims (`fundEpoch()` is O(1) regardless of claim count — see §6.1), the original DoS rationale for per-user claim throttling is substantially weaker. Confirm this is an acceptable tradeoff for the deployment's expected exit volume before relying on it. --- @@ -499,36 +515,42 @@ The `bm.refill` in `_settleScan` is the sole hot-balance replenishment path duri ### 10.1 Standard withdrawal ``` -User: requestClaim(false, 1000e18) +User: requestEpochWithdrawal(1000e18) 1. FM gate check (if applicable) - 2. Reentrancy lock - 3. _ensureFreshWarmNav() - 4. rollEpochIfNeeded(core) - 5. Escrow: _transferShares(user, vault, 1000e18) - 6. Store Claim{user, ts=now, immediate=false, shares=1000e18} - 7. queue.push(claimId), pendingShares += 1000e18 - 8. Emit ClaimQueued(claimId, user, 1000e18, false) - -Later — Admin: settleFeesAndProcessQueue(20) - 1. epoch roll - 2. cachedTA = totalAssets(), cachedTS = totalSupply() // snapshot once - 3. calculateCapRemaining() → capRem - 4. _settleScan → _boundedPreScan → _settleLoop - Claim{immediate=false}: lockPeriod check passes - feeShares = mulBpsUp(1000e18, witBps=50) = 5e18 - userShares = 995e18 - net = 995e18 * cachedTA / cachedTS - _transferShares(vault, feeCollector, 5e18) - _burn(vault, 995e18) - token.safeTransfer(user, net) - Emit ClaimSettled(claimId, user, net) + 2. epoch 0 lazily opened if this is the first-ever submission + 3. _trySoftRefreshWarmNav() — try/catch + 4. computeFeeShares(1000e18, STANDARD, fee) → feeShares=5e18 (witBps=50), netShares=995e18 + 5. Escrow: _transferShares(user, vault, 1000e18) // FULL gross, not just net + 6. claimId = ++nextClaimId[0] + 7. claims[0][claimId] = EpochClaim{user, netShares=995e18, feeShares=5e18, claimed=false} + 8. escrowedShares += 1000e18; outstandingClaimCount += 1 + 9. Emit EpochWithdrawalRequested(0, claimId, user, 1000e18, 995e18, 5e18) + +Later — Keeper: closeCurrentEpoch() + 1. block.timestamp >= openedAt + minEpochDuration, else EpochTooYoung + 2. ppsAtClose = totalAssets() * WAD / totalSupply() // locked, once, for the WHOLE epoch + 3. epoch.totalNetAssets = totalNetShares * ppsAtClose / WAD + 4. _transferShares(vault, feeCollector, epoch.totalFeeShares) // ONE batched transfer + 5. escrowedShares -= totalFeeShares + 6. Open epoch 1 immediately + 7. Emit EpochClosed(0, ppsAtClose, totalNetShares, totalNetAssets, totalFeeShares) + +Keeper: fundEpoch(0) + 1. hot = balanceOf(vault); if hot < totalNetAssets: try warm refill, then strategy redeem + 2. if hot >= totalNetAssets: state = Funded; emit EpochFunded(0, totalNetAssets) + +User: claimEpochAssets(0, claimId) + 1. assets = 995e18 * ppsAtClose / WAD + 2. claim.claimed = true; escrowedShares -= 995e18; outstandingClaimCount -= 1 + 3. _burn(vault, 995e18); token.safeTransfer(user, assets) + 4. Emit EpochAssetsClaimed(0, claimId, user, assets, 995e18) ``` ### 10.2 Instant withdrawal (success path) ``` -User: requestClaim(true, 1000e18) - → _canSettleInstant(): +User: requestInstantWithdrawal(1000e18) + → _canInstant(): lockPeriod=0 ✓ gross=~995 USDC, capRem=10_000 USDC ✓ hot=50_000 USDC ✓ @@ -541,18 +563,21 @@ User: requestClaim(true, 1000e18) → net = convertToAssets(985e18) → token.safeTransfer(user, net) → consumeEpochCap(core, ~995 USDC) - → Emit ClaimQueued NOT emitted (no queue entry) + → emit InstantExit(user, 1000e18, net, 15e18) + → return (settledImmediately=true, epochId=0, claimId=0) + → NOTE: no EpochClaim created — this exit never touches EpochQueueStorage ``` -### 10.3 Instant fallback to queue +### 10.3 Instant fallback to the epoch queue ``` -User: requestClaim(true, 1000e18) - → _canSettleInstant(): +User: requestInstantWithdrawal(1000e18) + → _canInstant(): capRem=0 ✗ (epoch cap exhausted) - → Queue path, stored with immediate=false - → Claim{user, ts, immediate=false, shares=1000e18} - → NOTE: no cap check at settlement; settles as STANDARD + → Falls back to the exact same path as requestEpochWithdrawal(1000e18) + → EpochClaim{user, netShares, feeShares, claimed=false} recorded in the current open epoch + → return (settledImmediately=false, epochId, claimId) + → NOTE: no cap check ever applies to this claim again; it settles as ordinary STANDARD ``` ### 10.4 Force withdrawal with plan @@ -645,11 +670,15 @@ Step 12: Emit ForceWithdrawAllExecuted, ForceExit | Case | Behavior | |------|---------| -| `requestClaim(true)` when epoch cap = 0 | Falls back to queue with `immediate=false`; settles as STANDARD with no cap check | -| `requestClaim(true)` when hot < gross | Falls back to queue (hot check is third gate in `_canSettleInstant`) | -| Settle loop: `capRem=0` during batch | INSTANT claims (`c.immediate=true`) skipped; STANDARD claims proceed via lockPeriod check only | -| `hot < gross` for a claim in settle loop | Claim skipped; `QueueClaimSkippedInsufficientHot` emitted; claim remains at queue position for next batch | -| All claims ineligible for 32 consecutive entries | `hitEarlyExit=true`; pre-scan terminates; `_settleLoop` runs on zero eligible entries (no-op) | +| `requestInstantWithdrawal` when epoch cap = 0 | Falls back to the epoch queue; settles as STANDARD with no cap check | +| `requestInstantWithdrawal` when hot < gross | Falls back to the epoch queue (hot check is the third gate in `_canInstant`) | +| `closeCurrentEpoch()` called before `minEpochDuration` elapsed | Reverts `EpochTooYoung()` — permissionless but time-gated | +| `closeCurrentEpoch()` on an epoch with zero claims | Succeeds; `ppsAtClose`/`totalNetAssets` compute sanely (no division issues); next epoch opens normally | +| `fundEpoch()` called on an already-`Funded` epoch | Reverts `EpochAlreadyFunded()` — callers must guard against double-funding, it is not silently idempotent | +| `fundEpoch()` partial funding (hot still < totalNetAssets after waterfall) | Epoch remains `Closed`; retry `fundEpoch()` later as more liquidity becomes available; `oldestUnfundedEpochId` cursor does not advance past it | +| `claimEpochAssets` on a not-yet-`Funded` epoch | Reverts `EpochNotFunded()` | +| `claimEpochAssets` called twice for the same claim | Second call reverts `ClaimAlreadySettled()` | +| `cancelEpochWithdrawal` after the epoch has closed | Reverts — cancellation is only possible while the epoch is still `Open` | | `totalSupply = 0` at crystallize, vault also holds 0 assets | Genuine fresh start: HWM reset to WAD, zero perf fee, `lastCrystallize` updated | | `totalSupply = 0` at crystallize, but dust assets remain | HWM preserved (not reset to WAD); zero perf fee; `lastCrystallize` left untouched (no-op — prevents free griefing of the interval clock via a `ROLE_PUBLIC` caller) | | FORCE on FixedMaturity/Funding | `_checkForceExitAllowed()` reverts | @@ -657,7 +686,7 @@ Step 12: Emit ForceWithdrawAllExecuted, ForceExit | `forceWithdrawAll`: hot < targetAssets, `assetsReceived >= minAssetsOut` | Best-effort: `assetsReceived = min(hot, targetAssets)` after `_forcePullAllLiquidity`; proportional burn, no revert | | `forceWithdrawAll`: `assetsReceived < minAssetsOut` | Reverts `SlippageExceeded` (F-03); no shares burned, no fees transferred, no state changed | | `witBps=0` and `forceExitPenaltyBps=0` | `feeShares=0`; fee transfer skipped; user receives full `grossShares` | -| Epoch multi-skip (vault inactive N epochs) | `rollEpochIfNeeded` iterates until `epochStart` is within one duration of `block.timestamp` | +| Cap-epoch multi-skip (vault inactive N epochs) | `rollEpochIfNeeded` iterates until `epochStart` is within one duration of `block.timestamp` — the settlement epoch is unaffected (it only advances via explicit `closeCurrentEpoch()` calls) | --- @@ -665,12 +694,14 @@ Step 12: Emit ForceWithdrawAllExecuted, ForceExit | Term | Definition | |------|-----------| -| **epoch** | Rolling time window (1d–30d) within which INSTANT withdrawal cap is tracked; `epochStart` stored in `CoreStorage` | -| **epochWithdrawn** | Cumulative INSTANT assets withdrawn in the current epoch (`CoreStorage.Layout.epochWithdrawn`) | -| **epoch cap** | Maximum total INSTANT assets per epoch; static (`maxWithdrawPerEpoch`) or dynamic via `WithdrawalCapLib` | +| **cap epoch** | Rolling time window (1d–30d) within which INSTANT withdrawal cap is tracked; `epochStart` stored in `CoreStorage` — distinct from the settlement epoch | +| **settlement epoch** | A batch of STANDARD claims sharing one locked `ppsAtClose` and one `fundEpoch()` liquidity pull; see `docs/queue-mechanics.md` | +| **epochWithdrawn** | Cumulative INSTANT assets withdrawn in the current cap epoch (`CoreStorage.Layout.epochWithdrawn`) | +| **epoch cap** | Maximum total INSTANT assets per cap epoch; static (`maxWithdrawPerEpoch`) or dynamic via `WithdrawalCapLib` | | **hot balance** | Idle underlying token held directly by the vault: `IERC20(_asset()).balanceOf(address(this))` | -| **escrow** | `address(this)` — vault contract address that holds shares for queued claims | +| **escrow** | `address(this)` — vault contract address that holds shares for open/closed-unfunded epoch claims | | **PPS** | Price per share = `totalAssets / totalSupply` (WAD-scaled, 1e18 base) | +| **ppsAtClose** | PPS locked once at `closeCurrentEpoch()`; used for every claim in that epoch, forever | | **HWM** | High water mark — peak PPS above which performance fee is charged (`FeeStorage.Layout.highWaterMark`) | | **feeShares** | Shares transferred to `feeCollector` as protocol fee on exit | | **witBps** | Withdrawal fee in basis points — applied to all three modes | @@ -685,54 +716,45 @@ Step 12: Emit ForceWithdrawAllExecuted, ForceExit ## Appendix: Code Reference Index -Key functions with canonical file paths (for auditor cross-referencing): - | Function | File | Line | -|----------|------|-------------| -| `ExitMode` enum | `src/core/libraries/ExitEngineLib.sol:28` | L8 | -| `ExitResult` struct | `src/core/libraries/ExitEngineLib.sol:34` | L18 | -| `rollEpochIfNeeded` | `src/core/libraries/ExitEngineLib.sol:77` | L40 | -| `calculateCapRemaining` | `src/core/libraries/ExitEngineLib.sol:106` | L60 | -| `simulateExit` | `src/core/libraries/ExitEngineLib.sol:202` | L80 | -| `consumeEpochCap` | `src/core/libraries/ExitEngineLib.sol:258` | L145 | -| `computeFeeShares` | `src/core/libraries/ExitEngineLib.sol:151` | L155 | -| `computeExitFee` | `src/core/libraries/ExitFeeLib.sol:29` | L20 | -| `exitFeeBps` | `src/core/libraries/ExitFeeLib.sol:61` | L10 | -| `requestClaim` | `src/core/modules/QueueModule.sol:81` | L100 | -| `settleFeesAndProcessQueue` | `src/core/modules/QueueModule.sol:207` | L200 | -| `_canSettleInstant` | `src/core/modules/QueueModule.sol:528` | L528 | -| `_boundedPreScan` | `src/core/modules/QueueModule.sol:303` | L350 | -| `_settleLoop` | `src/core/modules/QueueModule.sol:407` | L430 | -| `_trySoftRefreshWarmNav` | `src/core/modules/QueueModule.sol:628` | L626 | -| `_crystallize` | `src/core/modules/QueueModule.sol:763` | L763 | -| `forceWithdraw` | `src/core/modules/ERC4626Module.sol:166` | L100 | -| `forceWithdrawAll` | `src/core/modules/ERC4626Module.sol:272` | L220 | -| `_checkForceExitAllowed` | `src/core/storage/FixedMaturityStorage.sol:111` | L105 | -| `InternalFeeParams` struct | `src/core/storage/FeeStorage.sol:12` | L20 | -| `QueueStorage.Layout` | `src/core/storage/QueueStorage.sol:24` | L10 | -| `Claim` struct | `src/core/storage/QueueStorage.sol:16` | L18 | +|----------|------|------| +| `ExitMode` enum | `src/core/libraries/ExitEngineLib.sol` | 28 | +| `ExitResult` struct | `src/core/libraries/ExitEngineLib.sol` | ~34 | +| `rollEpochIfNeeded` | `src/core/libraries/ExitEngineLib.sol` | ~77 | +| `simulateExit` | `src/core/libraries/ExitEngineLib.sol` | ~202 | +| `consumeEpochCap` | `src/core/libraries/ExitEngineLib.sol` | ~258 | +| `computeFeeShares` | `src/core/libraries/ExitEngineLib.sol` | ~151 | +| `computeExitFee` | `src/core/libraries/ExitFeeLib.sol` | ~29 | +| `exitFeeBps` | `src/core/libraries/ExitFeeLib.sol` | ~61 | +| `requestEpochWithdrawal` / `_requestEpochWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 212 / 228 | +| `requestInstantWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 698 | +| `cancelEpochWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 290 | +| `closeCurrentEpoch` | `src/core/modules/EpochedQueueModule.sol` | 327 | +| `fundEpoch` | `src/core/modules/EpochedQueueModule.sol` | 390 | +| `claimEpochAssets` / `batchClaimEpochAssets` | `src/core/modules/EpochedQueueModule.sol` | 470 / 515 | +| `endEpochCrystallize` / `_crystallize` | `src/core/modules/EpochedQueueModule.sol` | 566 / 576 | +| `_canInstant` / `_epochCapRemaining` | `src/core/modules/EpochedQueueModule.sol` | 917 / 946 | +| `forceWithdraw` | `src/core/modules/ERC4626Module.sol` | 166 | +| `forceWithdrawAll` | `src/core/modules/ERC4626Module.sol` | 272 | +| `_checkForceExitAllowed` | `src/core/storage/FixedMaturityStorage.sol` | 111 | +| `InternalFeeParams` struct | `src/core/storage/FeeStorage.sol` | ~12 | +| `EpochQueueStorage.Layout` | `src/core/modules/EpochedQueueModule.sol` | 59 | +| `EpochClaim` struct | `src/core/modules/EpochedQueueModule.sol` | 52 | +| Reserved (unused) legacy slot | `src/core/storage/QueueStorage.sol` | kept only for EIP-7201 slot-collision safety | --- ## Footer -**Source commit**: `c39f9462` (branch `reorg/runbook-docs-consolidate-01a.2`) - -**Authoritative files read** (ADR-015 §2 workflow): - -| File | Lines | Notes | -|------|-------|-------| -| `src/core/libraries/ExitEngineLib.sol:202` | 279 | Full read | -| `src/core/libraries/ExitFeeLib.sol:29` | 77 | Full read | -| `src/core/modules/QueueModule.sol:207` | 841 | Full read | -| `src/core/modules/ERC4626Module.sol:166` | L62–L336 | Force section | -| `src/core/storage/FeeStorage.sol:12` | 79 | Full read | -| `src/core/storage/FixedMaturityStorage.sol:111` | 123 | Full read | -| `src/core/storage/QueueStorage.sol:24` | 38 | Full read | -| `src/core/storage/CoreStorage.sol:38` | partial | epochWithdrawn, epochStart, paramMinDelay | +**Source commit**: `f7e3544` -**Discrepancies** (ADR-015 §5): +**Migration note**: This document was rewritten for the `EpochedQueueModule` cutover. +`QueueModule.sol` (FIFO array, keeper-scanned settle loop) was fully deleted; all queue +selectors now route exclusively to `EpochedQueueModule` (epoch-bucketed, close → fund → +pull-claim). See `docs/queue-mechanics.md` for the complete queue-side writeup and +`git log` on this file for the pre-migration (`QueueModule`-based) version of this document. -1. `src/core/mixins/PerfFeeMixin.sol:1` (pragma `0.8.24`) contains a legacy `_crystallize()` using a struct-based `perf` storage field. The active implementation is `QueueModule._crystallize()` using `FeeStorage.Layout` (EIP-7201 namespaced). `PerfFeeMixin` is not imported by any active module on `c39f9462`. +**Known discrepancy carried forward from the pre-migration version**: -2. `src/core/mixins/FeeMixin.sol:1` (pragma `0.8.24`) uses a 3-field `InternalFeeParams` (no `immediateExitPenaltyBps`, no `forceExitPenaltyBps`). Active fee params use the 5-field struct in `FeeStorage.sol`. `FeeMixin` is not imported by any active module. +1. `src/core/mixins/PerfFeeMixin.sol` (pragma `0.8.24`) contains a legacy `_crystallize()` using a struct-based `perf` storage field. The active implementation is `EpochedQueueModule._crystallize()` using `FeeStorage.Layout` (EIP-7201 namespaced). `PerfFeeMixin` is not imported by any active module. +2. `src/core/mixins/FeeMixin.sol` (pragma `0.8.24`) uses a 3-field `InternalFeeParams` (no `immediateExitPenaltyBps`, no `forceExitPenaltyBps`). Active fee params use the 5-field struct in `FeeStorage.sol`. `FeeMixin` is not imported by any active module. diff --git a/docs/fee-policy.md b/docs/fee-policy.md index 3d5bcea..f01aad6 100644 --- a/docs/fee-policy.md +++ b/docs/fee-policy.md @@ -23,12 +23,12 @@ Fee parameters live in `FeeStorage.Layout` (EIP-7201 namespaced, `src/core/stora Fee computation lives in two libraries: - `src/core/libraries/ExitFeeLib.sol:29` — on-exit fee calculation -- `src/core/modules/QueueModule.sol:763` (`_crystallize`) — performance fee crystallization +- `src/core/modules/EpochedQueueModule.sol` (`_crystallize`) — performance fee crystallization ```mermaid flowchart LR A[FeeStorage.Layout\nwitBps\nimmediateExitPenaltyBps\nforceExitPenaltyBps\ndepBps\nperfRateX\nhighWaterMark] -->|read by| EFL[ExitFeeLib\ncomputeExitFee] - A -->|read by| QM[QueueModule\n_crystallize] + A -->|read by| QM[EpochedQueueModule\n_crystallize] EFL -->|feeShares transferred| FC[feeCollector] QM -->|feeShares minted| FC @@ -223,7 +223,9 @@ newHwm = totalAssets / (totalSupply + 1_980_198e12) ≈ 1.0078 USDC/share ### 5.2 Crystallization trigger -`_crystallize()` is called at the end of every `settleFeesAndProcessQueue` call (`src/core/modules/QueueModule.sol:238`). It is NOT called on INSTANT exits or FORCE exits — only during the scheduled batch settle. +`_crystallize()` is reached only through `endEpochCrystallize()`, which is permissionless and independent of the queue lifecycle. It is NOT called by `closeCurrentEpoch()`, `fundEpoch()`, instant exits or force exits. + +Consequence worth stating plainly: `ppsAtClose` is snapshotted gross of any pending performance fee, and nothing orders `endEpochCrystallize()` against `closeCurrentEpoch()`. Both are public, so whoever calls first decides whether an epoch's claimants exit before or after the crystallization dilutes the price. The keeper schedules CRYSTALLIZE below the queue ops, so under continuous exit flow it can be deferred. Pre-conditions for fee to be minted: 1. `totalSupply > 0` @@ -242,7 +244,7 @@ Branch behavior when the interval guard (condition 3) blocks a would-be-profitab ### 5.4 PerfFeeMixin (legacy) -`src/core/mixins/PerfFeeMixin.sol:73` contains an older `_crystallize()` using a `Perf` struct stored in contract storage (not EIP-7201 namespaced). This mixin is **not imported by any active module** on `c39f9462`. The active perf fee logic is in `QueueModule._crystallize()` (`src/core/modules/QueueModule.sol:763`), which uses `FeeStorage.Layout`. See Footer §Discrepancies. +`src/core/mixins/PerfFeeMixin.sol:73` contains an older `_crystallize()` using a `Perf` struct stored in contract storage (not EIP-7201 namespaced). This mixin is **not imported by any active module** on `c39f9462`. The active perf fee logic is in `EpochedQueueModule._crystallize()`, which uses `FeeStorage.Layout`. See Footer §Discrepancies. This branch applied the same HWM-monotonicity and min-interval-enforcement fixes (§5.2, §5.3) to `PerfFeeMixin._crystallize()` for consistency, even though it remains dead code with no active caller. @@ -292,9 +294,9 @@ totalForceBps = witBps + forceExitPenaltyBps + preMaturityForceExitPenaltyBps | `FeeParamsRevoked()` | AdminModule | `revokeFeeParams` | | `PerfParamsSubmitted(rateX, minInterval, eta)` | AdminModule | `submitPerfParams` | | `PerfParamsAccepted(rateX, minInterval)` | AdminModule | `acceptPerfParams` | -| `FeePaid(user, feeCollector, feeShares)` | QueueModule | Exit fee transfer in settle loop | -| `Crystallized(oldHwm, newHwm, feeAssets)` | QueueModule | Crystallization (fee or not) | -| `PerfFeeMinted(oldHwm, ppsBefore, feeShares, ppsAfter)` | QueueModule | When perf fee > 0 | +| `FeePaid(user, feeCollector, feeShares)` | EpochedQueueModule | Batch fee transfer at epoch close, and on the instant exit path | +| `Crystallized(oldHwm, newHwm, feeAssets)` | EpochedQueueModule | Crystallization (fee or not) | +| `PerfFeeMinted(oldHwm, ppsBefore, feeShares, ppsAfter)` | EpochedQueueModule | When perf fee > 0 | --- @@ -397,7 +399,7 @@ Day 3, 14:00: owner calls acceptFeeParams() | `revokeFeeParams` | `src/core/modules/AdminModule.sol:141` | L141 | | `_validateEta` | `src/core/modules/AdminModule.sol:812` | L812 | | `MAX_WINDOW` constant | `src/core/modules/AdminModule.sol:45` | L45 | -| `_crystallize` (active) | `src/core/modules/QueueModule.sol:763` | L763 | +| `_crystallize` (active) | `src/core/modules/EpochedQueueModule.sol` | — | | `computeExitFee` | `src/core/libraries/ExitFeeLib.sol:29` | L20 | | `exitFeeBps` | `src/core/libraries/ExitFeeLib.sol:61` | L10 | | `preMaturityForceExitPenaltyBps` | `src/core/storage/FixedMaturityStorage.sol:47` | L80 | @@ -418,14 +420,14 @@ Day 3, 14:00: owner calls acceptFeeParams() |------|-------|-------| | `src/core/storage/FeeStorage.sol:55` | 79 | Full read | | `src/core/modules/AdminModule.sol:73` | partial | submitFeeParams, acceptFeeParams, revokeFeeParams, _validateEta | -| `src/core/modules/QueueModule.sol:763` | 841 | Full read — `_crystallize()` section | +| `src/core/modules/EpochedQueueModule.sol` | — | Full read — `_crystallize()` section | | `src/core/mixins/PerfFeeMixin.sol:73` | 108 | Full read (legacy) | | `src/core/libraries/ExitFeeLib.sol:29` | 77 | Full read | | `src/core/storage/FixedMaturityStorage.sol:47` | 123 | Full read — `preMaturityForceExitPenaltyBps` | **Discrepancies** (ADR-015 §5): -1. `PerfFeeMixin.sol` (pragma `0.8.24`) implements `_crystallize()` using a struct `Perf { hwm, rateX, minInterval, last, init }` stored in contract storage (not EIP-7201). The active implementation is `QueueModule._crystallize()` using `FeeStorage.Layout.perfRateX` and `FeeStorage.Layout.highWaterMark`. `PerfFeeMixin` is not imported by any active module. +1. `PerfFeeMixin.sol` (pragma `0.8.24`) implements `_crystallize()` using a struct `Perf { hwm, rateX, minInterval, last, init }` stored in contract storage (not EIP-7201). The active implementation is `EpochedQueueModule._crystallize()` using `FeeStorage.Layout.perfRateX` and `FeeStorage.Layout.highWaterMark`. `PerfFeeMixin` is not imported by any active module. 2. `FeeMixin.sol` (pragma `0.8.24`) uses a 3-field `InternalFeeParams { depBps, witBps, treasury }` — missing `immediateExitPenaltyBps` and `forceExitPenaltyBps`. This is a superseded design. `FeeMixin` is not imported by any active module. @@ -470,7 +472,7 @@ Design rationale: without a penalty, all users would prefer INSTANT over STANDAR Performance fee uses a High Water Mark (HWM) model. Fee is charged only on profits above the last peak PPS. -Formula (active implementation in `QueueModule._crystallize()`, `src/core/modules/QueueModule.sol:763`): +Formula (active implementation in `EpochedQueueModule._crystallize()`): ``` pps = totalAssets / totalSupply (WAD-scaled, 1e18) diff --git a/docs/modules.md b/docs/modules.md index 6a06964..852ad75 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -10,7 +10,7 @@ 1. [Overview](#1-overview) 2. [ERC4626Module](#2-erc4626module) -3. [QueueModule](#3-queuemodule) +3. [EpochedQueueModule](#3-epochedqueuemodule) 4. [AdminModule](#4-adminmodule) 5. [BufferManager](#5-buffermanager) 6. [FixedMaturityModule](#6-fixedmaturitymodule) @@ -42,7 +42,7 @@ Multyr Core uses a Diamond-lite architecture where all economic logic is impleme | Category | Modules | Deployment pattern | |---|---|---| -| Delegatecall modules | ERC4626Module, QueueModule, AdminModule, LiquidityOpsModule, FixedMaturityModule | External contracts, invoked via `delegatecall` | +| Delegatecall modules | ERC4626Module, EpochedQueueModule, AdminModule, LiquidityOpsModule, FixedMaturityModule | External contracts, invoked via `delegatecall` | | Standalone modules | BufferManager, FeeCollector, BatchGuardrails, PriceOracleMiddleware, ExecutionMemory | Standard external contracts, external call | | Strategy infrastructure | StrategyRouter, StrategyHealthRegistry | Standalone external contracts; called by LiquidityOpsModule | | V10 allocation | StrategyScorer, RouterAllocationPolicy, RouterRebalanceGuard | Standalone view / guard contracts; called by LiquidityOpsModule and StrategyRouter | @@ -64,7 +64,7 @@ Multyr Core uses a Diamond-lite architecture where all economic logic is impleme ### 2.1 Role -Handles all user-facing deposit and force-exit operations. Standard `withdraw()` / `redeem()` always revert — users must use `QueueModule.requestClaim()` for queue-based exits. +Handles all user-facing deposit and force-exit operations. Standard `withdraw()` / `redeem()` always revert — users must use `EpochedQueueModule.requestInstantWithdrawal()` / `requestEpochWithdrawal()` for queue-based exits. ### 2.2 Public Functions @@ -159,104 +159,121 @@ Source: `src/core/modules/ERC4626Module.sol:163-250`. --- -## 3. QueueModule +## 3. EpochedQueueModule -**File**: `src/core/modules/QueueModule.sol` -**Version**: v6 (ExitEngineLib Architecture) +**File**: `src/core/modules/EpochedQueueModule.sol` **Delegatecall**: yes -**Storage namespaces**: `CoreStorage`, `QueueStorage`, `FeeStorage`, `FixedMaturityStorage` +**Storage namespaces**: `CoreStorage`, `EpochQueueStorage`, `FeeStorage`, `FixedMaturityStorage` + +> **History**: this module replaced `QueueModule.sol` (a FIFO array with a keeper-scanned +> settle loop) as the sole production queue-settlement mechanism. `QueueModule.sol` has been +> deleted; see `docs/queue-mechanics.md` for the full behavioral writeup and migration notes. +> The retired `QueueStorage.sol` layout is kept only as a permanently-reserved EIP-7201 slot — +> no live code reads or writes it. ### 3.1 Role -Manages the async exit queue: accepts claim requests, processes batch settlements, crystallizes performance fees, and manages epoch rollover. +Manages the async exit queue using Renzo ezETH-style epoch batching: accepts claim requests +into a currently-open epoch, closes the epoch to lock a single price-per-share for every claim +in it, pulls liquidity once per epoch, and lets users self-serve their claim via a pull-based +call. Also owns performance-fee crystallization and NAV smoothing (decoupled from the epoch +lifecycle — see `docs/queue-mechanics.md` §7). ### 3.2 Public Functions | Function | Access | Description | |---|---|---| -| `requestClaim(bool immediate, uint256 shares)` | PUBLIC | Submit exit: instant or queued | -| `cancelClaim(uint256 claimId)` | PUBLIC | Cancel pending queued claim | -| `processQueuedRedemptions(uint256 maxClaims)` | PUBLIC | Process queue (no cap enforcement) | -| `settleFeesAndProcessQueue(uint256 maxClaims)` | PUBLIC | Process queue with epoch cap | -| `endEpochCrystallize()` | PUBLIC | Crystallize perf fee + update NAV smoothing | -| `compactQueue()` | PUBLIC | GC: remove processed head entries from queue array | -| `nextClaimId()` | PUBLIC view | Auto-increment counter | -| `queueLength()` | PUBLIC view | Active queue length (from head to end) | -| `pendingShares()` | PUBLIC view | Total shares in escrow | -| `requiredHotForBatch(uint256 maxClaims)` | PUBLIC view | USDC needed for next settle batch | -| `settlePreview(uint256 maxClaims)` | PUBLIC view | Preview of settle outcome | - -Source: `src/core/modules/QueueModule.sol:81-296`. - -### 3.3 requestClaim Decision Tree +| `requestEpochWithdrawal(uint256 shares)` | PUBLIC | Submit a standard (queued) withdrawal into the current open epoch | +| `cancelEpochWithdrawal(uint256 epochId, uint256 claimId)` | PUBLIC | Cancel a claim while its epoch is still Open | +| `closeCurrentEpoch()` | PUBLIC | Lock PPS for the current epoch, open the next one | +| `fundEpoch(uint256 epochId)` | PUBLIC | Pull liquidity (warm refill → strategy redeem) for a Closed epoch | +| `claimEpochAssets(uint256 epochId, uint256 claimId)` | PUBLIC | Self-serve claim from a Funded epoch | +| `batchClaimEpochAssets(uint256 epochId, uint256[] claimIds)` | PUBLIC | Batch self-serve claim for one user's multiple claims | +| `requestInstantWithdrawal(uint256 shares)` | PUBLIC | Cap-eligible instant exit; falls back to the epoch queue otherwise | +| `endEpochCrystallize()` | PUBLIC | Crystallize perf fee + update NAV smoothing (independent of epoch state) | +| `currentEpochId()` / `epochData(id)` / `epochClaim(id, claimId)` | PUBLIC view | Epoch and claim state | +| `nextClaimIdForEpoch(id)` | PUBLIC view | Next claim ID counter for a given epoch | +| `totalEscrowedShares()` | PUBLIC view | Total shares in escrow across all epochs | +| `outstandingClaimCount()` | PUBLIC view | Total unclaimed claims across all epochs (dynamic-cap signal) | +| `oldestUnfundedEpochId()` | PUBLIC view | Keeper cursor — oldest Closed-not-yet-Funded epoch | +| `epochDeficit(id)` | PUBLIC view | Remaining liquidity shortfall for a Closed epoch | +| `canCloseCurrentEpoch()` / `currentEpochClaimCount()` | PUBLIC view | Keeper eligibility + anti-churn checks | + +Source: `src/core/modules/EpochedQueueModule.sol:212-833`. + +### 3.3 requestEpochWithdrawal / requestInstantWithdrawal Decision Tree ``` -requestClaim(immediate, shares): - 1. _checkStandardExitAllowed() — FixedMaturity gate - 2. _enterNonReentrant() - 3. rollEpochIfNeeded() — parametric epoch duration - 4. gross = convertToAssets(shares) - 5. Check minClaimAmount - 6. _checkQueueAntiSpam() — cooldown + per-epoch count - 7. if (immediate AND _canSettleInstant()): +requestInstantWithdrawal(shares): + 1. _checkStandardExitAllowed(fm, immediate=true) + 2. _trySoftRefreshWarmNav(); rollEpochIfNeeded() — the CAP epoch (ExitEngineLib), not the settlement epoch + 3. gross = convertToAssets(shares) + 4. if _canInstant(gross, wp, core): INSTANT PATH: - - computeFeeShares(INSTANT) - - processorTransfer(user → feeCollector, feeShares) - - processorBurn(user, userShares) - - safeTransfer(user, netAssets) - - consumeEpochCap(gross) + - computeFeeShares(shares, INSTANT, fee) + - _transferShares(user → feeCollector, feeShares); _burn(user, netShares) + - safeTransfer(user, netAssets); consumeEpochCap(gross) - emit InstantExit + - return (settledImmediately=true, epochId=0, claimId=0) else: - QUEUE PATH: - - processorTransfer(user → vault, shares) [escrow] - - create Claim{user, ts, immediate=false, settled=false, shares} - - push claimId to queue - - emit ClaimQueued + FALLBACK — same as requestEpochWithdrawal(shares): + - _transferShares(user → vault, shares) [escrow ALL gross shares] + - claimId = ++nextClaimId[epochId]; record EpochClaim{user, netShares, feeShares, claimed=false} + - escrowedShares += shares; outstandingClaimCount += 1 + - emit EpochWithdrawalRequested + - return (settledImmediately=false, epochId, claimId) ``` -Source: `src/core/modules/QueueModule.sol:81-169`. - -### 3.4 Settlement Algorithm +Callers must branch on `settledImmediately` — a cap-exhausted instant request never reverts, +it silently becomes a standard epoch claim (W2 rule). -`_settleScan()` implements a bounded three-step algorithm: +Source: `src/core/modules/EpochedQueueModule.sol:212-289, 698-749`. -**Step A — Pre-scan** (`_boundedPreScan`): -- Scans up to `maxClaims * 2` queue entries. -- Stops after `MAX_CONSECUTIVE_INELIGIBLE = 32` consecutive ineligible entries. -- Outputs: `requiredHot`, `eligibleCount`, `scanWindowEnd`. +### 3.4 Settlement: Close → Fund → Claim -**Step B — Warm refill**: -- If `hot < requiredHot`, attempts `bm.refill(warmGap)` (try/catch). -- Warm refill only — no strategy redeem in settle path. +Settlement is a three-step, **epoch-wide** (not per-claim) process — the core structural +difference from the old per-claim settle loop: -**Step C — Settle loop** `[head, scanWindowEnd)`: -- Per-claim: escrow invariant check → eligibility check → hot liquidity check → ExitEngineLib fee → settle. -- Pricing: cached `(cachedTA, cachedTS)` snapshot for deterministic intra-batch PPS. -- Head advancement: after loop, head advances past leading settled/ghost entries. +**Step A — `closeCurrentEpoch()`** (permissionless, gated on `minEpochDuration`): +- Snapshots `ppsAtClose = totalAssets/totalSupply` once for the whole epoch. +- Batch-transfers accumulated fee shares to `feeCollector` in one call. +- Opens the next epoch immediately so new submissions are never blocked. -Source: `src/core/modules/QueueModule.sol:358-521`. +**Step B — `fundEpoch(epochId)`** (permissionless, repeatable): +- One liquidity pull covers the epoch's entire net liability, not a per-batch slice. +- Waterfall: warm refill first (`bm.refill`), then strategy redeem (`router.planRedeem` / + `executeRedeemBatch`) for any remaining gap — both try/catch, W2 rule. +- Epoch transitions to `Funded` only once `hot >= totalNetAssets`; otherwise stays `Closed` + for a later retry. -### 3.5 Epoch Management +**Step C — `claimEpochAssets(epochId, claimId)`** (pull-based, per claimant, any time after Funded): +- `assets = claim.netShares * epoch.ppsAtClose / WAD` — deterministic, no live-PPS exposure. +- No keeper required for a user to receive funds. -`ExitEngineLib.rollEpochIfNeeded()` is called at the start of `requestClaim`, `processQueuedRedemptions`, and `settleFeesAndProcessQueue`. It aligns `epochStart` to the nearest epoch boundary relative to the original start: +Source: `src/core/modules/EpochedQueueModule.sol:327-513`. -```solidity -// ExitEngineLib.sol:86-91 -core.epochStart = uint64(block.timestamp - ((block.timestamp - es) % dur)); -core.epochWithdrawn = 0; -``` +### 3.5 Epoch Management -This ensures epoch boundaries are predictable and monotonically increasing. +`EpochedQueueModule`'s settlement epoch (`currentEpochId`, duration from +`IParamsProvider.QueueParams.epochDuration`) is a **separate concept** from `ExitEngineLib`'s +withdrawal-cap epoch (`CoreStorage.epochStart`, rolled by `rollEpochIfNeeded()`). The settlement +epoch only advances when `closeCurrentEpoch()` is explicitly called; the cap epoch rolls +automatically on interaction. See `docs/queue-mechanics.md` §6 for the full distinction — they +are not architecturally coupled even though test fixtures often configure matching durations. ### 3.6 Performance Fee Crystallization -`_crystallize()` (`src/core/modules/QueueModule.sol:763-803`): +`_crystallize()` (`src/core/modules/EpochedQueueModule.sol:576-653`) — ported verbatim from +`QueueModule.sol`, unchanged logic: 1. Compute PPS = `totalAssets / totalSupply`. 2. If PPS <= HWM: update HWM, no fee. 3. If PPS > HWM: `profit = totalAssets - HWM * totalSupply`, `feeAssets = profit * perfRateX`. 4. `feeShares = convertToShares(feeAssets)` — minted (dilutive, by design). 5. Update HWM = new PPS post-mint. +This logic has zero dependency on `EpochQueueStorage` — crystallization can be triggered +independent of any epoch's open/closed/funded state (see `docs/queue-mechanics.md` §7). + Performance fee minting is the ONLY exit-related path that mints new shares (fee accrual is dilutive). All other exits are non-dilutive. ### 3.7 Invariants @@ -521,7 +538,7 @@ Receives vault share fees and distributes them to configured sinks: treasury, op ### 8.2 AUTO_HARVEST Mode -In `AUTO_HARVEST` mode, `FeeCollector` calls `IQueueModule.requestClaim(true, bal)` on the vault to convert shares to USDC. If the instant exit falls back to queue (epoch cap exhausted), `pendingHarvestShares[token]` is incremented. A subsequent `harvestQueued()` call checks for settled shares and credits the remaining USDC. +In `AUTO_HARVEST` mode, `FeeCollector` calls `IQueueModule.requestInstantWithdrawal(bal)` on the vault to convert shares to USDC. If the instant exit falls back to the epoch queue (cap exhausted, or free liquidity below the ask), `pendingHarvestShares[token]` is incremented and the claim's `(epochId, claimId)` is appended to a per-token list. `harvestQueued()` walks that list and settles whichever claims sit in a funded epoch, leaving the rest queued, so one epoch that never funds delays only its own claim instead of blocking the token. An instant harvest whose payout rounds down to zero emits `HarvestDustBurned` and records nothing, since the inline-settlement path returns `(0, 0)` for the claim handle. Source: `src/core/modules/FeeCollector.sol:55-58`. @@ -585,7 +602,7 @@ interface IIncentivesEngine { } ``` -`onExitLight()` is called in the queue settle path (gas-constrained). Source: `src/core/modules/QueueModule.sol:644-651`. +`onExitLight()` is called in the queue settle path (gas-constrained). Source: `src/core/modules/EpochedQueueModule.sol:892-901` (`_notifyIncentivesExit`). ### 10.3 Error Handling @@ -1024,7 +1041,7 @@ After `maxConsecutiveSkips` (default: 5) consecutive skips, hysteresis threshold graph LR CV["CoreVault"] EM["ERC4626Module"] - QM["QueueModule"] + QM["EpochedQueueModule"] AM["AdminModule"] LOM["LiquidityOpsModule"] FM["FixedMaturityModule"] @@ -1078,7 +1095,7 @@ graph LR | Module | Key Invariants | |---|---| | ERC4626Module | withdraw/redeem always revert; no mint on exit; deposit blocked if warmNavInvalid | -| QueueModule | totalSupply decreases only on exit; batch PPS deterministic; escrow balance ≥ pendingClaim.shares | +| EpochedQueueModule | totalSupply decreases only on exit; PPS deterministic per-epoch (locked at close); escrow balance == totalEscrowedShares | | AdminModule | Pending params must be resolved before new submission; ETA window 7 days; fee caps enforced | | BufferManager | Never holds idle USDC; cachedWarmNav reflects 100% of warm assets | | FixedMaturityModule | finalPerformanceFeeApplied exactly once; fundingFailedPPS immutable after markFundingFailed | @@ -1156,6 +1173,6 @@ Source: estimated from `forge test --gas-report` output on branch `pierdev`. Act - `docs/01-architecture/FEECOLLECTOR-SAFETYRESERVE.md` — FeeCollector modes terminology **Discrepancies found** (code vs. old source .md): -- [^1]: FeeCollector docs may reference `IERC4626Minimal.redeem()` as the harvest path. Code shows `src/core/modules/FeeCollector.sol` uses `IQueueModule.requestClaim(true, bal)` (see `src/core/modules/FeeCollector.sol:56-58`) — corrected in FIX-FEECOLLECTOR-AUTOHARVEST-01. Old sync-redeem flow is broken in v9+ (`AsyncWithdrawalRequired`). -- [^2]: Some docs may list only 8-10 modules. Current codebase has 17: ERC4626Module, QueueModule, AdminModule, BufferManager, FixedMaturityModule, LiquidityOpsModule, FeeCollector, Incentives, IncentivesEngine, BatchGuardrails, PriceOracleMiddleware, ExecutionMemory, StrategyRouter, StrategyScorer, StrategyHealthRegistry, RouterAllocationPolicy, RouterRebalanceGuard. +- [^1]: FeeCollector docs may reference `IERC4626Minimal.redeem()` as the harvest path. Code shows `src/core/modules/FeeCollector.sol` uses `IQueueModule.requestInstantWithdrawal(bal)` — corrected in FIX-FEECOLLECTOR-AUTOHARVEST-01. Old sync-redeem flow is broken in v9+ (`AsyncWithdrawalRequired`). +- [^2]: Some docs may list only 8-10 modules. Current codebase has 17: ERC4626Module, EpochedQueueModule, AdminModule, BufferManager, FixedMaturityModule, LiquidityOpsModule, FeeCollector, Incentives, IncentivesEngine, BatchGuardrails, PriceOracleMiddleware, ExecutionMemory, StrategyRouter, StrategyScorer, StrategyHealthRegistry, RouterAllocationPolicy, RouterRebalanceGuard. - [^3]: RouterAllocationPolicy, RouterRebalanceGuard, and ExecutionMemory (`src/core/storage/CoreStorage.sol:100-105`) represent a partially-implemented V10 allocation engine. The feature is optional (controlled by `strictExecutionMemory`) and not fully activated in current production deployment. diff --git a/docs/queue-mechanics.md b/docs/queue-mechanics.md index aa5c785..c699602 100644 --- a/docs/queue-mechanics.md +++ b/docs/queue-mechanics.md @@ -1,98 +1,133 @@ --- title: Queue Mechanics category: multyr-core -version: "1.0" -commit: c39f9462 -updated: 2026-05-15 +version: "2.0" +commit: f7e3544 +updated: 2026-08-13 status: final -tags: [queue, settlement, withdrawal, requestClaim, settleFeesAndProcessQueue] +tags: [queue, settlement, withdrawal, epoch, requestEpochWithdrawal, fundEpoch, claimEpochAssets] --- # Queue Mechanics -> **Source of truth**: `src/core/modules/QueueModule.sol:207` @ `c39f9462` -> **ADR-015 workflow applied**: full code read before drafting. +> **Source of truth**: `src/core/modules/EpochedQueueModule.sol` @ `f7e3544` +> **Supersedes**: v1.0 of this document, which described `QueueModule.sol` (deleted). +> `QueueModule` was the original FIFO/keeper-scanned queue; `EpochedQueueModule` replaced +> it as the sole production queue-settlement mechanism (Renzo ezETH-style epoch batching). --- ## 1. Overview -The queue is the primary withdrawal mechanism for all vault users. It serializes and batches claim settlement, decoupling withdrawal requests from liquidity availability. Every withdrawal — even INSTANT — passes through the same entry point (`requestClaim`) before settling. +The queue is the async withdrawal mechanism for all vault users. Every non-instant withdrawal +— and every instant withdrawal that fails the cap check — passes through the epoch queue. +Unlike the retired `QueueModule` (a live FIFO array scanned by a keeper), the queue is now +**epoch-bucketed**: claims submitted within a time window share one epoch, one locked +price-per-share, and one liquidity pull. -The queue is implemented in `QueueModule` (`src/core/modules/QueueModule.sol:207`, 841L), a module that runs in delegatecall context. Storage lives in `QueueStorage.Layout` (EIP-7201 namespaced, `src/core/storage/QueueStorage.sol:24`). +The queue is implemented in `EpochedQueueModule` (`src/core/modules/EpochedQueueModule.sol`, +~990L), a module that runs in delegatecall context. Storage lives in +`EpochQueueStorage.Layout` (EIP-7201 namespaced, `EpochedQueueModule.sol:29`) — a storage +slot entirely separate from the retired `QueueStorage.Layout` (`src/core/storage/QueueStorage.sol`, +still present purely as a reserved/never-reused EIP-7201 slot; no live code writes to it). Key design decisions: | Decision | Rationale | |----------|-----------| -| All withdrawals enter via `requestClaim` | Single entry point — uniform anti-spam, NAV freshness, epoch roll | -| Shares escrowed to vault on queue | Prevents double-spend; vault holds escrowed shares during pending period | -| Deterministic PPS per batch | `cachedTA/cachedTS` snapshot once per `settleFeesAndProcessQueue` call — all claims in same tx use identical price | -| Bounded pre-scan | `maxEntries = maxClaims × MAX_SCAN_MULTIPLIER(2)` + `MAX_CONSECUTIVE_INELIGIBLE(32)` cap — gas-bounded without O(queue) scans | -| W2: never block exits | All non-critical calls (NAV refresh, incentives notify) are try/catch | +| Claims bucket into epochs, not a flat FIFO array | O(1) liquidity pull per epoch instead of O(queue depth) keeper scan | +| PPS locked once at `closeCurrentEpoch()`, not per-claim | Eliminates live-PPS MEV window and settlement-order unfairness | +| Pull-based `claimEpochAssets()`, not keeper-push | No keeper dependency for a user to eventually receive funds; removes head-of-line blocking | +| Single liquidity pull via `fundEpoch()` per epoch | One warm-refill + strategy-redeem waterfall covers every claim in the epoch, regardless of count | +| `requestInstantWithdrawal()` preserved for cap-eligible exits | Keeps the fast path for small/early exits; falls back to the epoch queue on cap exhaustion | +| Crystallization decoupled from queue settlement | `endEpochCrystallize()`/`_crystallize()` have zero dependency on epoch/queue state — ported verbatim from `QueueModule`, callable independent of epoch lifecycle | +| W2: never block exits | Non-critical calls (NAV refresh, incentives notify, warm refill) are try/catch | --- ## 2. Queue Storage -`QueueStorage.Layout` is stored at EIP-7201 slot `0x20afa2de85fad1e68653d750134f8c4543e7db931009cedccc72142811c77f00` (`QueueStorage.sol:L10`): +`EpochQueueStorage.Layout` is defined at `EpochedQueueModule.sol:29-90`: ```solidity -// src/core/storage/QueueStorage.sol:24 +// src/core/modules/EpochedQueueModule.sol:59 struct Layout { - uint256[] queue; // ordered list of active claim IDs - uint256 head; // index into queue[] of first unsettled entry - uint256 nextClaimId; // monotonic counter - uint256 pendingShares; // total escrowed shares across all active claims - mapping(uint256 => Claim) claims; // full claim data per ID + uint256 currentEpochId; + mapping(uint256 => EpochData) epochs; // epochId => aggregate + mapping(uint256 => mapping(uint256 => EpochClaim)) claims; // epochId => claimId => claim + mapping(uint256 => uint256) nextClaimId; // epochId => next claimId (starts at 1) + uint256 escrowedShares; // total shares in vault escrow across ALL open/closed epochs + uint256 outstandingClaimCount; // total unclaimed claims across ALL epochs (dynamic-cap signal) + uint256 oldestUnfundedEpochId; // oldest epoch CLOSED but not yet FUNDED (keeper cursor) } ``` -`Claim` struct packs into two 32-byte storage slots: +Per-epoch aggregate (`EpochData`, `EpochedQueueModule.sol:37-49`): ```solidity -struct Claim { - // slot 0: [address user (20B)] [uint64 ts (8B)] [bool immediate (1B)] [bool settled (1B)] - address user; - uint64 ts; - bool immediate; - bool settled; - - // slot 1: - uint256 shares; // escrowed shares (gross — before fee deduction) +struct EpochData { + EpochState state; // Open | Closed | Funded + uint64 openedAt; + uint64 closedAt; + uint64 fundedAt; + uint256 totalGrossShares; + uint256 totalNetShares; + uint256 totalFeeShares; + uint256 ppsAtClose; // WAD price-per-share locked at closeCurrentEpoch() + uint256 totalNetAssets; // totalNetShares * ppsAtClose / WAD, set at close + uint256 claimedAssets; // running total paid out via claimEpochAssets + uint256 claimCount; // number of claims submitted to THIS epoch } ``` -### 2.1 Head pointer pattern - -The `queue[]` array is append-only during normal operation — claims are never removed from the array at request time. The `head` pointer tracks the first entry that has not been settled or is not a ghost entry. - -After each settle batch, `_settleLoop` advances `head` past contiguous settled or zero-shares entries: +Per-claim entry (`EpochClaim`, `EpochedQueueModule.sol:52-57`): ```solidity -// src/core/modules/QueueModule.sol:506 -uint256 h = q.head; -while (h < qLen) { - QueueStorage.Claim storage hc = q.claims[q.queue[h]]; - if (!hc.settled && hc.shares > 0) break; - unchecked { ++h; } +struct EpochClaim { + address user; + uint256 netShares; // after fee deduction — what the user burns at claim time + uint256 feeShares; // already transferred to feeCollector at epoch close + bool claimed; } -q.head = h; ``` -This is O(n) in the number of settled entries at the head of the array. The separate `compactQueue()` function physically removes settled head entries to free storage, but it is never called during settlement — it is a maintenance operation callable by anyone. +### 2.1 Two counters, two purposes + +`EpochData.claimCount` (per-epoch, resets to 0 on `closeCurrentEpoch()`) and +`Layout.outstandingClaimCount` (global, persists across epoch boundaries) serve different +roles: -### 2.2 pendingShares invariant +- `claimCount` — used by keepers as an anti-churn check before closing (`currentEpochClaimCount()` + view): don't close an epoch with nothing in it. +- `outstandingClaimCount` — the dynamic-cap "queue depth" signal (`WithdrawalCapLib.calculateDynamicCapBps`). + A dedicated fix in this module ensures this counter does **not** reset when an epoch closes — + an earlier version used per-epoch `claimCount` for this signal, which meant dynamic-cap stress + detection could be dodged by front-running an epoch close with a large instant withdrawal. See + `EpochedQueueModule.t.sol:test_dynamicCap_survivesEpochClose` for the regression test. -`pendingShares` tracks the total shares escrowed in the vault across all unsettled claims: +### 2.2 escrowedShares invariant + +`escrowedShares` tracks total shares escrowed in the vault across every open, closed-unfunded, +and funded-unclaimed epoch combined: ``` -On requestClaim (queue path): pendingShares += shares -On cancelClaim: pendingShares -= shares -On _settleLoop per claim: pendingShares -= c.shares +On requestEpochWithdrawal / requestInstantWithdrawal fallback: escrowedShares += grossShares +On cancelEpochWithdrawal: escrowedShares -= grossShares +On closeCurrentEpoch (fee shares leave escrow to feeCollector): escrowedShares -= totalFeeShares +On claimEpochAssets / batchClaimEpochAssets: escrowedShares -= claim.netShares ``` -This value is the authoritative measure of the vault's escrow obligation. It is decremented in `_settleLoop` storage batch commit (`src/core/modules/QueueModule.sol:519`). +`vault.balanceOf(address(vault)) == escrowedShares` holds at all times — this is the epoch-model +equivalent of the old `pendingShares` invariant, exposed via the `totalEscrowedShares()` view. + +### 2.3 oldestUnfundedEpochId — the keeper cursor + +The epoch-model equivalent of `QueueStorage.head`. Lets a keeper find "what needs `fundEpoch()` +next" in O(1) instead of scanning epoch IDs from 0. Advanced lazily inside `fundEpoch()` +(`EpochedQueueModule.sol:444-459`) — only past *consecutively* FUNDED epochs, and only when the +just-funded epoch **is** the current cursor position. Funding can happen out of order (a keeper +might fund epoch 5 before epoch 3 finally gets enough liquidity), so the cursor must never skip +past a still-unfunded earlier epoch. --- @@ -100,629 +135,349 @@ This value is the authoritative measure of the vault's escrow obligation. It is ```mermaid stateDiagram-v2 - [*] --> Pending: requestClaim()\nshares escrowed + [*] --> SettledInstant: requestInstantWithdrawal()\n_canInstant() == true\nsame tx + [*] --> Open: requestEpochWithdrawal()\nor requestInstantWithdrawal() cap-exhausted fallback\nshares escrowed, epoch state = Open + + Open --> Cancelled: cancelEpochWithdrawal(epochId, claimId)\nonly while epoch is Open - Pending --> SettledInstant: requestClaim(true)\n_canSettleInstant() == true\nSame tx - Pending --> Active: enqueued with\nimmediate=false or fallback + Open --> Closed: closeCurrentEpoch()\n(epoch-wide transition —\nALL claims in the epoch move together) + Closed --> Funded: fundEpoch(epochId)\n(repeatable until hot >= totalNetAssets) - Active --> Cancelled: cancelClaim(claimId) - Active --> Settled: _settleLoop processes claim\nIn a future settleFeesAndProcessQueue tx + Funded --> Claimed: claimEpochAssets(epochId, claimId)\nor batchClaimEpochAssets\npull-based, any time after Funded SettledInstant --> [*] Cancelled --> [*] - Settled --> [*] + Claimed --> [*] - note right of Pending: Claim.settled = false\nShares in vault escrow - note right of Settled: Claim.settled = true\nShares burned\nAssets transferred - note right of Cancelled: Claim.settled = true\nShares returned to user + note right of Open: EpochClaim.claimed = false\nShares in vault escrow\nPPS not yet locked + note right of Closed: ppsAtClose locked\nfeeShares left escrow\nawaiting liquidity + note right of Funded: hot balance covers\ntotalNetAssets\nusers may self-claim + note right of Claimed: netShares burned\nassets transferred ``` ### 3.1 State transitions | Transition | Function | Condition | |-----------|---------|-----------| -| `[*] → SettledInstant` | `requestClaim(true, shares)` | `_canSettleInstant()` = true | -| `[*] → Active` | `requestClaim(false/true, shares)` | queue path (not instant or fallback) | -| `Active → Settled` | `settleFeesAndProcessQueue` | claim eligible (lock/cap) + hot available | -| `Active → Cancelled` | `cancelClaim(claimId)` | caller == claim.user | -| N/A | `compactQueue()` | maintenance only; does not change Claim state | +| `[*] → SettledInstant` | `requestInstantWithdrawal(shares)` | `_canInstant()` = true (cap + liquidity) | +| `[*] → Open` | `requestEpochWithdrawal(shares)` | always queues into the current open epoch | +| `[*] → Open` (fallback) | `requestInstantWithdrawal(shares)` | `_canInstant()` = false → calls `_requestEpochWithdrawal` internally | +| `Open → Cancelled` | `cancelEpochWithdrawal(epochId, claimId)` | caller == claim.user, epoch still `Open` | +| epoch `Open → Closed` | `closeCurrentEpoch()` | `block.timestamp >= epoch.openedAt + minEpochDuration`; permissionless | +| epoch `Closed → Funded` | `fundEpoch(epochId)` | hot balance ends up `>= totalNetAssets` after the liquidity waterfall; permissionless, repeatable | +| `Funded → Claimed` | `claimEpochAssets` / `batchClaimEpochAssets` | caller == claim.user, epoch is `Funded`, claim not already claimed | -### 3.2 requestClaim full flow +Note that `closeCurrentEpoch()` and `fundEpoch()` act on the **whole epoch**, not a single +claim — this is the core structural difference from `QueueModule`'s per-claim settle loop. -`requestClaim(bool immediate, uint256 shares)` (`src/core/modules/QueueModule.sol:81`): +### 3.2 requestEpochWithdrawal full flow -``` -1. FM gate: _checkStandardExitAllowed() (if !immediate) or _checkInstantExitAllowed() -2. _enterNonReentrant() — reentrancy lock -3. _ensureFreshWarmNav() — mandatory NAV freshness (reverts if stale and refresh fails) -4. rollEpochIfNeeded(core) — advance epoch if boundary passed -5. Min claim check: shares >= minClaimShares -6. _checkQueueAntiSpam(user): - - cooldownPerClaim: block.timestamp >= lastClaimTime + cooldown - - maxClaimsPerUserPerEpoch: userClaimsCount[user] < max -7a. INSTANT path (immediate == true && _canSettleInstant()): - computeFeeShares(shares, INSTANT, fee) - _transferShares(msg.sender, feeCollector, feeShares) - _burn(msg.sender, userShares) - token.safeTransfer(receiver, net) - consumeEpochCap(core, gross) - → DONE (no queue entry) -7b. Queue path (fallback or immediate==false): - _transferShares(msg.sender, vault, shares) // escrow - id = nextClaimId++ - claims[id] = Claim{user, ts, immediate=false, shares} - queue.push(id) - pendingShares += shares - userLastClaimTime[user] = now -8. _exitNonReentrant() -``` - -Note: step 7a transfers shares from `msg.sender` — not escrowed. Step 7b escrows to `address(this)`. - -### 3.3 cancelClaim +`requestEpochWithdrawal(uint256 shares)` (`EpochedQueueModule.sol:212`, delegates to +`_requestEpochWithdrawal` at `:228`): ``` -cancelClaim(uint256 claimId) (src/core/modules/QueueModule.sol:173) - -1. Load claim: claims[claimId] -2. Check: caller == claim.user AND !claim.settled AND claim.shares > 0 -3. _transferShares(vault, user, claim.shares) // return escrowed shares -4. claim.settled = true; claim.shares = 0 -5. pendingShares -= shares -6. Emit ClaimCancelled(claimId, user, shares) +1. FM gate: _checkStandardExitAllowed(fm, false) +2. shares == 0 → revert ZeroAmount() +3. Lazily initialize epoch 0 on the very first-ever submission (openedAt, state=Open, emit EpochOpened) +4. epoch.state must be Open, else revert EpochNotOpen() +5. _trySoftRefreshWarmNav() — try/catch, W2 rule +6. computeFeeShares(shares, STANDARD, fee) → (feeShares, netShares) +7. _transferShares(user, address(this), shares) // escrow ALL gross shares +8. claimId = ++nextClaimId[epochId] +9. claims[epochId][claimId] = EpochClaim{user, netShares, feeShares, claimed=false} +10. epoch.totalGrossShares += shares; totalNetShares += netShares; totalFeeShares += feeShares; claimCount++ +11. escrowedShares += shares; outstandingClaimCount += 1 +12. _notifyIncentivesExit(user, grossAssets, core) — try/catch +13. emit EpochWithdrawalRequested(epochId, claimId, user, shares, netShares, feeShares) ``` -Cancelled claims remain in `queue[]` as ghost entries (settled=true, shares=0). The head advance loop skips them. +Anti-spam on this path is `IParamsProvider.WithdrawalParams.minClaimAmount`, enforced in +`_requestEpochWithdrawal` and, so the outcome depends on the caller's input rather than on vault +state, also up front in `requestInstantWithdrawal`. `core.feeCollector` is exempt: it is a single +trusted address whose own bookkeeping caps its outstanding claims, and applying the floor to it +would strand fee accruals smaller than the floor with no route to distribution. ---- +The per-user `QueueParams.maxClaimsPerUserPerEpoch` / `cooldownPerClaim` throttles are NOT enforced +by this module. `minClaimAmount` is the only per-claim gate, so the cost of driving +`outstandingClaimCount` past `DynamicCapParams.queueStressThreshold` is roughly +`threshold * minClaimAmount` in capital, refundable, plus gas. Size the threshold against the +vault's deposit cap, not against a fixed claim count. -## 4. Settlement Loop - -`settleFeesAndProcessQueue(uint256 maxClaims)` (`src/core/modules/QueueModule.sol:207`): - -### 4.1 Pre-settlement phase +### 3.3 cancelEpochWithdrawal ``` -1. FM gate: _checkSettlementAllowed() -2. rollEpochIfNeeded(core) -3. cachedTA = totalAssets() // snapshot — immutable for entire batch -4. cachedTS = totalSupply() // snapshot -5. capRem = calculateCapRemaining(core, q, cachedTA, vault) -6. _settleScan(maxClaims, cachedTA, cachedTS, capRem) -``` - -The TA/TS snapshot at step 3-4 is the core of deterministic PPS: all settlements in the same batch use the same price, regardless of intermediate burns or transfers within the loop. +cancelEpochWithdrawal(uint256 epochId, uint256 claimId) (EpochedQueueModule.sol:290) -### 4.2 _settleScan and bounded pre-scan - -`_settleScan` orchestrates two sub-steps before the main loop: - -``` -_trySoftRefreshWarmNav() // try/catch — W2 rule -_boundedPreScan(maxClaims) // find eligible claims without settling -bm.refill(prescan.requiredHot) // warm up hot balance if BufferManager is wired -_settleLoop(eligible, ...) // settle only the pre-scanned window +1. epoch.state must be Open (cannot cancel after close — PPS is about to lock) +2. claim.user == msg.sender, else revert NotClaimOwner() +3. claim.claimed must be false, else revert ClaimAlreadySettled() +4. Return ALL gross shares (netShares + feeShares) to the user via _transferShares +5. epoch.totalGrossShares -= gross; totalNetShares -= netShares; totalFeeShares -= feeShares; claimCount-- +6. escrowedShares -= gross; outstandingClaimCount -= 1 +7. Mark the claim entry cancelled (claimed=true, netShares=0) so it can never be claimed or re-cancelled +8. emit EpochWithdrawalCancelled(epochId, claimId, user, gross) ``` -`_boundedPreScan(maxClaims)` (`src/core/modules/QueueModule.sol:303`): +Cancellation is only possible while the epoch is still `Open`. Once `closeCurrentEpoch()` runs +and locks `ppsAtClose`, claims in that epoch can only be claimed (once funded) — never cancelled. -``` -maxEntries = maxClaims × MAX_SCAN_MULTIPLIER // MAX_SCAN_MULTIPLIER = 2 -consecutive_ineligible = 0 - -for i in [head, head + maxEntries): - if consecutive_ineligible >= MAX_CONSECUTIVE_INELIGIBLE (32): - hitEarlyExit = true; break - claim = claims[queue[i]] - if settled or shares==0: skip (ghost) - eligible = check(claim): - INSTANT: gross <= capRem - STANDARD: lockPeriod==0 || now >= ts + lockPeriod - if !eligible: - consecutive_ineligible++ - else: - requiredHot += gross - eligibleCount++ - consecutive_ineligible = 0 - -return PrescanResult{requiredHot, eligibleCount, scanWindowEnd, hitEarlyExit} -``` +--- -The pre-scan serves two purposes: -1. Determine `requiredHot` for `bm.refill()` — avoids mid-loop refill surprises -2. Limit scan window to `scanWindowEnd` — the settle loop processes only entries in `[head, scanWindowEnd)` +## 4. Epoch Close and Funding -### 4.3 _settleLoop +### 4.1 closeCurrentEpoch -`_settleLoop(prescan, maxClaims, cachedTA, cachedTS, capRem, hot)` (`src/core/modules/QueueModule.sol:407`): +`closeCurrentEpoch()` (`EpochedQueueModule.sol:327`), permissionless: ``` -proc = 0 -for i in [head, scanWindowEnd): - if proc >= maxClaims: break - if gasleft() <= 150_000: break // gas safety exit - - claim = claims[queue[i]] - if settled or shares==0: skip - - // Eligibility (re-check with current capRem/lockPeriod) - if c.immediate: - eligible = gross <= capRem - else: - eligible = lockPeriod==0 || now >= ts + lockPeriod - if !eligible: continue - - // Hot liquidity check (in-memory tracker) - if hot < gross: - emit QueueClaimSkippedInsufficientHot(id, hot, gross) - continue - - // Incentives sync (try/catch) - _notifyIncentivesExit(user, gross, core) - - // Fee computation - mode = c.immediate ? INSTANT : STANDARD - (feeShares, userShares) = computeFeeShares(c.shares, mode, fee) - - // Fee transfer (escrow → feeCollector) - if feeShares > 0: - _transferShares(vault, feeCollector, feeShares) - emit FeePaid(user, feeCollector, feeShares) - - // Asset transfer (cached PPS) - net = (userShares × cachedTA) / cachedTS - _burn(vault, userShares) - pendingShares -= c.shares - c.settled = true - token.safeTransfer(user, net) - hot -= net // in-memory hot tracker - - emit ClaimSettled(id, user, net) - emit IERC4626.Withdraw(vault, user, user, gross, c.shares) - - // Cap update - if c.immediate: - capRem = capRem >= gross ? capRem - gross : 0 - epochWithdrawn += gross - - proc++ - -// Advance head past settled/ghost claims -// Batch commit: epochWithdrawn, pendingShares +1. FM gate: _checkSettlementAllowed(fm) +2. epoch.state must be Open, else revert EpochNotOpen() +3. block.timestamp >= epoch.openedAt + minEpochDuration, else revert EpochTooYoung() +4. _trySoftRefreshWarmNav() — try/catch +5. Snapshot PPS: ts = totalSupply(); ta = totalAssets(); pps = ts==0 ? WAD : ta*WAD/ts +6. epoch.ppsAtClose = pps; totalNetAssets = totalNetShares * pps / WAD; closedAt = now; state = Closed +7. If totalFeeShares > 0: transfer them (address(this) → feeCollector) in ONE batched transfer; + escrowedShares -= totalFeeShares; emit FeePaid +8. emit EpochClosed(epochId, pps, totalNetShares, totalNetAssets, totalFeeShares) +9. Open the NEXT epoch immediately (currentEpochId++, new epoch openedAt=now, state=Open, emit EpochOpened) + — new submissions are never blocked waiting for the old epoch to fund/settle ``` -### 4.4 Gas safety - -The `gasleft() > 150_000` check ensures the loop exits with enough gas remaining to commit storage and emit final events. If the check triggers mid-batch, partial progress is committed — the next `settleFeesAndProcessQueue` call continues from the new `head` position. +`minEpochDuration` (`EpochedQueueModule.sol:827`) reads `IParamsProvider.QueueParams.epochDuration`, +falling back to a 1-day default if governance hasn't configured it — this is an operational +batching cadence, not a security gate, so a nonzero default is intentional (unlike the cap-epoch +duration in `ExitEngineLib.rollEpochIfNeeded`, which has no zero fallback). -### 4.4.1 Settlement pipeline diagram +Step 6 is the single most important behavioral guarantee of this architecture: **every claim in +the epoch settles at the exact same price**, fixed once, regardless of what totalAssets/totalSupply +do afterward. There is no live-PPS MEV window during funding or claiming. -```mermaid -flowchart TD - REQ([requestClaim\nimmediate=false or fallback]) -->|escrow shares| QU[queue\nenqueue Claim] - QU -->|future call| SETTLE([settleFeesAndProcessQueue]) - - SETTLE --> SNAP[Snapshot\ncachedTA / cachedTS] - SNAP --> PRESCAN[_boundedPreScan\nfind eligible claims\nmaxEntries = maxClaims × 2] - PRESCAN --> REFILL[bm.refill\nrequiredHot] - REFILL --> LOOP[_settleLoop\nfor each eligible claim] - - LOOP --> CHK{gasleft\n> 150k?} - CHK -->|no| COMMIT[commit partial\nstorage + head] - COMMIT --> DONE([return]) - CHK -->|yes| ELIG{eligible?} - - ELIG -->|INSTANT:\ngross <= capRem| FEE[computeFeeShares\ntransfer fee\nburn shares\ntransfer assets] - ELIG -->|STANDARD:\nlockPeriod check| FEE - ELIG -->|no| SKIP[skip, incr\nconsecutive_ineligible] - SKIP --> CHK - - FEE --> CAPU[if INSTANT:\ncapRem -= gross\nepochWithdrawn += gross] - CAPU --> CHK - - LOOP --> ADVANCE[advance head\npast settled/ghost] - ADVANCE --> XTAL[_crystallize\nperf fee if pps > hwm] - XTAL --> NAVS[_updateNavSmooth] - NAVS --> SNAP2[emit VaultPpsSnapshot] - SNAP2 --> DONE -``` +### 4.2 fundEpoch — the liquidity waterfall -### 4.5 Post-settlement phase - -After `_settleLoop` returns: +`fundEpoch(uint256 epochId)` (`EpochedQueueModule.sol:390`), permissionless, repeatable: ``` -_crystallize() // performance fee if pps > hwm -_updateNavSmooth() // EMA nav smoothing -emit VaultPpsSnapshot(pps, block.timestamp) +1. epoch.state must be Closed (EpochNotClosed if Open, EpochAlreadyFunded if already Funded) +2. hot = asset.balanceOf(vault) +3. emit EpochFundAttempt(epochId, needed=totalNetAssets, hotBefore=hot, hotAfter=0) +4. if hot < totalNetAssets: + deficit = totalNetAssets - hot + Step A — warm refill (cheaper than strategy redeem): + if BufferManager wired and warmNavState valid and warmNav > 0: + pullWarm = min(deficit, warmNav) + try bm.refill(pullWarm) {} catch { emit QueueWarmRefillFailed } + hot = balanceOf(vault); deficit = max(0, totalNetAssets - hot) + Step B — strategy redeem for remaining gap: + if deficit > 0 and router wired: + plan = router.planRedeem(deficit) + if plan.length > 0: try router.executeRedeemBatch(plan) { emit RealizedForQueue } catch {} + hot = balanceOf(vault) +5. emit EpochFundAttempt(epochId, needed=totalNetAssets, hotBefore=0, hotAfter=hot) +6. if hot >= totalNetAssets: + state = Funded; fundedAt = now; emit EpochFunded(epochId, totalNetAssets) + Advance oldestUnfundedEpochId past any now-consecutively-Funded epochs (§2.3) + else: + epoch remains Closed — retry fundEpoch() later as more liquidity becomes available ``` ---- +This single call replaces `QueueModule`'s per-batch `bm.refill(requiredHot)` inside the settle +loop — the pull happens **once per epoch**, sized to the epoch's total liability, not once per +`maxClaims` batch. `EpochAlreadyFunded()` reverts on a second call to an already-funded epoch — +callers (including test helpers) must guard against double-funding rather than relying on +idempotence. -## 4.6 BufferManager integration +### 4.3 claimEpochAssets — pull-based settlement -`bm.refill(requiredHot)` is called by `_settleScan` after the pre-scan, before the settle loop. The buffer manager is responsible for moving funds from deployed strategies back to the vault's idle balance ("hot"): +`claimEpochAssets(uint256 epochId, uint256 claimId)` (`EpochedQueueModule.sol:470`): ``` -_settleScan flow: - prescan = _boundedPreScan() // requiredHot computed - if address(bm) != address(0): - bm.refill(prescan.requiredHot) // asks BM to ensure hot >= required - _settleLoop(prescan, hot=IERC20.balanceOf(vault)) +1. epoch.state must be Funded, else revert EpochNotFunded() +2. claim.user == msg.sender, else revert NotClaimOwner() +3. claim.claimed must be false, else revert ClaimAlreadySettled() +4. assets = claim.netShares * epoch.ppsAtClose / WAD // deterministic — locked at close +5. claim.claimed = true (CEI — before external transfer); epoch.claimedAssets += assets +6. escrowedShares -= claim.netShares; outstandingClaimCount -= 1 +7. _burn(address(this), claim.netShares) +8. if assets > 0: asset.safeTransfer(msg.sender, assets) +9. emit EpochAssetsClaimed(epochId, claimId, user, assets, netShares) +10. emit IERC4626.Withdraw(vault, user, user, netShares+feeShares valued at ppsAtClose, netShares+feeShares) ``` -If `bm == address(0)` (buffer manager not wired), `_settleScan` proceeds directly to `_settleLoop` with whatever hot balance the vault currently holds. +`batchClaimEpochAssets(epochId, claimIds[])` (`EpochedQueueModule.sol:515`) is a gas-efficiency +variant for one user claiming several of their own claim IDs in the same funded epoch in one +transaction — entries in the array not owned by `msg.sender` are silently skipped (no revert), +so a caller can safely pass a superset of IDs. -Hot balance tracking in `_settleLoop` is **in-memory** — the loop maintains a local `hot` variable decremented on each `safeTransfer`. This avoids a `balanceOf` call per iteration (saves ~800 gas each). - -`src/core/modules/QueueModule.sol:492`: -```solidity -hot -= net; // in-memory tracker after each transfer -``` - -If a claim is skipped due to `hot < gross` (insufficient hot after partial refill), the claim is left in the queue for the next batch. This is the designed behavior: `bm.refill` provides best-effort liquidity; if the strategy cannot return enough, later batches will retry. +No keeper is required for a user to receive funds — this is the core UX improvement over +`QueueModule`, whose users depended on a keeper eventually reaching their claim in the FIFO scan. --- -## 5. Anti-spam Guards - -`_checkQueueAntiSpam(address user)` (`src/core/modules/QueueModule.sol:580`) enforces per-user rate limits: - -| Parameter | Source | Effect | -|-----------|--------|--------| -| `cooldownPerClaim` | `QueueParams.cooldownPerClaim` | Minimum seconds between consecutive claims by same user | -| `maxClaimsPerUserPerEpoch` | `QueueParams.maxClaimsPerUserPerEpoch` | Maximum claims per user per anti-spam epoch | -| `epochDuration` | `QueueParams.epochDuration` | Duration of anti-spam epoch | - -Implementation detail: the anti-spam epoch uses a separate counter (`currentEpochNumber`, `lastEpochReset` in `CoreStorage.Layout`) from the withdrawal cap epoch. They can have different durations. - -When `cooldownPerClaim = 0 && maxClaimsPerUserPerEpoch = 0`, anti-spam is disabled entirely (early return in `_checkQueueAntiSpam`). - -### 5.1 Anti-spam CoreStorage fields - -Fields in `CoreStorage.Layout` used by `_checkQueueAntiSpam`: - -| Field | Type | Description | -|-------|------|-------------| -| `currentEpochNumber` | `uint64` | Monotonic epoch counter; incremented on epoch boundary | -| `lastEpochReset` | `uint64` | Timestamp of last epoch advance | -| `userLastClaimTime` | `mapping(address → uint64)` | Last `requestClaim` timestamp per user | -| `userLastClaimEpoch` | `mapping(address → uint64)` | Epoch number of last claim per user | -| `userClaimsCount` | `mapping(address → uint64)` | Claims submitted by user in current epoch | - -Epoch advance logic (`QueueLib.shouldAdvanceEpoch`): -``` -if block.timestamp >= lastEpochReset + epochDuration: - currentEpochNumber++ - lastEpochReset = uint64(block.timestamp) - return (true, newReset) -``` +## 4.4 Full flow diagram -When epoch advances, `userClaimsCount` is reset per-user lazily: on the user's next `requestClaim`, if `userLastClaimEpoch[user] < currentEpochNumber`, the count is zeroed before incrementing. +```mermaid +flowchart TD + REQ([requestEpochWithdrawal\nor requestInstantWithdrawal\ncap-exhausted fallback]) -->|escrow gross shares| OPEN[epoch: Open\nclaim recorded] + OPEN -->|cancelEpochWithdrawal| CANCELLED([shares returned]) -### 5.2 Cooldown check + OPEN -->|closeCurrentEpoch\nafter minEpochDuration| CLOSED[epoch: Closed\nppsAtClose locked\nfeeShares -> feeCollector] + CLOSED -->|fundEpoch\nrepeatable| WATERFALL{hot >= totalNetAssets?} + WATERFALL -->|no: try warm refill,\nthen strategy redeem| CLOSED + WATERFALL -->|yes| FUNDED[epoch: Funded] -```solidity -// QueueLib.isCooldownActive -function isCooldownActive(uint64 last, uint256 now, uint64 cooldown) → bool { - return cooldown > 0 && now < uint256(last) + cooldown; -} + FUNDED -->|claimEpochAssets\nany time, pull-based\nper claimant| CLAIMED([netShares burned\nassets transferred]) ``` -The cooldown uses `userLastClaimTime` updated at the end of each successful `requestClaim` (queue path). INSTANT settlements (no queue entry) do NOT update `userLastClaimTime`, because no queue entry is created. - -### 5.3 Interaction with INSTANT path - -Anti-spam checks (`_checkQueueAntiSpam`) are called in step 6 of `requestClaim`, **before** the INSTANT/queue branching at step 7. This means: -- An INSTANT settlement consumes one "claim slot" against the user's epoch quota -- The cooldown timer is updated after INSTANT settlement -- A user cannot bypass anti-spam by always requesting immediate=true - --- -## 6. NAV Freshness - -Two NAV freshness modes are used on the queue path: - -| Mode | Used when | Reverts on stale? | -|------|-----------|------------------| -| `_ensureFreshWarmNav()` | `requestClaim` | Yes — blocks claim if NAV is stale and refresh fails | -| `_trySoftRefreshWarmNav()` | `settleFeesAndProcessQueue` | No — W2 rule, settlement proceeds with stale NAV | +## 5. Instant Withdrawal Path -`MAX_WARM_NAV_AGE = 15 minutes` (`QueueModule.sol:L20`). If `block.timestamp > warmNavTs + 15min`, the module attempts a refresh via `bm.refreshWarmNav()`. - -The asymmetry is intentional: a user requesting a claim should see a fresh price, but batch settlement (often run by a keeper) must not be blocked by a transient NAV staleness. - ---- - -## 7. compactQueue - -`compactQueue()` (`src/core/modules/QueueModule.sol:562`) is a housekeeping function that physically removes settled head entries from the `queue[]` array: +`requestInstantWithdrawal(uint256 shares)` (`EpochedQueueModule.sol:698`) is functionally +identical to `QueueModule.requestClaim(immediate=true, ...)`: ``` -h = q.head -if h == 0 or len == 0: return - -// Shift elements left by h positions -for i in [0, len-h): queue[i] = queue[i+h] -// Pop h tail entries -for i in [0, h): queue.pop() -q.head = 0 +1. FM gate: _checkStandardExitAllowed(fm, true) +2. shares == 0 → revert ZeroAmount() +3. _trySoftRefreshWarmNav(); rollEpochIfNeeded(core) — the CAP epoch, not the settlement epoch (see §6) +4. gross = convertToAssets(shares) +5. if _canInstant(gross, withdrawalParams, core): + computeFeeShares(shares, INSTANT, fee); notify incentives + transfer feeShares to feeCollector (if any); burn netShares + asset.safeTransfer(msg.sender, netAssets); consumeEpochCap(core, gross) + emit InstantExit(user, shares, netAssets, feeShares) + return (settledImmediately=true, epochId=0, claimId=0) + else: + (epochId, claimId) = _requestEpochWithdrawal(msg.sender, shares) // fallback, same as §3.2 + return (settledImmediately=false, epochId, claimId) ``` -Properties: -- O(n) in total queue length — expensive on large queues -- Never called in the settle path — calling it during settlement would corrupt the in-progress scan -- Idempotent: calling it on an already-compact queue is a no-op -- No economic impact: settled claims are already processed; this is pure storage cleanup -- Callable by anyone (external) — no access control; callers pay gas +Callers **must** branch on the returned `settledImmediately` flag — a cap-exhausted instant +request silently becomes a standard epoch claim rather than reverting (W2 rule: never block +exits), and the caller needs the `(epochId, claimId)` pair to later cancel or claim it. --- -## 7.5 Claim ordering and fairness - -The queue is **FIFO with eligibility gaps**. Claims are processed in insertion order, but ineligible claims (lock not elapsed, cap insufficient) are **skipped** rather than blocking subsequent claims. - -This "skip-ineligible" model has intentional implications: +## 6. Two Independent Epoch Concepts — Do Not Confuse -1. **STANDARD before INSTANT is possible**: a STANDARD claim queued before an INSTANT claim processes first if both are eligible. If the STANDARD claim is eligible but the INSTANT claim's cap is met first, the INSTANT claim may have to wait for the next epoch. +This module's "epoch" (settlement batching, `EpochQueueStorage.Layout.currentEpochId`, +default duration read from `IParamsProvider.QueueParams.epochDuration`) is **entirely +separate** from `ExitEngineLib`'s cap epoch (`CoreStorage.Layout.epochStart`, fixed 7-day +default set at vault deploy time, rolled by `rollEpochIfNeeded`, governs the dynamic +withdrawal cap consumed by `requestInstantWithdrawal`/`consumeEpochCap`). They: -2. **No ordering fairness for hot shortage**: if the vault has 10 USDC hot and two eligible claims for 6 USDC each, the first claim in the array is settled, the second is skipped with `QueueClaimSkippedInsufficientHot`. The second claim will be retried in the next batch when liquidity is available. - -3. **Epoch cap is not first-come-first-served**: INSTANT claims are checked against `capRem` at the moment the settle loop reaches them. If earlier claims in the same batch consumed the cap, later INSTANT claims are skipped for this batch. - -Mitigation: `bm.refill` is called before the settle loop to pre-load the expected hot liquidity. Claims should be skipped only if the strategy genuinely cannot provide liquidity, not due to ordering effects. +- Have independent storage fields and independent durations (can be reconfigured independently). +- Roll on different triggers: the cap epoch rolls automatically on interaction + (`rollEpochIfNeeded`); the settlement epoch only rolls when `closeCurrentEpoch()` is + explicitly called (permissionless, but not automatic). +- Both happened to default to matching durations in most test fixtures (`MockParamsProvider` + sets the settlement epoch to 7 days; `CoreVault` sets the cap epoch to 7 days at deploy) — + this is a test-fixture coincidence, not an architectural coupling. --- -## 8. Incentives Integration - -`_notifyIncentivesExit(user, assetsExited, core)` (`src/core/modules/QueueModule.sol:644`): +## 7. Crystallization — Decoupled From Queue Settlement -```solidity -IIncentivesEngine eng = core.incentivesEngine; -if (address(eng) == address(0)) return; -try eng.onExitLight(user, assetsExited * 1e12) {} catch {} -``` +`endEpochCrystallize()` (`EpochedQueueModule.sol:566`), `_pps()` (`:571`), `_crystallize()` +(`:576`), and `_updateNavSmooth()` (`:654`) were ported **verbatim** from `QueueModule.sol` +during the cutover. A deliberate discovery made during that migration: this logic has **zero** +dependency on `EpochQueueStorage` or the epoch lifecycle — it only reads/writes +`FeeStorage.Layout` (HWM, perf rate) and `CoreStorage.Layout` (NAV smoothing state). It was only +ever colocated with the queue module because that was the only module wired to the +`endEpochCrystallize` selector at the time. -- Called once per settled claim in `_settleLoop`, before fee computation -- `assetsExited` is in USDC (6 decimals), scaled by 1e12 to 18 decimals for the incentives engine -- `try/catch` with no error handling — W2 rule; incentives failure never blocks settlement -- If `incentivesEngine == address(0)`: early return, no external call +Practically: crystallization can be triggered independent of whether any epoch is open, closed, +or funded. A keeper calling `endEpochCrystallize()` has no interaction with `closeCurrentEpoch()`/ +`fundEpoch()` — they are orthogonal operations that happen to share a module for selector-wiring +convenience. --- -## 9. Invariants +## 8. Invariants | ID | Invariant | Enforcement | |----|-----------|-------------| -| **Q1** | Claim can only be settled once (`c.settled = true` is permanent) | `_settleLoop` skips `settled=true` entries; `cancelClaim` also sets `settled=true` | -| **Q2** | `pendingShares` = sum of all unsettled `c.shares` in active claims | Maintained by `requestClaim` (+), `cancelClaim` (-), `_settleLoop` (-) | -| **Q3** | `escrow balance(vault) >= pendingShares` at all times | Shares escrowed before enqueue; consumed on settlement or cancellation | -| **Q4** | Deterministic PPS within one batch: all claims use `cachedTA/cachedTS` snapshot | Snapshot taken once before `_settleLoop`; never updated mid-loop | -| **Q5** | Escrow underflow never causes revert | `_settleLoop` emits warning + skips (not revert) on unexpected escrow deficit | -| **Q6** | Anti-spam limits apply before escrow — spam claims are rejected before shares move | `_checkQueueAntiSpam` called in `requestClaim` before `_transferShares` | -| **Q7** | `gasleft() > 150_000` guard ensures storage commit before gas exhaustion | Inner loop condition in `_settleLoop` | +| **Q1** | A claim can only be settled (claimed or cancelled) once | `claimed` flag checked before both `claimEpochAssets` and `cancelEpochWithdrawal` | +| **Q2** | `escrowedShares` = sum of all unclaimed/uncancelled claims' outstanding gross-or-net shares in escrow | Maintained by request (+gross), close (-feeShares), claim (-netShares), cancel (-gross) | +| **Q3** | `vault.balanceOf(vault) == escrowedShares` at all times | Shares only move via `_transferShares`/`_burn` inside this module, always paired with an `escrowedShares` update | +| **Q4** | Deterministic PPS within one epoch: every claim uses `ppsAtClose` | Snapshot taken once in `closeCurrentEpoch()`; immutable afterward for that epoch | +| **Q5** | `outstandingClaimCount` persists across epoch boundaries (not reset by close) | Distinct from per-epoch `claimCount`; regression-tested (`EpochedQueueModule.t.sol`) after the dynamic-cap bug fix | +| **Q6** | `oldestUnfundedEpochId` never skips past a still-unfunded earlier epoch | `fundEpoch` only advances the cursor when the just-funded epoch IS the cursor position | +| **Q7** | `fundEpoch()` never marks an epoch `Funded` while `hot < totalNetAssets` | Explicit `hot >= totalNetAssets` check gates the state transition | +| **Q8** | Cancellation only possible while epoch is `Open` | `cancelEpochWithdrawal` checks `epoch.state == Open` | --- -## 10. Events +## 9. Events | Event | When | |-------|------| -| `ClaimQueued(claimId, user, shares, immediate)` | `requestClaim` → queue path | -| `ClaimSettled(claimId, user, netAssets)` | `_settleLoop` per settled claim | -| `ClaimCancelled(claimId, user, shares)` | `cancelClaim` | -| `QueueClaimSkippedInsufficientHot(id, hot, gross)` | `_settleLoop` — hot < gross | -| `FeePaid(user, feeCollector, feeShares)` | `_settleLoop` — fee transfer | -| `VaultPpsSnapshot(pps, ts)` | End of `settleFeesAndProcessQueue` | -| `Crystallized(oldHwm, newHwm, feeAssets)` | After settle batch — crystallization | -| `PerfFeeMinted(oldHwm, ppsBefore, feeShares, ppsAfter)` | Perf fee mint | -| `NavSmoothUpdated(navReal, navSmooth, ts)` | After crystallization | -| `EpochRolled(epochStart, epochDuration)` | Epoch boundary crossed | - ---- - -## 11. Thread Safety and Reentrancy - -`requestClaim` and `settleFeesAndProcessQueue` are both protected by the `FLAG_REENTRANCY_LOCKED` flag in `CoreStorage.Layout.packedFlags`: - -```solidity -function _enterNonReentrant() internal { - if (core.packedFlags & FLAG_REENTRANCY_LOCKED != 0) revert ReentrancyGuardLocked(); - core.packedFlags |= FLAG_REENTRANCY_LOCKED; -} - -function _exitNonReentrant() internal { - core.packedFlags &= ~FLAG_REENTRANCY_LOCKED; -} -``` - -The ERC-20 `safeTransfer` in `_settleLoop` is the primary reentrancy risk (USDC has hooks on some chains). The reentrancy guard prevents a re-entrant call to `requestClaim` from the USDC transfer callback. - ---- - -## 12. Examples - -### 12.1 Queue with three pending claims - -``` -State: paramMinDelay=2d, witBps=50, immediateExitPenaltyBps=100, lockPeriod=0 -queue = [claimId=1, claimId=2, claimId=3], head=0 - -Claim1: {user=Alice, ts=Day0, immediate=false, shares=500e18} -Claim2: {user=Bob, ts=Day1, immediate=true, shares=200e18} -Claim3: {user=Carol, ts=Day2, immediate=false, shares=300e18} - -Day 2, 14:00: settleFeesAndProcessQueue(maxClaims=10) - cachedTA = 1_000_000 USDC, cachedTS = 1_000_000e18 → PPS = 1.0 - - _boundedPreScan: - Claim1: immediate=false, lockPeriod=0 → eligible, gross ≈ 500 USDC - Claim2: immediate=true, capRem=10_000 → eligible, gross ≈ 200 USDC - Claim3: immediate=false → eligible, gross ≈ 300 USDC - requiredHot = 1000 USDC, eligible=3 - - bm.refill(1000 USDC) // warm up hot if needed - - _settleLoop: - Claim1 (STANDARD): - feeShares = mulBpsUp(500e18, 50) = 2.5e18 → 3e18 (ceiling) - userShares = 497e18 - net = 497e18 × 1_000_000e6 / 1_000_000e18 = 497e6 USDC - → transfer 497 USDC to Alice - pendingShares -= 500e18 - - Claim2 (INSTANT): - feeShares = mulBpsUp(200e18, 150) = 3e18 - userShares = 197e18 - net = 197e6 USDC - → transfer 197 USDC to Bob - capRem -= 200 USDC; epochWithdrawn += 200 USDC - - Claim3 (STANDARD): - feeShares = mulBpsUp(300e18, 50) = 2e18 (ceiling of 1.5) - userShares = 298e18 - net = 298e6 USDC - → transfer 298 USDC to Carol - - head advances to 3 (all settled) - _crystallize() — PPS slightly above 1.0 due to fees? → depends on protocol state - emit VaultPpsSnapshot -``` - -### 12.2 Anti-spam block - -``` -User: requestClaim(false, 100e18) at T=0 - → userLastClaimTime[user] = 0; cooldownPerClaim = 3600 (1 hour) - → 0 < 3600: no cooldown yet ✓ (first claim) - → claim stored, lastClaimTime = T - -User: requestClaim(false, 100e18) at T=1800 (30 min later) - → isCooldownActive: now(1800) < lastClaimTime(0) + cooldownPerClaim(3600) = 3600 ✓ - → revert ClaimCooldownActive() -``` - ---- - -## 12.3 compactQueue gas cost - -``` -Before: queue.length=200, head=150 - → 150 settled entries at head - -compactQueue(): - newLen = 200 - 150 = 50 - for i in [0, 50): queue[i] = queue[i+150] // 50 SSTORE(warm) reads + writes - for i in [0, 150): queue.pop() // 150 SSTORE clears (refund) - head = 0 - -Approximate gas: 50 × 3000 (warm SSTORE) + 150 × ~4800 (SSTORE → zero, refund) ≈ ~870k gas -Gas refund partially offsets SSTORE clears under EIP-3529. -``` - -In practice, `compactQueue` is called periodically by an off-chain keeper once the head pointer is significantly behind `queue.length`. There is no incentive to compact more frequently than necessary. - -### 12.4 Minimum claim size - -`minClaimShares` is a governance-configurable threshold stored in `QueueParams` (read from `IParamsProvider`). - -Step 5 of `requestClaim` enforces a minimum: - -```solidity -// src/core/modules/QueueModule.sol:122 -if (shares < core.params.getQueueParams(vault).minClaimShares) - revert ClaimTooSmall(); -``` - -`minClaimShares` prevents micro-claims that would fill the queue without meaningful economic activity. When `minClaimShares = 0`, no minimum is enforced. - -The minimum also serves as an implicit dust-guard: shares below the minimum would produce a `net = 0` after fee deduction at 100+ bps combined exit fee, leaving the user with nothing. Rejecting them at the entry point is cleaner than settling and emitting `ClaimSettled(id, user, 0)`. - ---- - -## 13. Edge Cases - -| Case | Behavior | -|------|---------| -| Queue empty (`head >= queue.length`) | `_boundedPreScan` immediately returns; settle is no-op | -| All claims ineligible for 32 entries | `hitEarlyExit=true`; settleLoop skips eligible=0 entries; fast path | -| `hot = 0` at settle time | All claims skipped with `QueueClaimSkippedInsufficientHot`; bm.refill was called but refill may have returned 0 | -| INSTANT claim cap exhausted mid-batch | Remaining INSTANT claims skipped; STANDARD claims continue | -| Large queue with `maxClaims=1` | Only 1 claim processed per call; head advances by 1; safe on any gas limit | -| `cancelClaim` after partial settlement of queue | A settled claim cannot be cancelled (`settled=true` check); non-settled claims can still cancel | -| `compactQueue()` race with active settle | `compactQueue` is a single external call; calling it while settle is in progress (same tx) is impossible due to reentrancy guard | -| Anti-spam epoch rolls mid-session | `_checkQueueAntiSpam` advances epoch when stale; `userClaimsCount` reset to 0 | +| `EpochOpened(epochId, openedAt)` | First-ever claim submission (epoch 0), or automatically after every `closeCurrentEpoch()` | +| `EpochWithdrawalRequested(epochId, claimId, user, grossShares, netShares, feeShares)` | `requestEpochWithdrawal` / fallback path | +| `EpochWithdrawalCancelled(epochId, claimId, user, grossShares)` | `cancelEpochWithdrawal` | +| `EpochClosed(epochId, ppsAtClose, totalNetShares, totalNetAssets, totalFeeShares)` | `closeCurrentEpoch` | +| `EpochFundAttempt(epochId, needed, hotBefore, hotAfter)` | `fundEpoch` — emitted twice (before and after the liquidity waterfall) | +| `EpochFunded(epochId, totalNetAssets)` | `fundEpoch` — only on success | +| `EpochAssetsClaimed(epochId, claimId, user, assets, netShares)` | `claimEpochAssets` / `batchClaimEpochAssets`, per claim | +| `InstantExit(user, shares, netAssets, feeShares)` | `requestInstantWithdrawal` — settled-immediately path | +| `FeePaid(from, feeCollector, feeShares)` | `closeCurrentEpoch` (batched fee transfer) | +| `QueueWarmRefillFailed(epochId, amount, reason)` | `fundEpoch` — `bm.refill` reverted | +| `RealizedForQueue(deficit, got)` | `fundEpoch` — strategy redeem executed | --- -## 14. Glossary +## 10. Glossary | Term | Definition | |------|-----------| -| **queue** | `QueueStorage.Layout.queue` — append-only array of claim IDs | -| **head** | Index into `queue[]` pointing to first unsettled entry | -| **ghost entry** | A queue entry with `settled=true` or `shares=0` — created by settlement or cancellation | -| **pendingShares** | Total escrowed shares across all active (unsettled) claims | -| **bounded pre-scan** | `_boundedPreScan` — eligibility check pass before settling; bounded by `maxClaims × 2` + 32 consecutive ineligible | -| **cachedTA / cachedTS** | totalAssets / totalSupply snapshot taken once per settle batch | -| **deterministic PPS** | All claims in one `settleFeesAndProcessQueue` tx use the same price (cached snapshot) | -| **W2 rule** | Never block exits — all non-critical external calls are try/catch | -| **compactQueue** | External housekeeping function; physically removes settled head entries from `queue[]` array | -| **NAV freshness** | `warmNavTs` must be within 15 min (`MAX_WARM_NAV_AGE`) for `requestClaim`; soft refresh for settlement | -| **anti-spam epoch** | Rolling period for `maxClaimsPerUserPerEpoch` tracking (separate from withdrawal cap epoch) | -| **INSTANT in queue** | A claim stored with `c.immediate=true` that was not settled at request time; settles with INSTANT mode (cap check) in `_settleLoop` | +| **epoch** | A time-bounded bucket of withdrawal claims, sharing one locked PPS and one liquidity pull (settlement epoch — see §6 for the distinct cap epoch) | +| **escrowedShares** | Total shares held by the vault in escrow across all epochs — the epoch-model successor to `pendingShares` | +| **outstandingClaimCount** | Total unclaimed claims across all epochs — the dynamic-cap "queue depth" signal, persists across epoch boundaries | +| **oldestUnfundedEpochId** | Keeper cursor: oldest epoch that is Closed but not yet Funded | +| **ppsAtClose** | Price-per-share locked once at `closeCurrentEpoch()`; used for every claim in that epoch, forever | +| **liquidity waterfall** | `fundEpoch()`'s two-step liquidity sourcing: warm refill first, strategy redeem second | +| **pull-based claim** | `claimEpochAssets()` — the user (or their delegate) calls in to receive funds; no keeper push required | +| **W2 rule** | Never block exits — all non-critical external calls (NAV refresh, incentives, warm refill) are try/catch | +| **cap epoch** | The separate `ExitEngineLib`/`CoreStorage.epochStart` epoch governing the dynamic instant-withdrawal cap — not the same as the settlement epoch (§6) | --- ## Appendix: Code Reference Index -| Function | File | Approx line | -|----------|------|-------------| -| `QueueStorage.Layout` | `src/core/storage/QueueStorage.sol:24` | L10 | -| `Claim` struct | `src/core/storage/QueueStorage.sol:16` | L18 | -| `MAX_BATCH` constant | `src/core/modules/QueueModule.sol:57` | L18 | -| `MAX_WARM_NAV_AGE` constant | `src/core/modules/QueueModule.sol:58` | L20 | -| `MAX_SCAN_MULTIPLIER` constant | `src/core/modules/QueueModule.sol:61` | L22 | -| `MAX_CONSECUTIVE_INELIGIBLE` constant | `src/core/modules/QueueModule.sol:62` | L23 | -| `requestClaim` | `src/core/modules/QueueModule.sol:81` | L100 | -| `cancelClaim` | `src/core/modules/QueueModule.sol:173` | L180 | -| `settleFeesAndProcessQueue` | `src/core/modules/QueueModule.sol:207` | L200 | -| `_settleScan` | `src/core/modules/QueueModule.sol:358` | L310 | -| `_boundedPreScan` | `src/core/modules/QueueModule.sol:303` | L350 | -| `_settleLoop` | `src/core/modules/QueueModule.sol:407` | L430 | -| `_checkQueueAntiSpam` | `src/core/modules/QueueModule.sol:580` | L580 | -| `_trySoftRefreshWarmNav` | `src/core/modules/QueueModule.sol:628` | L626 | -| `_notifyIncentivesExit` | `src/core/modules/QueueModule.sol:644` | L643 | -| `compactQueue` | `src/core/modules/QueueModule.sol:562` | L562 | -| `_crystallize` | `src/core/modules/QueueModule.sol:763` | L763 | -| `_updateNavSmooth` | `src/core/modules/QueueModule.sol:805` | L805 | -| `_canSettleInstant` | `src/core/modules/QueueModule.sol:528` | L528 | -| `_convertToAssetsCached` | `src/core/modules/QueueModule.sol:698` | L698 | -| `rollEpochIfNeeded` | `src/core/libraries/ExitEngineLib.sol:77` | L40 | -| `calculateCapRemaining` | `src/core/libraries/ExitEngineLib.sol:106` | L60 | -| `computeFeeShares` | `src/core/libraries/ExitEngineLib.sol:151` | L155 | -| `_checkStandardExitAllowed` | `src/core/storage/FixedMaturityStorage.sol:91` | L90 | -| `_checkSettlementAllowed` | `src/core/storage/FixedMaturityStorage.sol:100` | L98 | +| Function / Item | File | Line | +|----------|------|------| +| `EpochQueueStorage.Layout` | `src/core/modules/EpochedQueueModule.sol` | 59 | +| `EpochData` struct | `src/core/modules/EpochedQueueModule.sol` | 37 | +| `EpochClaim` struct | `src/core/modules/EpochedQueueModule.sol` | 52 | +| `requestEpochWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 212 | +| `_requestEpochWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 228 | +| `cancelEpochWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 290 | +| `closeCurrentEpoch` | `src/core/modules/EpochedQueueModule.sol` | 327 | +| `fundEpoch` | `src/core/modules/EpochedQueueModule.sol` | 390 | +| `claimEpochAssets` | `src/core/modules/EpochedQueueModule.sol` | 470 | +| `batchClaimEpochAssets` | `src/core/modules/EpochedQueueModule.sol` | 515 | +| `endEpochCrystallize` / `_crystallize` | `src/core/modules/EpochedQueueModule.sol` | 566 / 576 | +| `_updateNavSmooth` | `src/core/modules/EpochedQueueModule.sol` | 654 | +| `requestInstantWithdrawal` | `src/core/modules/EpochedQueueModule.sol` | 698 | +| `currentEpochId` / `epochData` / `epochClaim` | `src/core/modules/EpochedQueueModule.sol` | 755 / 767 / 774 | +| `totalEscrowedShares` / `outstandingClaimCount` | `src/core/modules/EpochedQueueModule.sol` | 785 / 791 | +| `oldestUnfundedEpochId` / `epochDeficit` | `src/core/modules/EpochedQueueModule.sol` | 799 / 805 | +| `canCloseCurrentEpoch` / `currentEpochClaimCount` | `src/core/modules/EpochedQueueModule.sol` | 813 / 762 | +| `_minEpochDuration` | `src/core/modules/EpochedQueueModule.sol` | 827 | +| `_canInstant` / `_epochCapRemaining` | `src/core/modules/EpochedQueueModule.sol` | 917 / 946 | +| `rollEpochIfNeeded` (cap epoch, distinct — §6) | `src/core/libraries/ExitEngineLib.sol` | ~77 | +| `computeFeeShares` | `src/core/libraries/ExitEngineLib.sol` | ~151 | +| `_checkStandardExitAllowed` / `_checkSettlementAllowed` | `src/core/storage/FixedMaturityStorage.sol` | ~91 / ~100 | +| Selector wiring (production) | `src/core/libraries/SelectorLib.sol` | `getQueueModuleSelectors()` / `getQueueModuleViewSelectors()` | +| Reserved (unused) legacy slot | `src/core/storage/QueueStorage.sol` | kept only for EIP-7201 slot-collision safety | --- ## Footer -**Source commit**: `c39f9462` (branch `reorg/runbook-docs-consolidate-01a.2`) - -**Authoritative files read** (ADR-015 §2 workflow): - -| File | Lines | Notes | -|------|-------|-------| -| `src/core/modules/QueueModule.sol:207` | 841 | Full read | -| `src/core/storage/QueueStorage.sol:24` | 38 | Full read | -| `src/core/storage/CoreStorage.sol:38` | partial | head, pendingShares, anti-spam fields, packedFlags | -| `src/core/libraries/ExitEngineLib.sol:77` | 279 | Full read — rollEpochIfNeeded, computeFeeShares | - -**Discrepancies** (ADR-015 §5): - -1. `_settleLoop` at `src/core/modules/QueueModule.sol:506` advances `head` past contiguous settled/ghost claims after each batch. The loop is O(n) in the number of contiguous settled entries at the head — it runs in the same tx as settlement. In high-volume scenarios, this advancement can itself consume significant gas. No issue found in `c39f9462`; noted for monitoring. +**Source commit**: `f7e3544` -2. `_checkQueueAntiSpam` advances the anti-spam epoch via `QueueLib.shouldAdvanceEpoch` — a separate epoch counter from the withdrawal cap epoch (`ExitEngineLib.rollEpochIfNeeded`). The two epoch systems have independent storage fields and can have different durations. This is by design but adds complexity to parameter governance. +**Migration note**: `QueueModule.sol` and its FIFO/keeper-scan settlement model were fully +removed. `QueueStorage.sol` (the storage layout, not the business logic) is intentionally kept +as a permanently-reserved EIP-7201 slot — see `docs/storage-layout.md` — since repurposing a +namespaced slot that may have held live data on a prior deployment is unsafe regardless of +whether the owning contract is still deployed. diff --git a/docs/storage-layout.md b/docs/storage-layout.md index be1a8fa..7cff657 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -12,7 +12,7 @@ 2. [Direct Storage — CoreVault (Slots 0-6)](#2-direct-storage--corevault-slots-0-6) 3. [CoreStorage.Layout — EIP-7201 (dsf.core.main.storage.v1)](#3-corestoragelayout--eip-7201-dsfcoremainsstoragev1) 4. [FeeStorage.Layout — EIP-7201 (dsf.core.fee.storage.v1)](#4-feestoragelayout--eip-7201-dsfcorefeestoragev1) -5. [QueueStorage.Layout — EIP-7201 (dsf.core.queue.storage.v1)](#5-queuestoragelayout--eip-7201-dsfcorequeuestoragev1) +5. [EpochQueueStorage.Layout — EIP-7201 (multyr.storage.EpochQueue.v1)](#5-epochqueuestoragelayout--eip-7201-multyrstorageepochqueuev1) 6. [FixedMaturityStorage.Layout — EIP-7201 (dsf.core.fixedmaturity.storage.v1)](#6-fixedmaturitystoragelayout--eip-7201-dsfcorefixedmaturitystoragev1) 7. [BufferManager — Non-Namespaced Storage](#7-buffermanager--non-namespaced-storage) 8. [Storage Interaction Matrix](#8-storage-interaction-matrix) @@ -60,9 +60,10 @@ Because modules execute via `delegatecall`, they share the same storage as `Core | Library | Namespace | Used by | |---|---|---| | `CoreStorage` | `dsf.core.main.storage.v1` | CoreVault, all modules | -| `FeeStorage` | `dsf.core.fee.storage.v1` | ERC4626Module, QueueModule, AdminModule | -| `QueueStorage` | `dsf.core.queue.storage.v1` | QueueModule | -| `FixedMaturityStorage` | `dsf.core.fixedmaturity.storage.v1` | ERC4626Module, QueueModule, FixedMaturityModule, LiquidityOpsModule | +| `FeeStorage` | `dsf.core.fee.storage.v1` | ERC4626Module, EpochedQueueModule, AdminModule | +| `EpochQueueStorage` | `multyr.storage.EpochQueue.v1` | EpochedQueueModule; read by ERC4626Module, LiquidityOpsModule, FixedMaturityModule | +| `QueueStorage` | `dsf.core.queue.storage.v1` | none — reserved, retired with QueueModule | +| `FixedMaturityStorage` | `dsf.core.fixedmaturity.storage.v1` | ERC4626Module, EpochedQueueModule, FixedMaturityModule, LiquidityOpsModule | --- @@ -190,7 +191,7 @@ Source: `src/core/storage/CoreStorage.sol:57-62`. | `navSmooth` | `uint256` | EMA of `totalAssets()` | | `lastNavSmoothUpdate` | `uint64` | Timestamp of last smoothing update | -Source: `src/core/storage/CoreStorage.sol:64-69`. NAV smoothing is updated via `QueueModule.endEpochCrystallize()` → `_updateNavSmooth()`. It uses an exponential moving average with configurable `alphaBps` parameter. +Source: `src/core/storage/CoreStorage.sol:64-69`. NAV smoothing is updated via `EpochedQueueModule.endEpochCrystallize()` → `_updateNavSmooth()`. It uses an exponential moving average with configurable `alphaBps` parameter. ### 3.5 Per-User Mappings @@ -321,66 +322,83 @@ Source: `src/core/storage/FeeStorage.sol:61-66`. `highWaterMark` is initialized --- -## 5. QueueStorage.Layout — EIP-7201 (dsf.core.queue.storage.v1) +## 5. EpochQueueStorage.Layout — EIP-7201 (multyr.storage.EpochQueue.v1) ```solidity -// QueueStorage.sol:9-10 +// EpochedQueueModule.sol bytes32 internal constant SLOT = - 0x20afa2de85fad1e68653d750134f8c4543e7db931009cedccc72142811c77f00; + 0xd8f6996c75206120e7e007afb307a0ab5673f8e6af6fff1bc619c574ef0f3000; ``` -### 5.1 Claim Struct +The retired `QueueStorage` namespace (`dsf.core.queue.storage.v1`, +`src/core/storage/QueueStorage.sol`) is kept in the tree but is neither read nor +written by any module. It exists solely as a permanently reserved slot, since +repurposing a namespace that may have held live data is unsafe regardless of +whether its owning contract still exists. -```solidity -// QueueStorage.sol:16-24 -struct Claim { - address user; // 20 bytes — slot 0 [0, 19] - uint64 ts; // 8 bytes — slot 0 [20, 27] (packed with user) - bool immediate; // 1 byte — slot 0 [28] (packed with user, ts) - bool settled; // 1 byte — slot 0 [29] (packed with user, ts, immediate) - uint256 shares; // 32 bytes — slot 1 (separate slot) -} -``` - -The Claim struct is 2-slot optimized: `user + ts + immediate + settled` pack into slot 0 (30 bytes used), and `shares` occupies slot 1. Total: 64 bytes. +### 5.1 EpochData -**Critical field semantics**: -- `immediate`: set at creation time. In v9, instant claims that fall back to queue are stored as `immediate = false` (no epoch cap reservation). Source: `src/core/modules/QueueModule.sol:148-150`. -- `settled`: once true, the claim is a ghost entry. Settlement checks `!c.settled && c.shares > 0`. -- `shares`: shares held in escrow by the vault. When `cancelClaim` is called, these are returned to the user. - -### 5.2 Layout Fields +One record per epoch, keyed by `epochId`. | Field | Type | Purpose | |---|---|---| -| `queue` | `uint256[]` | Ordered array of claim IDs (FIFO) | -| `head` | `uint256` | First valid index (logical head, for O(1) head advance) | -| `nextClaimId` | `uint256` | Auto-increment; starts at 1 (0 = no claim) | -| `pendingShares` | `uint256` | Total shares currently in escrow | -| `claims[claimId]` | `mapping(uint256 => Claim)` | Per-claim data | - -Source: `src/core/storage/QueueStorage.sol:24-30`. +| `state` | `EpochState` | `Open` -> `Closed` -> `Funded` | +| `openedAt` / `closedAt` / `fundedAt` | `uint64` | Lifecycle timestamps | +| `totalGrossShares` | `uint256` | Sum of submitted shares, fee included | +| `totalNetShares` | `uint256` | Sum after fee deduction | +| `totalFeeShares` | `uint256` | Fee portion, batch-transferred at close | +| `ppsAtClose` | `uint256` | WAD price locked once, at `closeCurrentEpoch()` | +| `totalNetAssets` | `uint256` | `totalNetShares * ppsAtClose`, the epoch's liability | +| `claimedAssets` | `uint256` | Running total paid out | +| `claimCount` | `uint256` | Claims submitted to this epoch | -### 5.3 Queue Compaction +### 5.2 EpochClaim -The `queue` array grows monotonically. `head` advances forward past settled/ghost entries — this achieves O(1) per-claim settlement without array shifting. Periodically, `compactQueue()` can be called by anyone to remove processed head entries and reduce storage rent. Source: `src/core/modules/QueueModule.sol:562-578`. +One record per claim, keyed by `(epochId, claimId)`. Claim IDs restart at 1 in +each epoch; there is no global claim ID. -**Invariant**: `queue.length >= head` always. Active queue length = `queue.length - head`. - -### 5.4 Escrow Invariant +| Field | Type | Purpose | +|---|---|---| +| `user` | `address` | Claim owner, the only address that can claim or cancel | +| `netShares` | `uint256` | Burned from escrow at claim time | +| `feeShares` | `uint256` | Already sent to `feeCollector` at epoch close | +| `claimed` | `bool` | Set on payout, and reused to mark a cancellation | -The vault holds `pendingShares` worth of shares in escrow (address: `address(this)` in the share `_balances` mapping). The settlement loop checks: +There is no `immediate` flag. An instant request that cannot settle falls back +to the identical queue path, so the class of bug where a fallback claim was +stored as `immediate = true` and re-checked against the cap at settlement is +eliminated by construction rather than by a fix. -```solidity -// QueueModule.sol:437-443 -uint256 escrowBalance = _balanceOf(address(this)); -if (escrowBalance < c.shares) { - emit Events.QueueClaimSkippedEscrowUnderflow(...); - continue; -} -``` +### 5.3 Layout Fields -This guards against any accounting inconsistency where the vault's share balance is less than a claim's shares. In a correct deployment, this invariant should never trigger. +| Field | Type | Purpose | +|---|---|---| +| `currentEpochId` | `uint256` | The single `Open` epoch | +| `epochs` | `mapping(uint256 => EpochData)` | Per-epoch aggregate | +| `claims` | `mapping(uint256 => mapping(uint256 => EpochClaim))` | Per-claim data | +| `nextClaimId` | `mapping(uint256 => uint256)` | Per-epoch claim counter, starts at 1 | +| `escrowedShares` | `uint256` | Shares held in vault escrow across all epochs | +| `outstandingClaimCount` | `uint256` | Unclaimed claims across all epochs; the dynamic-cap queue-depth signal | +| `oldestUnfundedEpochId` | `uint256` | Keeper cursor: the oldest `Closed` epoch | +| `reservedForClaims` | `uint256` | Assets earmarked for `Funded`-but-unclaimed claims | +| `closedPendingAssets` | `uint256` | Locked-price liability of `Closed`-not-yet-`Funded` epochs | + +### 5.4 Escrow and Reservation Invariants + +Two invariants hold at all times, both asserted by the stateful suites: + +- `vault.balanceOf(vault) == escrowedShares` — every escrowed share is + accounted for. Fee shares leave escrow at epoch close, not at claim time. +- `assetBalanceOf(vault) >= reservedForClaims` — the vault always holds what it + has already promised to funded claimants. Every consumer of the hot balance + (instant exits, force exits, strategy deploys, warm-buffer deploys, funding a + later epoch) must treat `hot - reservedForClaims` as the only spendable + amount. + +`reservedForClaims` is not an exact round trip. It is taken on the epoch total +and released 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. +Never assert `reservedForClaims == 0`. --- @@ -481,12 +499,12 @@ Source: `src/core/modules/BufferManager.sol:46-75`. Exact slot numbers depend on Which modules read/write which namespaces: -| Namespace | CoreVault | ERC4626Module | QueueModule | AdminModule | LiquidityOpsModule | FixedMaturityModule | +| Namespace | CoreVault | ERC4626Module | EpochedQueueModule | AdminModule | LiquidityOpsModule | FixedMaturityModule | |---|---|---|---|---|---|---| | Direct (slots 0-6) | R/W (opsNavCache) | — | — | — | — | — | | `CoreStorage` | R/W (init, routing) | R (params, flags) | R/W (epoch, user ts) | R/W (components) | R (bm, router) | R (mode flags) | | `FeeStorage` | R (previewDeposit) | R/W (deposit fee) | R/W (perf fee, crystallize) | R/W (timelock) | — | — | -| `QueueStorage` | R (canSettle) | — | R/W (claims, queue) | — | — | — | +| `EpochQueueStorage` | R (canSettle, via routed views) | R (reservedForClaims) | R/W (epochs, claims, counters) | — | R (reservedForClaims, escrowedShares) | R (outstandingClaimCount) | | `FixedMaturityStorage` | — | R (gating) | R (gating) | — | R (gating) | R/W (lifecycle) | --- @@ -589,7 +607,7 @@ Fields in `CoreStorage.Layout` are NOT packed by size (each address occupies its ### 11.4 Claim `immediate = false` on fallback -When an INSTANT claim falls back to the queue (cap exhausted or lock period not passed), the stored claim has `immediate = false`. This means at settlement time, the standard fee tier applies (witBps only, no immediateExitPenaltyBps) and no epoch cap is consumed. This is a user-favorable design choice to avoid double-penalizing users who attempted an instant exit but were queued. Source: `src/core/modules/QueueModule.sol:148-150`. +When an INSTANT request cannot settle (cap exhausted, lock period not passed, or free liquidity below the ask), it falls back to the identical queue path used by `requestEpochWithdrawal`. The standard fee tier applies (witBps only, no immediateExitPenaltyBps) and no epoch cap is consumed. There is no flag distinguishing the two, so the two paths cannot diverge. ### 11.5 fundingFailedPPS immutability @@ -603,31 +621,33 @@ This section documents which functions within each module read or write each sto ### 12.1 ERC4626Module -| Function | CoreStorage | FeeStorage | QueueStorage | FixedMaturityStorage | +| Function | CoreStorage | FeeStorage | EpochQueueStorage | FixedMaturityStorage | |---|---|---|---|---| | `deposit()` | R (packedFlags, paramMinDelay, bufferManager) | R (fee.depBps) | — | R (_checkDepositsAllowed) | | `mint()` | R (packedFlags, bufferManager) | R (fee.depBps) | — | R (_checkDepositsAllowed) | | `withdraw() / redeem()` | — | — | — | — | -| `forceWithdraw()` | R/W (lastDepositTs, epochStart, packedFlags) | R/W (witBps, forceExitPenaltyBps) | W (new Claim) | R (_checkForceExitAllowed) | -| `forceWithdrawAll()` | R/W (same as forceWithdraw) | R/W (same) | W (new Claims) | R (_checkForceExitAllowed) | +| `forceWithdraw()` | R/W (lastDepositTs, epochStart, packedFlags) | R/W (witBps, forceExitPenaltyBps) | R (reservedForClaims) | R (_checkForceExitAllowed) | +| `forceWithdrawAll()` | R/W (same as forceWithdraw) | R/W (same) | R (reservedForClaims) | R (_checkForceExitAllowed) | | `_depositInternal()` | R/W (lastDepositTs, packedFlags, navSmooth) | R (depBps) | — | R (gating) | | `_ensureFreshWarmNav()` | R (bufferManager) | — | — | — | Source: `src/core/modules/ERC4626Module.sol:81-332`. -### 12.2 QueueModule +### 12.2 EpochedQueueModule -| Function | CoreStorage | FeeStorage | QueueStorage | FixedMaturityStorage | +| Function | CoreStorage | FeeStorage | EpochQueueStorage | FixedMaturityStorage | |---|---|---|---|---| -| `requestClaim(immediate, shares)` | R/W (lastDepositTs, epochWithdrawn, epochStart) | R (witBps, immediateExitPenaltyBps) | R/W (new Claim, pendingShares) | R (_checkStandardExitAllowed) | -| `settleFeesAndProcessQueue()` | R/W (epochWithdrawn) | R/W (perf fee, crystallize) | R/W (head, settled) | R (_checkSettlementAllowed) | -| `cancelClaim(claimId)` | R (lastDepositTs) | — | R/W (claim.settled, pendingShares) | — | -| `compactQueue()` | — | — | R/W (queue[], head) | — | -| `endEpochCrystallize()` | R/W (navSmooth, lastCrystallize) | R/W (highWaterMark, perfRateX) | — | — | -| `_settleScan()` | R/W (epochWithdrawn, bufferManager) | — | R/W (head, claims) | R (gating) | -| `_settleLoop()` | R/W (epochWithdrawn) | R (witBps) | R/W (settled, pendingShares) | — | - -Source: `src/core/modules/QueueModule.sol:81-803`. +| `requestEpochWithdrawal(shares)` | R (params, incentivesEngine) | R (witBps) | R/W (new claim, escrowedShares, outstandingClaimCount) | R (_checkStandardExitAllowed) | +| `requestInstantWithdrawal(shares)` | R/W (lastDepositTs, epochWithdrawn, epochStart) | R (witBps, immediateExitPenaltyBps) | R (reservedForClaims); R/W on fallback | R (_checkStandardExitAllowed) | +| `cancelEpochWithdrawal(epochId, claimId)` | R/W (packedFlags guard) | — | R/W (claim.claimed, escrowedShares, outstandingClaimCount) | — | +| `closeCurrentEpoch()` | R/W (packedFlags guard, bufferManager) | R (feeCollector) | R/W (ppsAtClose, state, closedPendingAssets, escrowedShares) | R (_checkSettlementAllowed) | +| `fundEpoch(epochId)` | R/W (packedFlags guard, bufferManager, router) | — | R/W (state, reservedForClaims, closedPendingAssets, oldestUnfundedEpochId) | — | +| `claimEpochAssets(epochId, claimId)` | R/W (packedFlags guard) | — | R/W (claim.claimed, escrowedShares, outstandingClaimCount, reservedForClaims) | — | +| `batchClaimEpochAssets(epochId, ids)` | R/W (packedFlags guard) | — | R/W (same as claimEpochAssets, per id) | — | +| `syncOldestUnfundedEpoch()` | — | — | R/W (oldestUnfundedEpochId) | — | +| `endEpochCrystallize()` | R/W (navSmooth, lastNavSmoothUpdate) | R/W (highWaterMark, perfRateX, lastCrystallize) | — | — | + +Source: `src/core/modules/EpochedQueueModule.sol`. ### 12.3 AdminModule @@ -690,7 +710,8 @@ The `& ~bytes32(uint256(0xff))` operation clears the lowest byte, ensuring the s |---|---|---| | `dsf.core.main.storage.v1` | `0xff7b491291207fbb51df1ab8f042e8ee7f087c9a7e4a083e1a2dbbddb742ef00` | `src/core/storage/CoreStorage.sol:16` | | `dsf.core.fee.storage.v1` | `0x70739e319b75b4e5834916b9ca624fcbb6af45b4e67e7e365061fa4e1afc2100` | `src/core/storage/FeeStorage.sol:10` | -| `dsf.core.queue.storage.v1` | `0x20afa2de85fad1e68653d750134f8c4543e7db931009cedccc72142811c77f00` | `src/core/storage/QueueStorage.sol:10` | +| `multyr.storage.EpochQueue.v1` | `0xd8f6996c75206120e7e007afb307a0ab5673f8e6af6fff1bc619c574ef0f3000` | `src/core/modules/EpochedQueueModule.sol` | +| `dsf.core.queue.storage.v1` | `0x20afa2de85fad1e68653d750134f8c4543e7db931009cedccc72142811c77f00` | `src/core/storage/QueueStorage.sol` (reserved, unused) | | `dsf.core.fixedmaturity.storage.v1` | `0xa3a7555930e5242b25f368378dfab11804bc8d89ad6df651515d4b215e809300` | `src/core/storage/FixedMaturityStorage.sol:27` | > **Historical note**: Prior to run book FIX-EIP7201-SLOTS-01 (2026-05-15), 3 of 4 SLOTs were arbitrary placeholder patterns (`0x5f3e8c9a...`, `0x2b4d6f8a...`, `0x8a3c5e7b...`). Corrected to true EIP-7201 keccak hashes. See FINDING-OOS-03. @@ -773,7 +794,7 @@ CoreStorage.Layout storage core = CoreStorage.layout(); ### 14.5 Treating `pendingShares` as Share Count for NAV -**Anti-pattern**: Including `QueueStorage.layout().pendingShares` in totalSupply or totalAssets computations. +**Anti-pattern**: Including `EpochQueueStorage.layout().escrowedShares` in totalSupply or totalAssets computations. **Why dangerous**: Shares held in escrow (pending queue claims) are already counted in `_totalSupply` (slot 2) and `_balances[address(this)]`. Including them again would double-count, artificially increasing share supply and deflating PPS. @@ -793,8 +814,8 @@ Quick reference for auditors navigating the codebase: | Active fee config | `FeeStorage.layout().fee` | `src/core/storage/FeeStorage.sol:55` | | Pending fee change | `FeeStorage.layout().pendingFee.exists` | `src/core/storage/FeeStorage.sol:56` | | HWM for perf fee | `FeeStorage.layout().highWaterMark` | `src/core/storage/FeeStorage.sol:63` | -| Active queue head | `QueueStorage.layout().head` | `src/core/storage/QueueStorage.sol:26` | -| Total escrowed shares | `QueueStorage.layout().pendingShares` | `src/core/storage/QueueStorage.sol:28` | +| Oldest unfunded epoch | `EpochQueueStorage.layout().oldestUnfundedEpochId` | `src/core/modules/EpochedQueueModule.sol` | +| Total escrowed shares | `EpochQueueStorage.layout().escrowedShares` | `src/core/modules/EpochedQueueModule.sol` | | Vault mode (OE vs FM) | `FixedMaturityStorage.layout().vaultMode` | `src/core/storage/FixedMaturityStorage.sol:31` | | FM lifecycle state | `FixedMaturityStorage.layout().vaultState` | `src/core/storage/FixedMaturityStorage.sol:32` | | Funding deadline | `FixedMaturityStorage.layout().fundingDeadlineTs` | `src/core/storage/FixedMaturityStorage.sol:37` | diff --git a/docs/testing.md b/docs/testing.md index 152d937..1a2101e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -151,7 +151,7 @@ All profiles defined in `foundry.toml`. Default EVM version: `shanghai`. Fork te Tests use `test/helpers/MockParamsProvider.sol` to provide controllable `IParamsProvider` implementation. This allows setting `lockPeriod`, `maxClaimsPerEpoch`, `cooldownPerClaim`, fee params in isolation without a live `GlobalConfig`. -**Source**: `src/core/modules/AdminModule.sol:73`, `src/core/modules/QueueModule.sol:207`, `src/core/storage/CoreStorage.sol:38`. +**Source**: `src/core/modules/AdminModule.sol`, `src/core/modules/EpochedQueueModule.sol`, `src/core/storage/CoreStorage.sol`. ### 4.3 Key Test Patterns @@ -371,7 +371,8 @@ forge test --fork-url $ARBITRUM_ARCHIVE_RPC_URL --fork-retry-backoff 1000 --fork **EIP7201 namespaces tested**: - `dsf.core.main.storage.v1` → `CoreStorage.SLOT` - `dsf.core.fee.storage.v1` → `FeeStorage.SLOT` -- `dsf.core.queue.storage.v1` → `QueueStorage.SLOT` +- `multyr.storage.EpochQueue.v1` → `EpochQueueStorage.SLOT` +- `dsf.core.queue.storage.v1` → `QueueStorage.SLOT` (reserved, unused) - `dsf.core.fixedmaturity.storage.v1` → `FixedMaturityStorage.SLOT` **Source**: `test/security/EIP7201Compliance.t.sol`, `src/core/storage/CoreStorage.sol:16`. @@ -462,7 +463,8 @@ Minimum coverage targets (not enforced in CI at this time): | `MockParamsProvider` | `test/helpers/MockParamsProvider.sol` | Controlled IParamsProvider for isolation | | `CoreStorage.SLOT` | `src/core/storage/CoreStorage.sol:16` | `dsf.core.main.storage.v1` | | `FeeStorage.SLOT` | `src/core/storage/FeeStorage.sol:9` | `dsf.core.fee.storage.v1` | -| `QueueStorage.SLOT` | `src/core/storage/QueueStorage.sol:9` | `dsf.core.queue.storage.v1` | +| `EpochQueueStorage.SLOT` | `src/core/modules/EpochedQueueModule.sol` | `multyr.storage.EpochQueue.v1` | +| `QueueStorage.SLOT` | `src/core/storage/QueueStorage.sol` | `dsf.core.queue.storage.v1` (reserved, unused) | | `FixedMaturityStorage.SLOT` | `src/core/storage/FixedMaturityStorage.sol:27` | `dsf.core.fixedmaturity.storage.v1` | --- diff --git a/script/DeployCoreSystem.s.sol b/script/DeployCoreSystem.s.sol index 6accf8b..77da102 100644 --- a/script/DeployCoreSystem.s.sol +++ b/script/DeployCoreSystem.s.sol @@ -8,7 +8,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I // Core import { CoreVault } from "@multyr-core/core/CoreVault.sol"; -import { QueueModule } from "@multyr-core/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "@multyr-core/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "@multyr-core/core/modules/AdminModule.sol"; import { ERC4626Module } from "@multyr-core/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "@multyr-core/core/modules/LiquidityOpsModule.sol"; @@ -88,7 +88,7 @@ contract DeployCoreSystem is Script { // Phase 3: Core + Modules CoreVault vault; - QueueModule queueModule; + EpochedQueueModule queueModule; AdminModule adminModule; ERC4626Module erc4626Module; LiquidityOpsModule liquidityOpsModule; @@ -327,9 +327,9 @@ contract DeployCoreSystem is Script { console.log("[3.1b] Vault registered in factory (subgraph template active)"); } - // 3.2 QueueModule (stateless - no constructor) - result.queueModule = new QueueModule(); - console.log("[3.2] QueueModule:", address(result.queueModule)); + // 3.2 EpochedQueueModule (stateless - no constructor) + result.queueModule = new EpochedQueueModule(); + console.log("[3.2] EpochedQueueModule:", address(result.queueModule)); // 3.3 AdminModule (stateless) result.adminModule = new AdminModule(); @@ -427,8 +427,6 @@ contract DeployCoreSystem is Script { address(result.bufferManager), address(result.strategyRouter), address(result.globalConfig), - 25, // defaultMaxClaims - 100, // hardMaxClaims 1000000e6, // defaultMaxRealize (1M USDC) 1000000e6, // defaultMaxDeploy (1M USDC) 10, // minRealizeGapBps (0.1%) @@ -486,7 +484,13 @@ contract DeployCoreSystem is Script { address[] memory warmAdapters = new address[](2); warmAdapters[0] = address(result.aaveWarmAdapter); warmAdapters[1] = address(result.morphoWarmAdapter); - result.vault.approveWarmAdapters(warmAdapters); + // Bounded, not unlimited: each adapter may pull at most this much in + // total before governance has to top it up. Sized off the vault + // deposit cap; see docs/deployment.md for the post-deploy tuning + // this and the other manual parameters need. + uint256 warmAdapterCap = vm.envOr("WARM_ADAPTER_ALLOWANCE_CAP", uint256(1_000_000e6)); + result.vault.approveWarmAdapters(warmAdapters, warmAdapterCap); + console.log(" Warm adapter allowance cap:", warmAdapterCap); } // 5.5 HealthRegistry @@ -593,11 +597,11 @@ contract DeployCoreSystem is Script { function _configureModuleRouting(CoreDeploymentResult memory result) internal { bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); _setModulesBatch(result.vault, queueSels, address(result.queueModule), SelectorLib.ROLE_PUBLIC); - console.log(" QueueModule write selectors:", queueSels.length); + console.log(" EpochedQueueModule write selectors:", queueSels.length); bytes4[] memory queueViewSels = SelectorLib.getQueueModuleViewSelectors(); _setModulesBatch(result.vault, queueViewSels, address(result.queueModule), SelectorLib.ROLE_PUBLIC); - console.log(" QueueModule view selectors:", queueViewSels.length); + console.log(" EpochedQueueModule view selectors:", queueViewSels.length); bytes4[] memory adminOwnerSels = SelectorLib.getAdminModuleOwnerSelectors(); _setModulesBatch(result.vault, adminOwnerSels, address(result.adminModule), SelectorLib.ROLE_OWNER); @@ -620,8 +624,8 @@ contract DeployCoreSystem is Script { require(result.vault.moduleOf(bytes4(keccak256("withdraw(uint256,address,address)"))) == address(result.erc4626Module), "GATE: withdraw routing"); require(result.vault.moduleOf(bytes4(keccak256("redeem(uint256,address,address)"))) == address(result.erc4626Module), "GATE: redeem routing"); - require(result.vault.moduleOf(IQueueModule.requestClaim.selector) == address(result.queueModule), "GATE: requestClaim routing"); - require(result.vault.moduleOf(IQueueModule.settleFeesAndProcessQueue.selector) == address(result.queueModule), "GATE: settle routing"); + require(result.vault.moduleOf(IQueueModule.requestEpochWithdrawal.selector) == address(result.queueModule), "GATE: requestEpochWithdrawal routing"); + require(result.vault.moduleOf(IQueueModule.closeCurrentEpoch.selector) == address(result.queueModule), "GATE: closeCurrentEpoch routing"); console.log(" [OK] Critical selector routing verified"); } @@ -756,7 +760,7 @@ contract DeployCoreSystem is Script { console.log(" SystemSealer: ", address(result.systemSealer)); console.log("Core:"); console.log(" CoreVault: ", address(result.vault)); - console.log(" QueueModule: ", address(result.queueModule)); + console.log(" EpochedQueueModule: ", address(result.queueModule)); console.log(" AdminModule: ", address(result.adminModule)); console.log(" ERC4626Module: ", address(result.erc4626Module)); console.log(" LiquidityOpsModule: ", address(result.liquidityOpsModule)); diff --git a/script/DeployFixedMaturityVault.s.sol b/script/DeployFixedMaturityVault.s.sol index ea84d28..96242d7 100644 --- a/script/DeployFixedMaturityVault.s.sol +++ b/script/DeployFixedMaturityVault.s.sol @@ -7,7 +7,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I // Core import { CoreVault } from "@multyr-core/core/CoreVault.sol"; -import { QueueModule } from "@multyr-core/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "@multyr-core/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "@multyr-core/core/modules/AdminModule.sol"; import { ERC4626Module } from "@multyr-core/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "@multyr-core/core/modules/LiquidityOpsModule.sol"; @@ -60,7 +60,7 @@ contract DeployFixedMaturityVault is Script { struct FMDeploymentResult { CoreVault vault; - QueueModule queueModule; + EpochedQueueModule queueModule; AdminModule adminModule; ERC4626Module erc4626Module; LiquidityOpsModule liquidityOpsModule; @@ -215,13 +215,13 @@ contract DeployFixedMaturityVault is Script { console.log("[2.1] CoreVault:", address(result.vault)); require(result.vault.paused(), "GATE: vault must start PAUSED"); - result.queueModule = new QueueModule(); + result.queueModule = new EpochedQueueModule(); result.adminModule = new AdminModule(); result.erc4626Module = new ERC4626Module(); result.liquidityOpsModule = new LiquidityOpsModule(); result.fixedMaturityModule = new FixedMaturityModule(); - console.log("[2.2] QueueModule:", address(result.queueModule)); + console.log("[2.2] EpochedQueueModule:", address(result.queueModule)); console.log("[2.3] AdminModule:", address(result.adminModule)); console.log("[2.4] ERC4626Module:", address(result.erc4626Module)); console.log("[2.5] LiquidityOpsModule:", address(result.liquidityOpsModule)); @@ -559,7 +559,7 @@ contract DeployFixedMaturityVault is Script { console.log("================================================================"); console.log("CoreVault: ", address(r.vault)); console.log("FixedMaturityModule: ", address(r.fixedMaturityModule)); - console.log("QueueModule: ", address(r.queueModule)); + console.log("EpochedQueueModule: ", address(r.queueModule)); console.log("AdminModule: ", address(r.adminModule)); console.log("ERC4626Module: ", address(r.erc4626Module)); console.log("LiquidityOpsModule: ", address(r.liquidityOpsModule)); diff --git a/script/DeployFixedMaturityVaultUpkeep.s.sol b/script/DeployFixedMaturityVaultUpkeep.s.sol index 72fa429..4a61841 100644 --- a/script/DeployFixedMaturityVaultUpkeep.s.sol +++ b/script/DeployFixedMaturityVaultUpkeep.s.sol @@ -15,7 +15,7 @@ import { FixedMaturityVaultUpkeep } from "@multyr-core/automation/FixedMaturityV /// Does not require any special permissions to deploy -- Chainlink registers as forwarder. /// @custom:chain-id 42161 (Arbitrum One -- enforced at runtime) /// @custom:env-vars DEPLOYER_PRIVATE_KEY, FM_VAULT_ADDRESS, -/// FM_MAX_SETTLE_CLAIMS (opt, default 15), FM_UPKEEP_STRICT_MODE (opt, default true) +/// FM_UPKEEP_STRICT_MODE (opt, default true) /// @custom:post-deploy 1) Register on Chainlink Automation /// 2) No additional vault grants needed -- upkeep reads public FM state contract DeployFixedMaturityVaultUpkeep is Script { @@ -32,7 +32,6 @@ contract DeployFixedMaturityVaultUpkeep is Script { address deployer = vm.addr(deployerPk); address fmVault = vm.envAddress("FM_VAULT_ADDRESS"); - uint32 maxClaims = uint32(vm.envOr("FM_MAX_SETTLE_CLAIMS", uint256(15))); bool strictMode = vm.envOr("FM_UPKEEP_STRICT_MODE", true); require(fmVault != address(0), "FM_VAULT_ADDRESS required"); @@ -42,13 +41,12 @@ contract DeployFixedMaturityVaultUpkeep is Script { console.log("================================================================"); console.log("Deployer: ", deployer); console.log("FM Vault: ", fmVault); - console.log("Max Claims: ", maxClaims); console.log("Strict Mode: ", strictMode); console.log("================================================================"); vm.startBroadcast(deployerPk); - fmUpkeep = new FixedMaturityVaultUpkeep(fmVault, maxClaims, strictMode); + fmUpkeep = new FixedMaturityVaultUpkeep(fmVault, strictMode); vm.stopBroadcast(); diff --git a/script/DeployQueueModule.s.sol b/script/DeployQueueModule.s.sol index 8c61c4c..28fb7eb 100644 --- a/script/DeployQueueModule.s.sol +++ b/script/DeployQueueModule.s.sol @@ -4,15 +4,18 @@ pragma solidity ^0.8.28; import { Script } from "forge-std/Script.sol"; import { console } from "forge-std/console.sol"; -import { QueueModule } from "@multyr-core/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "@multyr-core/core/modules/EpochedQueueModule.sol"; import { SelectorLib } from "@multyr-core/core/libraries/SelectorLib.sol"; import { CoreVault } from "@multyr-core/core/CoreVault.sol"; -/// @title DeployQueueModule -- QueueModule standalone redeploy (incident response) -/// @notice Deploys a new QueueModule delegatecall target and re-wires it to an existing CoreVault. -/// Replaces RedeployQueueModule legacy script. Idempotent: safe to redeploy and re-wire. -/// Designed for incident response when QueueModule must be upgraded without full redeploy. -/// @dev QueueModule is stateless -- no constructor arguments, no storage. +/// @title DeployQueueModule -- EpochedQueueModule standalone redeploy (incident response) +/// @notice Deploys a new EpochedQueueModule delegatecall target and re-wires it to an +/// existing CoreVault. Idempotent: safe to redeploy and re-wire. +/// Designed for incident response when the queue module must be upgraded +/// without a full redeploy. +/// @dev EpochedQueueModule is stateless -- no constructor arguments, no own storage +/// (EIP-7201 namespaced storage lives at a fixed slot, independent of which +/// module instance is wired to it). /// Re-wiring requires vault owner (pre-seal) or timelock (post-seal routing freeze lifted). /// CRITICAL: Do NOT re-wire after routing is frozen unless a timelock tx is submitted first. /// @custom:chain-id 42161 (Arbitrum One -- enforced at runtime) @@ -22,12 +25,12 @@ import { CoreVault } from "@multyr-core/core/CoreVault.sol"; /// @custom:post-deploy If REWIRE=false: /// 1) Check isRoutingFrozen() on vault before proceeding /// 2) vault.setModulesBatch(queueModuleSelectors, newQueueModule, ROLE_PUBLIC) -/// 3) Verify routing: vault.moduleOf(requestClaim.selector) == newModule +/// 3) Verify routing: vault.moduleOf(requestEpochWithdrawal.selector) == newModule contract DeployQueueModule is Script { uint256 constant ARBITRUM_ONE_CHAIN_ID = 42161; - function run() external returns (QueueModule queueModule) { + function run() external returns (EpochedQueueModule queueModule) { require( block.chainid == ARBITRUM_ONE_CHAIN_ID, "WRONG_CHAIN: DeployQueueModule is Arbitrum-only (chainId 42161)" @@ -53,9 +56,9 @@ contract DeployQueueModule is Script { vm.startBroadcast(deployerPk); - // QueueModule has no constructor -- purely stateless delegatecall target - queueModule = new QueueModule(); - console.log("QueueModule deployed:", address(queueModule)); + // EpochedQueueModule has no constructor -- purely stateless delegatecall target + queueModule = new EpochedQueueModule(); + console.log("EpochedQueueModule deployed:", address(queueModule)); if (rewire) { CoreVault vault = CoreVault(coreVault); @@ -95,7 +98,7 @@ contract DeployQueueModule is Script { console.log(" 3. vault.setModulesBatch(queueViewSelectors,", address(queueModule), ", ROLE_PUBLIC)"); } else { console.log("Rewire complete. Verify:"); - console.log(" vault.moduleOf(requestClaim.selector) ==", address(queueModule)); + console.log(" vault.moduleOf(requestEpochWithdrawal.selector) ==", address(queueModule)); } } } diff --git a/script/DeployVaultUpkeep.s.sol b/script/DeployVaultUpkeep.s.sol index 395572a..1e44bf2 100644 --- a/script/DeployVaultUpkeep.s.sol +++ b/script/DeployVaultUpkeep.s.sol @@ -16,7 +16,6 @@ import { BufferManager } from "@multyr-core/core/modules/BufferManager.sol"; /// @custom:chain-id 42161 (Arbitrum One -- enforced at runtime) /// @custom:env-vars DEPLOYER_PRIVATE_KEY, VAULT_ADDRESS, BUFFER_MANAGER_ADDRESS, /// STRATEGY_ROUTER_ADDRESS, GLOBAL_CONFIG_ADDRESS, -/// DEFAULT_MAX_CLAIMS (opt, default 25), HARD_MAX_CLAIMS (opt, default 100), /// DEFAULT_MAX_REALIZE (opt, default 1000000e6), DEFAULT_MAX_DEPLOY (opt, default 1000000e6), /// MIN_REALIZE_GAP_BPS (opt, default 10), MIN_REALIZE_FLOOR (opt, default 10000) /// @custom:post-deploy 1) bufferManager.setKeeper(upkeep) -- caller must hold BM owner role @@ -40,8 +39,6 @@ contract DeployVaultUpkeep is Script { address strategyRouter = vm.envAddress("STRATEGY_ROUTER_ADDRESS"); address globalConfig = vm.envAddress("GLOBAL_CONFIG_ADDRESS"); - uint256 defaultMaxClaims = vm.envOr("DEFAULT_MAX_CLAIMS", uint256(25)); - uint256 hardMaxClaims = vm.envOr("HARD_MAX_CLAIMS", uint256(100)); uint256 defaultMaxRealize = vm.envOr("DEFAULT_MAX_REALIZE", uint256(1000000e6)); uint256 defaultMaxDeploy = vm.envOr("DEFAULT_MAX_DEPLOY", uint256(1000000e6)); uint256 minRealizeGapBps = vm.envOr("MIN_REALIZE_GAP_BPS", uint256(10)); @@ -51,7 +48,6 @@ contract DeployVaultUpkeep is Script { require(bufferManager != address(0), "BUFFER_MANAGER_ADDRESS required"); require(strategyRouter != address(0), "STRATEGY_ROUTER_ADDRESS required"); require(globalConfig != address(0), "GLOBAL_CONFIG_ADDRESS required"); - require(defaultMaxClaims <= hardMaxClaims, "defaultMaxClaims must be <= hardMaxClaims"); console.log("================================================================"); console.log(" DEPLOY VAULT UPKEEP (OE standalone)"); @@ -61,8 +57,6 @@ contract DeployVaultUpkeep is Script { console.log("BufferManager: ", bufferManager); console.log("StrategyRouter: ", strategyRouter); console.log("GlobalConfig: ", globalConfig); - console.log("defaultMaxClaims: ", defaultMaxClaims); - console.log("hardMaxClaims: ", hardMaxClaims); console.log("defaultMaxRealize:", defaultMaxRealize); console.log("defaultMaxDeploy: ", defaultMaxDeploy); console.log("================================================================"); @@ -74,8 +68,6 @@ contract DeployVaultUpkeep is Script { bufferManager, strategyRouter, globalConfig, - defaultMaxClaims, - hardMaxClaims, defaultMaxRealize, defaultMaxDeploy, uint16(minRealizeGapBps), diff --git a/script/lib/DeployLib.sol b/script/lib/DeployLib.sol index 964548b..afa8bc5 100644 --- a/script/lib/DeployLib.sol +++ b/script/lib/DeployLib.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.28; import { DeployTypes } from "@multyr-core/libs/DeployTypes.sol"; import { CoreVault } from "@multyr-core/core/CoreVault.sol"; -import { QueueModule } from "@multyr-core/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "@multyr-core/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "@multyr-core/core/modules/AdminModule.sol"; import { ERC4626Module } from "@multyr-core/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "@multyr-core/core/modules/LiquidityOpsModule.sol"; @@ -25,7 +25,7 @@ library DeployLib { function deploy( DeployTypes.DeployConfig memory config, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule @@ -37,7 +37,7 @@ library DeployLib { function deployDeterministic( DeployTypes.DeployConfig memory config, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule, @@ -50,7 +50,7 @@ library DeployLib { function _deploy( DeployTypes.DeployConfig memory config, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule, @@ -132,7 +132,7 @@ library DeployLib { function _configureRouting( CoreVault vault, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule diff --git a/src/automation/FixedMaturityVaultUpkeep.sol b/src/automation/FixedMaturityVaultUpkeep.sol index 79ba9be..94dc0bf 100644 --- a/src/automation/FixedMaturityVaultUpkeep.sol +++ b/src/automation/FixedMaturityVaultUpkeep.sol @@ -5,7 +5,7 @@ import { AutomationCompatibleInterface } from "./AutomationCompatibleInterface.s import { IFixedMaturityModule } from "../interfaces/IFixedMaturityModule.sol"; import { IFixedTermStrategy } from "../interfaces/IFixedTermStrategy.sol"; import { VaultMode, VaultState } from "../core/storage/FixedMaturityStorage.sol"; -import { IQueueModule } from "../interfaces/IQueueModule.sol"; +import { EpochedQueueModule } from "../core/modules/EpochedQueueModule.sol"; // ── Upkeep opcodes ──────────────────────────────────────────────────────────── uint8 constant OP_NONE = 0; @@ -14,9 +14,10 @@ uint8 constant OP_FM_FAIL = 2; // Funding -> FundingFailed uint8 constant OP_FM_ACTIVATE = 3; // Starting -> Active (+ deploy via StrategyRouter) uint8 constant OP_FM_MARK_MATURED = 4; // Active -> Matured uint8 constant OP_FM_RECALL = 5; // Matured: recall capital via Core -> Router -> Strategy -uint8 constant OP_FM_SETTLE = 6; // Matured: settleFeesAndProcessQueue batch -uint8 constant OP_FM_CLOSE = 7; // Matured -> Closed (pendingShares == 0) -uint8 constant OP_FM_MONITOR_ONLY = 8; // explicit no-op for monitoring +uint8 constant OP_FM_EPOCH_CLOSE = 6; // Matured: closeCurrentEpoch() (epoch-model queue) +uint8 constant OP_FM_EPOCH_FUND = 7; // Matured: fundEpoch(oldestUnfundedEpochId) +uint8 constant OP_FM_CLOSE = 8; // Matured -> Closed (outstandingClaimCount == 0) +uint8 constant OP_FM_MONITOR_ONLY = 9; // explicit no-op for monitoring // ── Errors ──────────────────────────────────────────────────────────────────── error InvalidFixedMaturityVault(); @@ -30,17 +31,28 @@ error UnknownOperation(); contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { address public immutable vault; - uint32 public immutable maxSettleClaimsPerUpkeep; bool public immutable strictMode; + // EPOCH_FUND stall backoff -- mirrors VaultUpkeep.sol's mechanism. In the + // Matured state, EPOCH_FUND and EPOCH_CLOSE are the ONLY two ops, and + // fundEpoch() can legitimately no-op forever under a persistent liquidity + // shortfall. Without this, checkUpkeep's unconditional EPOCH_FUND + // priority would starve EPOCH_CLOSE indefinitely for any claims + // accumulating in the still-open epoch. No Ownable here, so the + // threshold/backoff are fixed rather than governance-configurable. + uint256 internal lastEpochFundTargetId = type(uint256).max; + uint16 internal epochFundStallCount; + uint64 internal lastEpochFundStallTs; + uint16 internal constant EPOCH_FUND_STALL_THRESHOLD = 1; + uint32 internal constant EPOCH_FUND_STALL_BACKOFF_SECONDS = 900; // 15min + event FixedMaturityUpkeepChecked(uint8 indexed op, bool upkeepNeeded, uint8 indexed state); event FixedMaturityUpkeepPerformed(uint8 indexed op, uint8 indexed stateBefore, uint8 indexed stateAfter); event FixedMaturityUpkeepNoOp(uint8 indexed op, uint8 indexed state); - constructor(address vault_, uint32 maxClaims_, bool strict_) { + constructor(address vault_, bool strict_) { if (vault_ == address(0)) revert InvalidFixedMaturityVault(); vault = vault_; - maxSettleClaimsPerUpkeep = maxClaims_ == 0 ? 15 : maxClaims_; strictMode = strict_; } @@ -62,7 +74,7 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { uint256 net = IFixedMaturityModule(vault).netFundedAssets(); uint256 minFunds = IFixedMaturityModule(vault).minFundingAssets(); bool deadlinePassed = block.timestamp >= deadline; - if (deadlinePassed && net < minFunds) return (true, abi.encode(OP_FM_FAIL)); + if (deadlinePassed && net < minFunds) return (true, abi.encode(OP_FM_FAIL, uint256(0))); return (false, ""); } @@ -74,7 +86,7 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { if (block.timestamp >= IFixedMaturityModule(vault).maturityTs()) { address strat = IFixedMaturityModule(vault).fixedTermStrategy(); if (_stratIsMaturityReady(strat)) { - return (true, abi.encode(OP_FM_MARK_MATURED)); + return (true, abi.encode(OP_FM_MARK_MATURED, uint256(0))); } return (false, ""); } @@ -86,8 +98,29 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { // Once the matured strategy is fully drained, queue settlement must // resume even if the strategy continues to report "maturity ready". if (_stratWithdrawable(strat) > 0) return (false, ""); - uint256 pending = _pendingShares(); - if (pending > 0) return (true, abi.encode(OP_FM_SETTLE)); + + // Priority: unlock a backlogged closed-but-unfunded epoch first — + // fundEpoch() runs its own liquidity waterfall internally. Unless + // this exact epoch already stalled past the threshold, in which + // case yield to EPOCH_CLOSE for a cooldown window so claims + // accumulating in the still-open epoch aren't blocked forever by + // one persistently-underfunded epoch. + uint256 oldestUnfunded = _oldestUnfundedEpochId(); + uint256 curEpoch = _currentEpochId(); + if (oldestUnfunded < curEpoch) { + bool stalled = oldestUnfunded == lastEpochFundTargetId + && epochFundStallCount >= EPOCH_FUND_STALL_THRESHOLD + && block.timestamp < uint256(lastEpochFundStallTs) + uint256(EPOCH_FUND_STALL_BACKOFF_SECONDS); + if (!stalled) { + return (true, abi.encode(OP_FM_EPOCH_FUND, oldestUnfunded)); + } + } + + // Otherwise close the current epoch once its min duration has + // elapsed, but only if it actually has claims (anti-churn). + if (_canCloseCurrentEpoch() && _currentEpochClaimCount() > 0) { + return (true, abi.encode(OP_FM_EPOCH_CLOSE, uint256(0))); + } return (false, ""); } @@ -100,7 +133,7 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { // ═══════════════════════════════════════════════════════════════════════════ function performUpkeep(bytes calldata performData) external override { - uint8 op = abi.decode(performData, (uint8)); + (uint8 op, uint256 arg) = _decode(performData); (, VaultState stateBefore) = IFixedMaturityModule(vault).currentVaultModeAndState(); // Stale performData protection: mode must still be FixedMaturity @@ -122,8 +155,33 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { } else if (op == OP_FM_RECALL) { // Always via Core → StrategyRouter → Strategy, never direct strategy call IFixedMaturityModule(vault).recallFixedTermCapital(); - } else if (op == OP_FM_SETTLE) { - IQueueModule(vault).settleFeesAndProcessQueue(maxSettleClaimsPerUpkeep); + } else if (op == OP_FM_EPOCH_CLOSE) { + EpochedQueueModule(vault).closeCurrentEpoch(); + } else if (op == OP_FM_EPOCH_FUND) { + // fundEpoch() runs its own warm-refill -> strategy-redeem waterfall + // internally; a partial fund is retried next cycle, not a failure. + // Swallowed deliberately: a revert here (an already-Funded target, + // say) is a stall like any other, and must still be accounted for + // below. Leaving it to bubble would skip the bookkeeping entirely + // and let EPOCH_FUND keep its unconditional priority forever. + try EpochedQueueModule(vault).fundEpoch(arg) { } catch { } + + // Track whether this attempt made progress on the SAME target + // epoch, so checkUpkeep can yield priority once it stalls. Runs on + // both outcomes -- a revert is a stall, not a non-event. + uint256 stillOldest = _oldestUnfundedEpochId(); + if (stillOldest == arg) { + if (lastEpochFundTargetId == arg) { + epochFundStallCount++; + } else { + lastEpochFundTargetId = arg; + epochFundStallCount = 1; + } + lastEpochFundStallTs = uint64(block.timestamp); + } else { + lastEpochFundTargetId = type(uint256).max; + epochFundStallCount = 0; + } } else if (op == OP_FM_CLOSE) { IFixedMaturityModule(vault).closeFixedMaturityCycle(); } else if (op == OP_FM_MONITOR_ONLY) { @@ -158,10 +216,39 @@ contract FixedMaturityVaultUpkeep is AutomationCompatibleInterface { if (ok && data.length == 32) ready = abi.decode(data, (bool)); } - function _pendingShares() internal view returns (uint256 pending) { + function _oldestUnfundedEpochId() internal view returns (uint256 id) { + (bool ok, bytes memory data) = vault.staticcall( + abi.encodeWithSignature("oldestUnfundedEpochId()") + ); + if (ok && data.length == 32) id = abi.decode(data, (uint256)); + } + + function _currentEpochId() internal view returns (uint256 id) { + (bool ok, bytes memory data) = vault.staticcall( + abi.encodeWithSignature("currentEpochId()") + ); + if (ok && data.length == 32) id = abi.decode(data, (uint256)); + } + + function _canCloseCurrentEpoch() internal view returns (bool ready) { + (bool ok, bytes memory data) = vault.staticcall( + abi.encodeWithSignature("canCloseCurrentEpoch()") + ); + if (ok && data.length == 32) ready = abi.decode(data, (bool)); + } + + function _currentEpochClaimCount() internal view returns (uint256 count) { (bool ok, bytes memory data) = vault.staticcall( - abi.encodeWithSignature("pendingShares()") + abi.encodeWithSignature("currentEpochClaimCount()") ); - if (ok && data.length == 32) pending = abi.decode(data, (uint256)); + if (ok && data.length == 32) count = abi.decode(data, (uint256)); + } + + function _decode(bytes calldata data) internal pure returns (uint8 op, uint256 arg) { + if (data.length == 32) { + op = abi.decode(data, (uint8)); + return (op, 0); + } + (op, arg) = abi.decode(data, (uint8, uint256)); } } diff --git a/src/automation/VaultUpkeep.sol b/src/automation/VaultUpkeep.sol index 43ec82e..0b9d19a 100644 --- a/src/automation/VaultUpkeep.sol +++ b/src/automation/VaultUpkeep.sol @@ -9,30 +9,29 @@ import { IFixedMaturityModule } from "../interfaces/IFixedMaturityModule.sol"; import { VaultMode, VaultState } from "../core/storage/FixedMaturityStorage.sol"; import { FixedMaturityAutomationDisabledForMode } from "../core/libraries/Errors.sol"; -/// @notice Minimal interface for CoreVault v8 +/// @notice Minimal interface for CoreVault + EpochedQueueModule (epoch-model queue) interface ICoreVault { function canSettle() external view returns (bool); function canCrystallize() external view returns (bool); function canRealizeWithGap() external view returns (bool canR, uint256 gap); function canDeploy() external view returns (bool); - function settleFeesAndProcessQueue(uint256 maxClaims) external; function endEpochCrystallize() external; function realizeForReserveAndOps(uint256 maxAmount) external; - function realizeForQueue(uint256 target) external; - function deficitForQueue(uint256 maxClaims) external view returns (uint256); - function settlePreview(uint256 maxClaims) external view returns ( - uint256 eligibleCount, uint256 requiredHot, uint256 inspectedCount, bool hitEarlyExit - ); - function deployToStrategies(uint256 maxAmount) external; - function compactQueue() external; - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); function totalAssets() external view returns (uint256); + function deployToStrategies(uint256 maxAmount) external; function reconcilePendingExits(uint256 maxUsers) external returns (uint256); function pendingExitCount() external view returns (uint256); function canRebalanceStrategies() external view returns (bool); function rebalanceStrategies() external; + + // Epoch-model queue (EpochedQueueModule, delegatecall-dispatched) + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function currentEpochId() external view returns (uint256); + function oldestUnfundedEpochId() external view returns (uint256); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; } /// @notice Minimal interface for StrategyRouter cooldown state @@ -52,33 +51,35 @@ interface IGlobalConfigReader { enum Op { NONE, - SETTLE, + EPOCH_CLOSE, // closeCurrentEpoch(): lock PPS, batch-transfer fees, open next epoch + EPOCH_FUND, // fundEpoch(oldestUnfundedEpochId): pull liquidity, mark FUNDED CRYSTALLIZE, REBALANCE, DEPLOY, REALIZE, - REALIZE_FOR_QUEUE, - COMPACT, RECONCILE, STRATEGY_REBALANCE // Inter-strategy allocation rebalance (distinct from buffer REBALANCE) } error UnknownOp(); -/// @title VaultUpkeep v4 — Gas-optimized Chainlink Automation orchestrator -/// @notice v8 changes: -/// - SETTLE path: only settleFeesAndProcessQueue (no pre-settle rebalance/realize) +/// @title VaultUpkeep v5 — Gas-optimized Chainlink Automation orchestrator +/// @notice v5 changes (epoch-model queue cutover): +/// - EPOCH_CLOSE/EPOCH_FUND replace SETTLE: closeCurrentEpoch()/fundEpoch() are +/// O(1) per epoch regardless of claim count, so there's no maxClaims batch +/// size to configure anymore (DEFAULT_MAX_CLAIMS/HARD_MAX_CLAIMS removed). +/// - COMPACT removed: no flat array to compact in the epoch model. +/// - REALIZE_FOR_QUEUE removed: fundEpoch() already runs the warm-refill → +/// strategy-redeem liquidity waterfall internally, so a separate +/// keeper-triggered pre-settle realize step is redundant. /// - REALIZE: uses canRealizeWithGap() (single call, no redundant totalAssets) /// - Failure backoff with configurable threshold -/// - Target gas: checkUpkeep < 15k (no work), SETTLE from hot < 300k, SETTLE+redeem < 1.2M contract VaultUpkeep is AutomationCompatibleInterface, Ownable { ICoreVault public immutable core; IBufferManager public immutable bufferManager; IStrategyRouterReader public immutable router; IGlobalConfigReader public immutable globalConfig; - uint256 public immutable DEFAULT_MAX_CLAIMS; - uint256 public immutable HARD_MAX_CLAIMS; uint256 public immutable DEFAULT_MAX_REALIZE; uint256 public immutable DEFAULT_MAX_DEPLOY; uint16 public immutable minRealizeGapBps; @@ -88,7 +89,6 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { event UpkeepBackoffEntered(uint8 failures); event UpkeepBackoffExited(); event FailureBackoffConfigured(uint8 threshold, uint32 backoffSeconds); - event RealizeForQueueFailed(uint256 target, bytes reason); event StrategyRebalanceCooldownSet(uint64 cooldown); // Failure backoff state @@ -100,6 +100,21 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { // RECONCILE threshold uint256 public reconcileHighThreshold = 20; + // EPOCH_FUND stall backoff: fundEpoch() can legitimately no-op forever if + // liquidity is short (deficit remains after its own warm-refill/strategy- + // redeem waterfall). Without this, checkUpkeep's unconditional EPOCH_FUND + // priority starves CRYSTALLIZE/REBALANCE/DEPLOY/REALIZE/RECONCILE every + // single cycle -- including the DEPLOY/REALIZE ops that could actually + // free up the liquidity fundEpoch needs. Track whether the last attempt + // on the current target epoch made progress; once stalled past the + // threshold, yield priority for a cooldown window so other ops can run. + uint256 public lastEpochFundTargetId = type(uint256).max; + uint16 public epochFundStallCount; + uint64 public lastEpochFundStallTs; + uint16 public epochFundStallThreshold = 1; + uint32 public epochFundStallBackoffSeconds = 900; // 15min + event EpochFundStallBackoffConfigured(uint16 threshold, uint32 backoffSeconds); + // DEPLOY/REALIZE fairness state uint64 public lastDeployTs; uint64 public lastRealizeTs; @@ -127,8 +142,6 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { address bufferManager_, address router_, address globalConfig_, - uint256 defaultMaxClaims, - uint256 hardMaxClaims, uint256 defaultMaxRealize, uint256 defaultMaxDeploy, uint16 minRealizeGapBps_, @@ -141,17 +154,14 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { bufferManager = IBufferManager(bufferManager_); router = IStrategyRouterReader(router_); globalConfig = IGlobalConfigReader(globalConfig_); - DEFAULT_MAX_CLAIMS = (defaultMaxClaims == 0) ? 15 : defaultMaxClaims; // max 15 — settle uses cached valuation, safe under Chainlink 5M - HARD_MAX_CLAIMS = (hardMaxClaims == 0) ? 100 : hardMaxClaims; DEFAULT_MAX_REALIZE = (defaultMaxRealize == 0) ? type(uint256).max : defaultMaxRealize; DEFAULT_MAX_DEPLOY = (defaultMaxDeploy == 0) ? type(uint256).max : defaultMaxDeploy; minRealizeGapBps = minRealizeGapBps_; minRealizeFloor = minRealizeFloor_; - require(DEFAULT_MAX_CLAIMS <= HARD_MAX_CLAIMS, "bad-claims-bounds"); } // ═══════════════════════════════════════════════════════════════════════════════ - // checkUpkeep — deterministic priority: SETTLE > CRYSTALLIZE > REBALANCE > DEPLOY > REALIZE + // checkUpkeep — deterministic priority: EPOCH_FUND > EPOCH_CLOSE > CRYSTALLIZE > REBALANCE > DEPLOY > REALIZE // ═══════════════════════════════════════════════════════════════════════════════ function checkUpkeep(bytes calldata) @@ -181,53 +191,43 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { } } - // Priority 1: SETTLE or REALIZE_FOR_QUEUE (queue-driven, anti-churn) + // Priority 1: EPOCH_FUND — unlock a backlogged closed-but-unfunded epoch + // before opening/closing new ones. fundEpoch() runs the full warm-refill + // → strategy-redeem liquidity waterfall internally, so no separate + // deficit/realize probe is needed here (unlike the old SETTLE path). + // EXCEPT: if this exact epoch already stalled (funded() ran and made no + // progress) past the threshold, yield priority for a cooldown window so + // CRYSTALLIZE/REBALANCE/DEPLOY/REALIZE/RECONCILE get a chance to run — + // one persistently-underfunded epoch must not livelock the whole keeper. { - bool canSettle; - try core.canSettle() returns (bool ok) { canSettle = ok; } catch {} - if (canSettle) { - // Anti-churn: check if settle would actually process anything - uint256 eligibleCount; - try core.settlePreview(DEFAULT_MAX_CLAIMS) returns ( - uint256 ec, uint256, uint256, bool - ) { - eligibleCount = ec; - } catch {} - - if (eligibleCount == 0) { - // No eligible claims (all in lockPeriod or empty). - // Do NOT settle — avoid LINK burn on no-op. - // Claims will mature naturally; retry at next cycle. - // Fall through to CRYSTALLIZE/REBALANCE/DEPLOY/REALIZE. - } else { - // Eligible claims exist — check liquidity - uint256 deficit; - try core.deficitForQueue(DEFAULT_MAX_CLAIMS) returns (uint256 d) { - deficit = d; - } catch {} - - if (deficit == 0) { - return (true, abi.encode(Op.SETTLE, DEFAULT_MAX_CLAIMS)); - } else { - return (true, abi.encode(Op.REALIZE_FOR_QUEUE, deficit)); - } + uint256 oldestUnfunded; + uint256 curEpoch; + try core.oldestUnfundedEpochId() returns (uint256 id) { oldestUnfunded = id; } catch {} + try core.currentEpochId() returns (uint256 id) { curEpoch = id; } catch {} + if (oldestUnfunded < curEpoch) { + bool stalled = oldestUnfunded == lastEpochFundTargetId + && epochFundStallCount >= epochFundStallThreshold + && block.timestamp < uint256(lastEpochFundStallTs) + uint256(epochFundStallBackoffSeconds); + if (!stalled) { + return (true, abi.encode(Op.EPOCH_FUND, oldestUnfunded)); } + // Stalled and within backoff — fall through to lower priorities. } } - // Priority 1b: COMPACT (threshold-based — if queue dirty ratio > 30%) + // Priority 1b: EPOCH_CLOSE — close the current epoch once its minimum + // duration has elapsed, but only if it actually has claims in it + // (anti-churn: don't burn LINK closing an empty epoch). { - uint256 qLen; - uint256 pendingS; - try core.queueLength() returns (uint256 l) { qLen = l; } catch {} - try core.pendingShares() returns (uint256 p) { pendingS = p; } catch {} - // queueLength = entries from head to end (includes settled in middle) - // If queueLength is much larger than expected from pendingShares, - // the queue has many settled entries that waste gas on iteration. - // Heuristic: if queueLength > 2 * batch size AND pendingShares < queueLength * 50% - if (qLen > DEFAULT_MAX_CLAIMS * 2) { - // Dirty queue — compact needed - return (true, abi.encode(Op.COMPACT, uint256(0))); + bool canClose; + try core.canCloseCurrentEpoch() returns (bool ok) { canClose = ok; } catch {} + if (canClose) { + uint256 claimCount; + try core.currentEpochClaimCount() returns (uint256 c) { claimCount = c; } catch {} + if (claimCount > 0) { + return (true, abi.encode(Op.EPOCH_CLOSE, uint256(0))); + } + // Empty epoch — nothing to close for. Fall through. } } @@ -330,20 +330,57 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { (Op op, uint256 arg) = _decode(performData); - if (op == Op.SETTLE) { - // v8: ONLY settleFeesAndProcessQueue — no pre-settle rebalance/realize - // The settle path has full waterfall: hot → warm refill → strategy redeem - uint256 maxClaims = arg; - if (maxClaims == 0 || maxClaims > HARD_MAX_CLAIMS) { - maxClaims = DEFAULT_MAX_CLAIMS; - } + if (op == Op.EPOCH_CLOSE) { + bool success; + try core.closeCurrentEpoch() { + success = true; + } catch {} + emit UpkeepPerformed(Op.EPOCH_CLOSE, 0, success); + if (success) { failureCountByOp[Op.EPOCH_CLOSE] = 0; _recordSuccess(); } + else { failureCountByOp[Op.EPOCH_CLOSE]++; _recordRealFailure(); } + return; + } + + if (op == Op.EPOCH_FUND) { + // fundEpoch() runs its own warm-refill -> strategy-redeem waterfall + // internally; a partial fund (deficit remains) is not a failure — + // it's retried next cycle via oldestUnfundedEpochId(), which won't + // have advanced past this epoch yet. bool success; - try core.settleFeesAndProcessQueue(maxClaims) { + try core.fundEpoch(arg) { success = true; } catch {} - emit UpkeepPerformed(Op.SETTLE, maxClaims, success); - if (success) { failureCountByOp[Op.SETTLE] = 0; _recordSuccess(); } - else { failureCountByOp[Op.SETTLE]++; _recordRealFailure(); } + emit UpkeepPerformed(Op.EPOCH_FUND, arg, success); + + // Stall accounting runs on BOTH outcomes. A revert is a stall, not + // a non-event: if it only ran on success, a target that reverts + // every time (fundEpoch on an epoch that is already Funded, say) + // would leave the counters untouched forever, checkUpkeep's + // stalled-check could never become true, and EPOCH_FUND would keep + // its unconditional priority — the exact livelock the backoff + // exists to break. + uint256 stillOldest = arg; + try core.oldestUnfundedEpochId() returns (uint256 id) { stillOldest = id; } catch {} + if (stillOldest == arg) { + if (lastEpochFundTargetId == arg) { + epochFundStallCount++; + } else { + lastEpochFundTargetId = arg; + epochFundStallCount = 1; + } + lastEpochFundStallTs = uint64(block.timestamp); + } else { + lastEpochFundTargetId = type(uint256).max; + epochFundStallCount = 0; + } + + if (success) { + failureCountByOp[Op.EPOCH_FUND] = 0; + _recordSuccess(); + } else { + failureCountByOp[Op.EPOCH_FUND]++; + _recordRealFailure(); + } return; } @@ -409,38 +446,6 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { return; } - if (op == Op.COMPACT) { - bool success; - try core.compactQueue() { - success = true; - } catch {} - emit UpkeepPerformed(Op.COMPACT, 0, success); - if (success) { - _recordSuccess(); - } else { - _recordRealFailure(); - } - return; - } - - if (op == Op.REALIZE_FOR_QUEUE) { - bool success; - try core.realizeForQueue(arg) { - success = true; - } catch (bytes memory reason) { - emit RealizeForQueueFailed(arg, reason); - } - emit UpkeepPerformed(Op.REALIZE_FOR_QUEUE, arg, success); - if (success) { - lastRealizeTs = uint64(block.timestamp); - failureCountByOp[Op.REALIZE_FOR_QUEUE] = 0; - _recordSuccess(); - } else { - failureCountByOp[Op.REALIZE_FOR_QUEUE]++; - _recordRealFailure(); - } - return; - } if (op == Op.RECONCILE) { bool success; @@ -503,6 +508,14 @@ contract VaultUpkeep is AutomationCompatibleInterface, Ownable { emit FailureBackoffConfigured(threshold, backoffSeconds); } + function setEpochFundStallBackoff(uint16 threshold, uint32 backoffSeconds) external onlyOwner { + require(threshold >= 1 && threshold <= 10, "bad threshold"); + require(backoffSeconds >= 60 && backoffSeconds <= 86400, "bad backoff"); + epochFundStallThreshold = threshold; + epochFundStallBackoffSeconds = backoffSeconds; + emit EpochFundStallBackoffConfigured(threshold, backoffSeconds); + } + event DeployRealizeCooldownSet(uint64 cooldown); function setDeployRealizeCooldown(uint64 cd) external onlyOwner { require(cd >= 60 && cd <= 86400, "range"); diff --git a/src/core/CoreVault.sol b/src/core/CoreVault.sol index 9b8620d..b7882f8 100644 --- a/src/core/CoreVault.sol +++ b/src/core/CoreVault.sol @@ -15,7 +15,6 @@ import { IIncentives } from "../interfaces/IIncentives.sol"; import { ICoreVault } from "../interfaces/ICoreVault.sol"; import { CoreStorage } from "./storage/CoreStorage.sol"; import { FeeStorage } from "./storage/FeeStorage.sol"; -import { QueueStorage } from "./storage/QueueStorage.sol"; import { Events } from "./libraries/Events.sol"; import { Percentage } from "../libs/Percentage.sol"; import { SelectorRegistry } from "./libraries/SelectorRegistry.sol"; @@ -701,9 +700,31 @@ contract CoreVault is ERC4626, ICoreVault { // ICoreVault VIEW INTERFACE // ═══════════════════════════════════════════════════════════════════════════════ + /// @notice True if there is epoch-queue work a keeper should perform: + /// either the open epoch is closeable and non-empty, or a prior + /// epoch closed but hasn't been funded yet. + /// @dev Reads EpochedQueueModule's delegatecall-dispatched views via + /// staticcall-to-self (this function lives in CoreVault's own + /// bytecode, not the module, so it cannot call them directly). function canSettle() external view returns (bool) { - QueueStorage.Layout storage q = QueueStorage.layout(); - return q.queue.length > q.head; + (bool okClose, bytes memory dataClose) = + address(this).staticcall(abi.encodeWithSignature("canCloseCurrentEpoch()")); + if (okClose && dataClose.length == 32 && abi.decode(dataClose, (bool))) { + (bool okCnt, bytes memory dataCnt) = + address(this).staticcall(abi.encodeWithSignature("currentEpochClaimCount()")); + if (okCnt && dataCnt.length == 32 && abi.decode(dataCnt, (uint256)) > 0) { + return true; + } + } + + (bool okOldest, bytes memory dataOldest) = + address(this).staticcall(abi.encodeWithSignature("oldestUnfundedEpochId()")); + (bool okCur, bytes memory dataCur) = + address(this).staticcall(abi.encodeWithSignature("currentEpochId()")); + if (okOldest && okCur && dataOldest.length == 32 && dataCur.length == 32) { + return abi.decode(dataOldest, (uint256)) < abi.decode(dataCur, (uint256)); + } + return false; } function canCrystallize() external view returns (bool) { @@ -762,37 +783,6 @@ contract CoreVault is ERC4626, ICoreVault { return (true, target - currentCash); } - /// @notice Returns strategy redeem deficit for pending queue claims. - /// @dev Single source of truth for VaultUpkeep scheduler. - /// Returns 0 if hot + warm (discounted by slippage) cover the batch. - /// Returns the USDC shortfall that must come from strategy redeem. - function deficitForQueue(uint256 maxClaims) external view returns (uint256 deficit) { - (bool ok, bytes memory data) = address(this).staticcall( - abi.encodeWithSignature("requiredHotForBatch(uint256)", maxClaims) - ); - if (!ok) return 0; - uint256 required = abi.decode(data, (uint256)); - if (required == 0) return 0; - - uint256 hot = IERC20(asset()).balanceOf(address(this)); - if (hot >= required) return 0; - - // Discount warm by slippage (conservative estimate) - CoreStorage.Layout storage core = CoreStorage.layout(); - IBufferManager bm = core.bufferManager; - if (address(bm) != address(0)) { - (uint256 warmNav,, bool valid) = bm.warmNavState(); - if (valid && warmNav > 0) { - uint16 slipBps = bm.getConfig().maxWarmSlippageBps; - uint256 usableWarm = warmNav * (10000 - uint256(slipBps)) / 10000; - uint256 available = hot + usableWarm; - if (available >= required) return 0; - return required - available; - } - } - return required - hot; - } - // ═══════════════════════════════════════════════════════════════════════════════ // VIEW HELPERS // ═══════════════════════════════════════════════════════════════════════════════ @@ -821,11 +811,29 @@ contract CoreVault is ERC4626, ICoreVault { // WARM ADAPTER APPROVALS // ═══════════════════════════════════════════════════════════════════════════════ - function approveWarmAdapters(address[] calldata adapters) external onlyOwner { + /// @notice Grant each warm adapter a BOUNDED allowance to pull the vault's + /// underlying. Warm adapters pull with transferFrom rather than + /// receiving a push, so they need standing allowance; an unbounded + /// one meant a single compromised or buggy adapter could drain the + /// vault outright, and reservedForClaims cannot defend against that + /// because it is enforced when sizing a deploy, not when the token + /// moves. + /// @dev The allowance depletes as it is spent and is not self-renewing: + /// once an adapter has pulled `cap` in total, warm deploys through it + /// stop until governance tops it up. That is the intended trade -- a + /// visible, deliberate budget per adapter instead of an open tap. Size + /// it against the vault's deposit cap and expected warm cycling, not + /// against a single deploy. + /// + /// Pairs with the adapter-list guard on the BufferManager side: this + /// bounds what each adapter can take, that bounds who can become an + /// adapter. Neither alone is sufficient. + function approveWarmAdapters(address[] calldata adapters, uint256 cap) external onlyOwner { + if (cap == 0) revert ZeroAmount(); address assetAddr = asset(); for (uint256 i; i < adapters.length;) { - IERC20(assetAddr).forceApprove(adapters[i], type(uint256).max); - emit Events.WarmAdapterApproved(adapters[i]); + IERC20(assetAddr).forceApprove(adapters[i], cap); + emit Events.WarmAdapterApproved(adapters[i], cap); unchecked { ++i; } } } diff --git a/src/core/libraries/Events.sol b/src/core/libraries/Events.sol index 6b6c6cd..7bf8a5a 100644 --- a/src/core/libraries/Events.sol +++ b/src/core/libraries/Events.sol @@ -34,7 +34,12 @@ library Events { event RoutedToStrategy(address indexed strategy, uint256 amount, uint256 cashAfter); event Realized(uint256 amount); event ReserveTargetRestored(uint256 cashAfter); - event EpochRolled(uint256 newEpochStart); + /// @notice The WITHDRAWAL CAP epoch rolled over. Unrelated to the + /// settlement queue's epochs -- those emit EpochOpened/EpochClosed/ + /// EpochFunded from EpochedQueueModule. Named explicitly because the + /// previous name, EpochRolled, invited indexers to merge the two + /// concepts on name alone. + event WithdrawalCapEpochRolled(uint256 newEpochStart); // --- Param timelock (audit hardening) --- event FeeParamsSubmitted( @@ -194,7 +199,7 @@ library Events { event RouterRevoked(); // --- Warm adapter approval events --- - event WarmAdapterApproved(address indexed adapter); + event WarmAdapterApproved(address indexed adapter, uint256 cap); event WarmAdapterRevoked(address indexed adapter); // --- Selector Registry & System Seal events --- diff --git a/src/core/libraries/ExitEngineLib.sol b/src/core/libraries/ExitEngineLib.sol index f43551e..b628aca 100644 --- a/src/core/libraries/ExitEngineLib.sol +++ b/src/core/libraries/ExitEngineLib.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.28; import { CoreStorage } from "../storage/CoreStorage.sol"; -import { QueueStorage } from "../storage/QueueStorage.sol"; import { FeeStorage } from "../storage/FeeStorage.sol"; import { ExitFeeLib } from "./ExitFeeLib.sol"; import { WithdrawalCapLib } from "./WithdrawalCapLib.sol"; @@ -95,47 +94,6 @@ library ExitEngineLib { // CAP CALCULATION // ═══════════════════════════════════════════════════════════════════════════════ - /// @notice Calculate remaining immediate withdrawal capacity for current epoch - /// @dev Uses LIVE totalAssets (no snapshot) per CTO directive. - /// Incorporates dynamic cap from WithdrawalCapLib if enabled. - /// @param core CoreStorage layout - /// @param q QueueStorage layout - /// @param totalAssets Current live totalAssets() - /// @param vault Address of the vault (for ParamsProvider calls) - /// @return remaining Remaining capacity in asset units - function calculateCapRemaining( - CoreStorage.Layout storage core, - QueueStorage.Layout storage q, - uint256 totalAssets, - address vault - ) internal view returns (uint256 remaining) { - IParamsProvider.WithdrawalParams memory wp = - core.params.getWithdrawalParams(vault); - IParamsProvider.DynamicCapParams memory dcp = - core.params.getDynamicCapParams(vault); - - uint16 cap; - if (dcp.enabled) { - if (dcp.minBps == 0 || dcp.maxBps == 0) { - cap = wp.capPerEpochBps; - } else { - uint256 queueLen = - q.queue.length > q.head ? q.queue.length - q.head : 0; - cap = WithdrawalCapLib.calculateDynamicCapBps( - dcp.minBps, dcp.maxBps, dcp.queueStressThreshold, queueLen - ); - } - } else { - cap = wp.capPerEpochBps == 0 ? type(uint16).max : wp.capPerEpochBps; - } - - if (cap == type(uint16).max) return type(uint256).max; - - return WithdrawalCapLib.calculateCapRemaining( - totalAssets, cap, core.epochWithdrawn - ); - } - // ═══════════════════════════════════════════════════════════════════════════════ // FEE COMPUTATION // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/core/libraries/SelectorLib.sol b/src/core/libraries/SelectorLib.sol index 8e036d5..ee2934f 100644 --- a/src/core/libraries/SelectorLib.sol +++ b/src/core/libraries/SelectorLib.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import { QueueModule } from "../modules/QueueModule.sol"; +import { EpochedQueueModule } from "../modules/EpochedQueueModule.sol"; import { AdminModule } from "../modules/AdminModule.sol"; import { LiquidityOpsModule } from "../modules/LiquidityOpsModule.sol"; import { FixedMaturityModule } from "../modules/FixedMaturityModule.sol"; @@ -22,8 +22,9 @@ library SelectorLib { // ═══════════════════════════════════════════════════════════════════════════════ // SELECTOR COUNTS (for validation) // ═══════════════════════════════════════════════════════════════════════════════ - uint256 internal constant QUEUE_MODULE_SELECTORS = 6; // +1: compactQueue - uint256 internal constant QUEUE_MODULE_VIEW_SELECTORS = 5; // +1: requiredHotForBatch, +1: settlePreview + // "Queue module" = EpochedQueueModule (the sole queue-settlement mechanism). + uint256 internal constant QUEUE_MODULE_SELECTORS = 9; // +1: syncOldestUnfundedEpoch + uint256 internal constant QUEUE_MODULE_VIEW_SELECTORS = 12; // +1: reservedForClaims, +1: closedPendingAssets uint256 internal constant ADMIN_MODULE_OWNER_SELECTORS = 35; // +1: setRewardsTreasury uint256 internal constant ADMIN_MODULE_VIEW_SELECTORS = 15; // +1: getForceExitPenalty, +1: isPerfInitialized uint256 internal constant ERC4626_MODULE_SELECTORS = 11; // +1: forceWithdraw, +1: forceWithdrawAll @@ -39,12 +40,15 @@ library SelectorLib { // ═══════════════════════════════════════════════════════════════════════════════ function getQueueModuleSelectors() internal pure returns (bytes4[] memory selectors) { selectors = new bytes4[](QUEUE_MODULE_SELECTORS); - selectors[0] = QueueModule.requestClaim.selector; - selectors[1] = QueueModule.cancelClaim.selector; - selectors[2] = QueueModule.processQueuedRedemptions.selector; - selectors[3] = QueueModule.settleFeesAndProcessQueue.selector; - selectors[4] = QueueModule.endEpochCrystallize.selector; - selectors[5] = QueueModule.compactQueue.selector; + selectors[0] = EpochedQueueModule.requestEpochWithdrawal.selector; + selectors[1] = EpochedQueueModule.cancelEpochWithdrawal.selector; + selectors[2] = EpochedQueueModule.closeCurrentEpoch.selector; + selectors[3] = EpochedQueueModule.fundEpoch.selector; + selectors[4] = EpochedQueueModule.claimEpochAssets.selector; + selectors[5] = EpochedQueueModule.batchClaimEpochAssets.selector; + selectors[6] = EpochedQueueModule.requestInstantWithdrawal.selector; + selectors[7] = EpochedQueueModule.endEpochCrystallize.selector; + selectors[8] = EpochedQueueModule.syncOldestUnfundedEpoch.selector; } // ═══════════════════════════════════════════════════════════════════════════════ @@ -52,11 +56,18 @@ library SelectorLib { // ═══════════════════════════════════════════════════════════════════════════════ function getQueueModuleViewSelectors() internal pure returns (bytes4[] memory selectors) { selectors = new bytes4[](QUEUE_MODULE_VIEW_SELECTORS); - selectors[0] = QueueModule.nextClaimId.selector; - selectors[1] = QueueModule.queueLength.selector; - selectors[2] = QueueModule.pendingShares.selector; - selectors[3] = QueueModule.requiredHotForBatch.selector; - selectors[4] = QueueModule.settlePreview.selector; + selectors[0] = EpochedQueueModule.currentEpochId.selector; + selectors[1] = EpochedQueueModule.epochData.selector; + selectors[2] = EpochedQueueModule.epochClaim.selector; + selectors[3] = EpochedQueueModule.nextClaimIdForEpoch.selector; + selectors[4] = EpochedQueueModule.totalEscrowedShares.selector; + selectors[5] = EpochedQueueModule.outstandingClaimCount.selector; + selectors[6] = EpochedQueueModule.oldestUnfundedEpochId.selector; + selectors[7] = EpochedQueueModule.canCloseCurrentEpoch.selector; + selectors[8] = EpochedQueueModule.currentEpochClaimCount.selector; + selectors[9] = EpochedQueueModule.epochDeficit.selector; + selectors[10] = EpochedQueueModule.reservedForClaims.selector; + selectors[11] = EpochedQueueModule.closedPendingAssets.selector; } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/core/libraries/SelectorRegistry.sol b/src/core/libraries/SelectorRegistry.sol index 147ed2b..de1d279 100644 --- a/src/core/libraries/SelectorRegistry.sol +++ b/src/core/libraries/SelectorRegistry.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.28; import { AdminModule } from "../modules/AdminModule.sol"; -import { QueueModule } from "../modules/QueueModule.sol"; +import { EpochedQueueModule } from "../modules/EpochedQueueModule.sol"; import { LiquidityOpsModule } from "../modules/LiquidityOpsModule.sol"; import { FixedMaturityModule } from "../modules/FixedMaturityModule.sol"; @@ -129,21 +129,33 @@ contract SelectorRegistry { if (selector == AdminModule.isPerfInitialized.selector) return ROLE_PUBLIC; // ───────────────────────────────────────────────────────────────────────── - // QUEUEMODULE WRITE SELECTORS (5 total) - MUST BE ROLE_PUBLIC + // EPOCHEDQUEUEMODULE WRITE SELECTORS (9 total) - MUST BE ROLE_PUBLIC // ───────────────────────────────────────────────────────────────────────── - if (selector == QueueModule.requestClaim.selector) return ROLE_PUBLIC; - if (selector == QueueModule.cancelClaim.selector) return ROLE_PUBLIC; - if (selector == QueueModule.processQueuedRedemptions.selector) return ROLE_PUBLIC; - if (selector == QueueModule.settleFeesAndProcessQueue.selector) return ROLE_PUBLIC; - if (selector == QueueModule.endEpochCrystallize.selector) return ROLE_PUBLIC; - if (selector == QueueModule.compactQueue.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.requestEpochWithdrawal.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.cancelEpochWithdrawal.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.closeCurrentEpoch.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.fundEpoch.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.claimEpochAssets.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.batchClaimEpochAssets.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.requestInstantWithdrawal.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.endEpochCrystallize.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.syncOldestUnfundedEpoch.selector) return ROLE_PUBLIC; // ───────────────────────────────────────────────────────────────────────── - // QUEUEMODULE VIEW SELECTORS (3 total) - MUST BE ROLE_PUBLIC + // EPOCHEDQUEUEMODULE VIEW SELECTORS (10 total) - MUST BE ROLE_PUBLIC // ───────────────────────────────────────────────────────────────────────── - if (selector == QueueModule.nextClaimId.selector) return ROLE_PUBLIC; - if (selector == QueueModule.queueLength.selector) return ROLE_PUBLIC; - if (selector == QueueModule.pendingShares.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.currentEpochId.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.epochData.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.epochClaim.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.nextClaimIdForEpoch.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.totalEscrowedShares.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.outstandingClaimCount.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.oldestUnfundedEpochId.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.canCloseCurrentEpoch.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.currentEpochClaimCount.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.epochDeficit.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.reservedForClaims.selector) return ROLE_PUBLIC; + if (selector == EpochedQueueModule.closedPendingAssets.selector) return ROLE_PUBLIC; // ───────────────────────────────────────────────────────────────────────── // ERC4626MODULE SELECTORS (10 total) - MUST BE ROLE_PUBLIC @@ -182,10 +194,6 @@ contract SelectorRegistry { if (selector == LiquidityOpsModule.canRebalanceStrategies.selector) return ROLE_PUBLIC; if (selector == LiquidityOpsModule.rebalanceStrategies.selector) return ROLE_PUBLIC; - // Queue module views - if (selector == QueueModule.requiredHotForBatch.selector) return ROLE_PUBLIC; - if (selector == QueueModule.settlePreview.selector) return ROLE_PUBLIC; - // ───────────────────────────────────────────────────────────────────────── // FIXEDMATURITYMODULE GOVERNANCE SELECTORS - MUST BE ROLE_OWNER // ───────────────────────────────────────────────────────────────────────── diff --git a/src/core/mixins/SkimMixin.sol b/src/core/mixins/SkimMixin.sol deleted file mode 100644 index 71e3746..0000000 --- a/src/core/mixins/SkimMixin.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/** - * @title SkimMixin - * @notice Recover non-underlying tokens accidentally sent to the vault. - * @dev Access control is delegated to the final contract via _assertSkimRole(). - */ -abstract contract SkimMixin { - error SkimDenied(); - error NoBalance(); - error TransferFailed(); - - event Skimmed(address indexed token, address indexed to, uint256 amount); - - function _assertSkimRole() internal view virtual; - - function _canSkim(address token) internal view virtual returns (bool) { - // default allow; overridden in final contract to restrict - return true; - } - - function skim(address token, address to) external { - _assertSkimRole(); - if (!_canSkim(token)) revert SkimDenied(); - uint256 bal = IERC20(token).balanceOf(address(this)); - if (bal == 0) revert NoBalance(); - if (!IERC20(token).transfer(to, bal)) revert TransferFailed(); - emit Skimmed(token, to, bal); - } -} diff --git a/src/core/modules/BufferManager.sol b/src/core/modules/BufferManager.sol index a25f04e..cd9e532 100644 --- a/src/core/modules/BufferManager.sol +++ b/src/core/modules/BufferManager.sol @@ -291,6 +291,17 @@ contract BufferManager is IBufferManager, ReentrancyGuard { return (nav * _cfg.targetHotBps) / 1e4; } + /// @dev Assets the vault has already promised to FUNDED-but-unclaimed epoch + /// claimants. Read through the vault's selector routing rather than a + /// typed interface so a core deployed without the epoch queue wired + /// (or an older core) degrades to "nothing reserved" instead of + /// bricking every buffer operation. + function _reservedForClaims() internal view returns (uint256 reserved) { + (bool ok, bytes memory data) = + core.staticcall(abi.encodeWithSignature("reservedForClaims()")); + if (ok && data.length == 32) reserved = abi.decode(data, (uint256)); + } + function plan() public view override returns (uint256 needRefill, uint256 needDeploy) { BufferConfig memory cfg = _cfg; // Cache storage to save ~2 SLOADs @@ -299,6 +310,14 @@ contract BufferManager is IBufferManager, ReentrancyGuard { (uint256 nav, uint256 hot, uint256 warm) = ICoreVault(core).totalAssetsBreakdown(); if (nav == 0) return (0, 0); + // The buffer manages FREE liquidity only. Cash earmarked for + // FUNDED-but-unclaimed epoch claims is not the buffer's to move: the + // warm adapters pull it straight out of the vault under the standing + // allowance from CoreVault.approveWarmAdapters(), which is a transfer + // path no module-level reservation check can see. Netting it out here + // shrinks needDeploy and grows needRefill, both in the safe direction. + hot = hot > _reservedForClaims() ? hot - _reservedForClaims() : 0; + uint256 targetHotAmt = (nav * cfg.targetHotBps) / 1e4; uint256 minHot = (nav * cfg.minHotBps) / 1e4; diff --git a/src/core/modules/ERC4626Module.sol b/src/core/modules/ERC4626Module.sol index 1de5124..968a108 100644 --- a/src/core/modules/ERC4626Module.sol +++ b/src/core/modules/ERC4626Module.sol @@ -16,6 +16,7 @@ import { IIncentives } from "../../interfaces/IIncentives.sol"; import { IIncentivesEngine } from "../../interfaces/IIncentivesEngine.sol"; import { ICoreVault } from "../../interfaces/ICoreVault.sol"; import { ExitEngineLib } from "../libraries/ExitEngineLib.sol"; +import { EpochQueueStorage } from "./EpochedQueueModule.sol"; import { FixedMaturityStorage, VaultMode, VaultState, _checkDepositsAllowed, _checkForceExitAllowed @@ -31,7 +32,7 @@ interface IFixedMaturityAutoClose { /// @notice Handles ERC4626 user-facing operations via delegatecall from CoreVault. /// @dev v9 changes (ExitEngineLib refactor): /// - withdraw()/redeem() ALWAYS revert AsyncWithdrawalRequired -/// - Users must use QueueModule.requestClaim() for all exits +/// - Users must use EpochedQueueModule.requestInstantWithdrawal()/requestEpochWithdrawal() for all exits /// - forceWithdraw/forceWithdrawAll remain instant (fee via ExitEngineLib) /// - deposit/mint unchanged (O(1), no routing) /// @@ -61,6 +62,10 @@ contract ERC4626Module { error VaultDepositCapExceeded(uint256 totalAssetsAfter, uint256 cap); error UserDepositCapExceeded(uint256 userAssetsAfter, uint256 cap); error SlippageExceeded(); + /// @dev Raised when a force exit would spend hot cash already reserved for + /// FUNDED-but-unclaimed epoch claims. Replaces the bare ERC20 + /// "transfer amount exceeds balance" that this path used to hit. + error InsufficientFreeLiquidity(); error ReentrancyGuardLocked(); error NavStale(); error NavInvalid(); @@ -145,7 +150,7 @@ contract ERC4626Module { // WITHDRAWAL FUNCTIONS — ALWAYS REVERT (queued protocol) // ═══════════════════════════════════════════════════════════════════════════════ // ERC4626 compliance: maxWithdraw/maxRedeem return 0, so these MUST revert. - // Users MUST use QueueModule.requestClaim() for all exits. + // Users MUST use EpochedQueueModule.requestInstantWithdrawal()/requestEpochWithdrawal() for all exits. /// @notice DISABLED — use requestClaim() instead function withdraw(uint256, address, address) external pure returns (uint256) { @@ -231,6 +236,12 @@ contract ERC4626Module { // Source liquidity with user plan _sourceLiquidityForForceWithdraw(assetAddr, assets, plan, core); + // A force exit is a consumer of hot cash like any other: it may only + // spend what is NOT already reserved for FUNDED-but-unclaimed epoch + // claims. Checked after sourcing, so a pull that closes the gap still + // lets the exit through. + if (_freeLiquidity(assetAddr) < assets) revert InsufficientFreeLiquidity(); + // Transfer fee shares to feeCollector (NO mint — anti-dilution) if (totalFeeShares > 0) { _processorTransfer(owner_, core.feeCollector, totalFeeShares); @@ -328,7 +339,11 @@ contract ERC4626Module { // determines how much of the caller's shares are actually consumed. _forcePullAllLiquidity(assetAddr, targetAssets, core); - uint256 hot = IERC20(assetAddr).balanceOf(address(this)); + // Free liquidity, not raw hot: cash reserved for FUNDED-but-unclaimed + // epoch claims is off-limits here exactly as it is to instant exits and + // strategy deploys. A shortfall degrades into a partial fill via the + // proportional-burn logic below, so no new revert path is introduced. + uint256 hot = _freeLiquidity(assetAddr); assetsReceived = hot >= targetAssets ? targetAssets : hot; // F-03: hard floor on the fill. Proportional burn already makes a partial @@ -768,6 +783,16 @@ contract ERC4626Module { return IERC4626(address(this)).asset(); } + /// @dev Hot balance net of assets earmarked for FUNDED-but-unclaimed epoch + /// claims. EpochQueueStorage is EIP-7201 namespaced and every module + /// shares the vault's storage under delegatecall, so this reads the + /// same slot EpochedQueueModule writes. + function _freeLiquidity(address assetAddr) internal view returns (uint256) { + uint256 hot = IERC20(assetAddr).balanceOf(address(this)); + uint256 reserved = EpochQueueStorage.layout().reservedForClaims; + return hot > reserved ? hot - reserved : 0; + } + /// @dev Raw asset-to-share conversion WITHOUT deposit fee. /// Used internally where fee is already deducted from `assets`. /// The public previewDeposit() includes fee deduction for ERC4626 compliance. diff --git a/src/core/modules/EpochedQueueModule.sol b/src/core/modules/EpochedQueueModule.sol index 771f695..4103286 100644 --- a/src/core/modules/EpochedQueueModule.sol +++ b/src/core/modules/EpochedQueueModule.sol @@ -29,7 +29,7 @@ import { library EpochQueueStorage { // keccak256(abi.encode(uint256(keccak256("multyr.storage.EpochQueue.v1")) - 1)) & ~bytes32(uint256(0xff)) bytes32 internal constant SLOT = - 0x3e5f2b4af1c6d7890a2b1c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c400; + 0xd8f6996c75206120e7e007afb307a0ab5673f8e6af6fff1bc619c574ef0f3000; enum EpochState { Open, Closed, Funded } @@ -66,6 +66,56 @@ library EpochQueueStorage { mapping(uint256 => uint256) nextClaimId; // total shares sitting in vault escrow across ALL open epochs uint256 escrowedShares; + // Total unclaimed claims across ALL epochs (open + closed-unfunded + + // funded-unclaimed). Used as the dynamic-cap "queue depth" signal — + // unlike EpochData.claimCount (which resets to 0 every closeCurrentEpoch()), + // this persists across epoch boundaries so cap stress detection can't be + // dodged by waiting for the next epoch to open. Mirrors escrowedShares' + // "running total across all epochs" pattern, but counts claims (matching + // QueueModule/ExitEngineLib's queue-depth unit) rather than shares. + uint256 outstandingClaimCount; + // Oldest epoch that is CLOSED but not yet FUNDED — the epoch-model + // equivalent of QueueStorage.head. Lets a keeper find "what needs + // fundEpoch() next" in O(1) instead of scanning epoch IDs from 0. + // Lazily advanced in fundEpoch() past any now-consecutively-FUNDED + // epochs (funding can happen out of order, so this only advances when + // the just-funded epoch IS the current cursor position). + uint256 oldestUnfundedEpochId; + // Assets earmarked for FUNDED-but-unclaimed claims across ALL funded + // epochs. This is what makes FUNDED a real claim on assets instead of + // a snapshot: every consumer of "hot balance" for another purpose + // (instant exits, deployToStrategies, funding a LATER epoch) must + // treat `hot - reservedForClaims` as the only spendable balance. + // Incremented in fundEpoch() when an epoch is marked Funded, + // decremented in claimEpochAssets()/batchClaimEpochAssets() as each + // claim is actually paid out. + // + // NOT an exact round trip: the reservation is taken as + // mulWadDown(totalNetShares, ppsAtClose) while each release is + // mulWadDown(netShares_i, ppsAtClose), and per-claim truncation makes + // the releases sum to at most the reservation. A fully drained epoch + // therefore leaves under one asset-unit behind -- measured at 1 wei per + // multi-claim epoch, which for 6-decimal USDC is 1e-6. It is dust by + // construction and no claimant is ever short-paid by it, but it does + // mean this counter is monotonically non-zero once the first + // multi-claim epoch settles. NEVER write an invariant asserting + // reservedForClaims == 0; bound it against the epoch count instead. + uint256 reservedForClaims; + // Locked-pps liability for CLOSED-but-not-yet-FUNDED epochs. Unlike + // reservedForClaims this is not yet backed by any specific cash + // (fundEpoch() hasn't succeeded for it), but tracking it lets views + // (see CoreVaultLens.getVaultReport) value total pending withdrawals + // exactly, in O(1), without scanning epochs. + // + // Monotone per epoch by design: incremented at close, and moved into + // reservedForClaims when (and only when) that epoch funds. An epoch + // that closes and never funds keeps its share of this total forever, + // which is correct rather than a leak -- Closed is terminal until + // funding succeeds (claimEpochAssets requires Funded, and + // cancelEpochWithdrawal requires Open), so the liability really is + // still outstanding. Each epoch contributes to exactly one of the three + // buckets getVaultReport sums, so there is no double count. + uint256 closedPendingAssets; } function layout() internal pure returns (Layout storage l) { @@ -136,6 +186,7 @@ contract EpochedQueueModule { error ReentrancyGuardLocked(); error EpochAlreadyFunded(); error InsufficientEscrow(); + error ClaimTooSmall(); // ========================================================================= // EVENTS (epoch lifecycle + claims) @@ -169,6 +220,34 @@ contract EpochedQueueModule { uint256 hotAfter ); event EpochFunded(uint256 indexed epochId, uint256 totalNetAssets); + /// @notice fundEpoch() was called on an epoch that is already FUNDED, so + /// there was nothing to do beyond syncing the keeper cursor. + /// @dev Not emitted on the keeper's normal path: it 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. Seeing + /// this event means the cursor was stale (cursorAfter > cursorBefore, + /// now repaired) or the caller picked the wrong epoch (cursor + /// unchanged). + event EpochFundSkipped( + uint256 indexed epochId, + uint256 cursorBefore, + uint256 cursorAfter + ); + + /// @notice A fundEpoch() attempt left the epoch CLOSED. Emitted on every + /// failed or partial attempt so a stalled epoch is visible to + /// monitoring without waiting for a user complaint. + /// @param epochId the epoch that could not be funded + /// @param needed the epoch's own locked-pps liability + /// @param freeLiquidity hot balance net of other funded epochs' reservations + /// @param shortfall how much more is required before it can be funded + event EpochFundingShortfall( + uint256 indexed epochId, + uint256 needed, + uint256 freeLiquidity, + uint256 shortfall + ); event EpochAssetsClaimed( uint256 indexed epochId, uint256 indexed claimId, @@ -182,6 +261,12 @@ contract EpochedQueueModule { // ========================================================================= uint256 public constant MAX_WARM_NAV_AGE = 15 minutes; + /// @notice Upper bound on how far one call may advance the + /// oldestUnfundedEpochId cursor, so the scan can never blow the + /// block gas limit. Hitting the bound leaves the cursor lagging, + /// never stuck: see syncOldestUnfundedEpoch(). + uint256 public constant MAX_CURSOR_SCAN = 50; + // ========================================================================= // EPOCH QUEUE -- WRITE FUNCTIONS // ========================================================================= @@ -235,6 +320,17 @@ contract EpochedQueueModule { _trySoftRefreshWarmNav(); + // --- Anti-spam floor (ported from QueueModule.requestClaim) ---------- + // Unconditional, and here rather than at the entry points, because this + // is the single choke point where a claim enters the queue: no future + // entry point can create one without passing through it. The instant + // path checks the same thing up front as well, so its outcome depends on + // the caller's input rather than on vault state; the resulting double + // check on the fallback route is idempotent and costs one extra + // conversion on the rare branch. That is the intended trade -- there is + // deliberately no way to signal "already checked" and skip it. + _checkMinClaimAmount(core, shares); + // --- Fee computation (STANDARD mode, rounding UP for protocol) ------- (uint256 feeShares, uint256 netShares) = ExitEngineLib.computeFeeShares(shares, ExitEngineLib.ExitMode.STANDARD, f.fee); @@ -260,6 +356,7 @@ contract EpochedQueueModule { // --- Global escrow tracker ------------------------------------------ eq.escrowedShares += shares; + eq.outstandingClaimCount += 1; // --- Incentives sync (best-effort) ----------------------------------- // Skip the convertToAssets() call entirely when no engine is wired -- @@ -271,10 +368,26 @@ contract EpochedQueueModule { emit EpochWithdrawalRequested(epochId, claimId, user, shares, netShares, feeShares); } + /// @dev Anti-spam floor on exits, bounding dust-claim griefing against + /// outstandingClaimCount. Applies to every caller without exception: + /// an address-based carve-out inside a security check is a standing + /// invitation to widen it. Callers that cannot tolerate a revert -- + /// FeeCollector's AUTO_HARVEST is the one in-protocol case -- handle + /// the failure on their own side rather than being special-cased here. + function _checkMinClaimAmount(CoreStorage.Layout storage core, uint256 shares) + internal + view + { + uint256 floor_ = core.params.getWithdrawalParams(address(this)).minClaimAmount; + if (floor_ == 0) return; + if (_convertToAssets(shares) < floor_) revert ClaimTooSmall(); + } + /// @notice Cancel a claim while the epoch is still OPEN. /// Returns all gross shares (netShares + feeShares) to the user. /// Not allowed once the epoch has closed. function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external { + _enterNonReentrant(); EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); EpochQueueStorage.EpochData storage epoch = eq.epochs[epochId]; EpochQueueStorage.EpochClaim storage claim = eq.claims[epochId][claimId]; @@ -295,6 +408,7 @@ contract EpochedQueueModule { epoch.totalFeeShares -= claim.feeShares; epoch.claimCount -= 1; eq.escrowedShares -= grossShares; + eq.outstandingClaimCount -= 1; // Mark cancelled (reuse the claimed flag) claim.claimed = true; @@ -303,6 +417,8 @@ contract EpochedQueueModule { _transferShares(address(this), msg.sender, grossShares); emit EpochWithdrawalCancelled(epochId, claimId, msg.sender, grossShares); + + _exitNonReentrant(); } /// @notice Close the current epoch and lock the PPS snapshot. @@ -311,6 +427,10 @@ contract EpochedQueueModule { /// feeCollector in a single call (vs. per-claim in QueueModule). /// Opens a fresh epoch immediately so new submissions are not blocked. function closeCurrentEpoch() external { + // Guarded for the same reason as the rest: it refreshes warm NAV and + // batch-transfers fee shares out, both external calls, before writing + // the epoch's locked price. + _enterNonReentrant(); _checkSettlementAllowed(FixedMaturityStorage.layout()); EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); @@ -337,6 +457,10 @@ contract EpochedQueueModule { epoch.closedAt = uint64(block.timestamp); epoch.state = EpochQueueStorage.EpochState.Closed; + // Locked-pps liability, not yet backed by reserved cash (that only + // happens once fundEpoch() succeeds) -- see EpochQueueStorage.Layout. + eq.closedPendingAssets += epoch.totalNetAssets; + // --- Batch fee transfer (one safeTransfer vs n transfers in QueueModule) // Fee shares have been sitting in escrow since submission. // Transfer all of them to feeCollector atomically here. @@ -363,6 +487,8 @@ contract EpochedQueueModule { eq.epochs[nextId].openedAt = uint64(block.timestamp); eq.epochs[nextId].state = EpochQueueStorage.EpochState.Open; emit EpochOpened(nextId, uint64(block.timestamp)); + + _exitNonReentrant(); } /// @notice Pull liquidity deficit for a CLOSED epoch. @@ -374,19 +500,49 @@ contract EpochedQueueModule { /// Core improvement over QueueModule: a single external-call pull /// covers ALL claims in the epoch, not one pull per settled claim. function fundEpoch(uint256 epochId) external { + // Guarded like every other state-changing entry point on this module. + // This one 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 exactly the shape worth excluding. + _enterNonReentrant(); EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); EpochQueueStorage.EpochData storage epoch = eq.epochs[epochId]; if (epoch.state == EpochQueueStorage.EpochState.Open) revert EpochNotClosed(); - if (epoch.state == EpochQueueStorage.EpochState.Funded) revert EpochAlreadyFunded(); + // Already funded: no-op rather than revert, matching this function's + // documented "safe to call multiple times" contract. Reverting here + // made the cursor wedge unrecoverable -- oldestUnfundedEpochId can land + // on a Funded epoch when the bounded advance scan below stops early, + // and a keeper pointed at it would then revert on every single cycle + // with no way back. Syncing the cursor first makes the call self-heal. + if (epoch.state == EpochQueueStorage.EpochState.Funded) { + uint256 cursorBefore = eq.oldestUnfundedEpochId; + _syncOldestUnfunded(eq); + // Turning the old EpochAlreadyFunded revert into a no-op kept the + // keeper alive but cost the diagnostic: an integrator targeting the + // wrong epoch got silence. Emitting the cursor either side of the + // sync keeps the case observable and tells the two apart -- a moved + // cursor is the self-heal doing its job, an unmoved one is a caller + // that had nothing to do here. + emit EpochFundSkipped(epochId, cursorBefore, eq.oldestUnfundedEpochId); + _exitNonReentrant(); + return; + } address assetAddr = _asset(); uint256 hot = IERC20(assetAddr).balanceOf(address(this)); - emit EpochFundAttempt(epochId, epoch.totalNetAssets, hot, 0 /* filled below */); + // "needed" is this epoch's liability PLUS everything already reserved + // for other FUNDED-but-unclaimed epochs -- hot must cover both before + // this epoch can be marked Funded, otherwise funding it would just be + // spending cash another epoch's claimants already own. + uint256 needed = epoch.totalNetAssets + eq.reservedForClaims; + + uint256 hotBefore = hot; - if (hot < epoch.totalNetAssets) { - uint256 deficit = epoch.totalNetAssets - hot; + if (hot < needed) { + uint256 deficit = needed - hot; CoreStorage.Layout storage core = CoreStorage.layout(); // --- Step 1: try warm refill (cheaper than strategy redeem) ------ @@ -400,7 +556,7 @@ contract EpochedQueueModule { emit Events.QueueWarmRefillFailed(epochId, pullWarm, reason); } hot = IERC20(assetAddr).balanceOf(address(this)); - deficit = hot < epoch.totalNetAssets ? epoch.totalNetAssets - hot : 0; + deficit = hot < needed ? needed - hot : 0; } } @@ -419,16 +575,71 @@ contract EpochedQueueModule { } } - emit EpochFundAttempt(epochId, epoch.totalNetAssets, 0 /* filled above */, hot); + // One event per call, with both balances populated. It used to be + // emitted twice, once with hotAfter zeroed and once with hotBefore + // zeroed, so an indexer reading either line in isolation got a + // fabricated balance. + emit EpochFundAttempt(epochId, epoch.totalNetAssets, hotBefore, hot); - // Mark funded only when fully covered - if (hot >= epoch.totalNetAssets) { + // Mark funded only when fully covered (this epoch's liability AND + // everything already reserved for other funded epochs) + if (hot >= needed) { epoch.state = EpochQueueStorage.EpochState.Funded; epoch.fundedAt = uint64(block.timestamp); + eq.closedPendingAssets -= epoch.totalNetAssets; + eq.reservedForClaims += epoch.totalNetAssets; emit EpochFunded(epochId, epoch.totalNetAssets); + + // Advance the oldest-unfunded cursor past any now-consecutively- + // FUNDED epochs, but only if this WAS the cursor position — funding + // can happen out of order, so a later epoch being funded first must + // not move the cursor past an still-unfunded earlier one. + if (epochId == eq.oldestUnfundedEpochId) { + _syncOldestUnfunded(eq); + } + } else { + // Underfunded: the epoch stays CLOSED and fundEpoch() can be + // retried as liquidity arrives. This branch used to emit nothing at + // all, so a funding failure was invisible to monitoring -- the only + // symptom was a user reporting they had not been paid. Free + // liquidity is reported net of other funded epochs' reservations, + // which is the number that actually governs whether this epoch can + // ever be funded. + uint256 reserved = eq.reservedForClaims; + emit EpochFundingShortfall( + epochId, + epoch.totalNetAssets, + hot > reserved ? hot - reserved : 0, + needed - hot + ); + } + + _exitNonReentrant(); + } + + /// @notice Advance oldestUnfundedEpochId past any leading FUNDED epochs. + /// Permissionless and idempotent. + /// @dev The scan is bounded, so a long run of out-of-order-funded epochs + /// may leave the cursor short of the true oldest unfunded epoch. That + /// is a lag, not a wedge: every further call advances it another + /// bounded step, and fundEpoch() on an already-funded target syncs it + /// too instead of reverting. A cursor pointing at a FUNDED epoch is + /// therefore always recoverable without governance. + function syncOldestUnfundedEpoch() external { + _syncOldestUnfunded(EpochQueueStorage.layout()); + } + + function _syncOldestUnfunded(EpochQueueStorage.Layout storage eq) internal { + uint256 next = eq.oldestUnfundedEpochId; + uint256 scanned = 0; + while ( + next < eq.currentEpochId && + eq.epochs[next].state == EpochQueueStorage.EpochState.Funded && + scanned < MAX_CURSOR_SCAN + ) { + unchecked { ++next; ++scanned; } } - // If still underfunded the epoch remains CLOSED; - // fundEpoch() can be retried as more liquidity becomes available. + if (next != eq.oldestUnfundedEpochId) eq.oldestUnfundedEpochId = next; } /// @notice User self-claims their assets from a FUNDED epoch. @@ -460,6 +671,9 @@ contract EpochedQueueModule { // feeShares already left escrow (transferred to feeCollector) at close; // only netShares remain in escrow for this claim. eq.escrowedShares -= claim.netShares; + eq.outstandingClaimCount -= 1; + // This claim's assets are no longer reserved -- they've been paid. + eq.reservedForClaims -= assets; // Burn net shares from escrow _burn(address(this), claim.netShares); @@ -502,6 +716,9 @@ contract EpochedQueueModule { // feeShares already left escrow at close; only netShares remain. eq.escrowedShares -= claim.netShares; + eq.outstandingClaimCount -= 1; + // This claim's assets are no longer reserved -- they've been paid. + eq.reservedForClaims -= assets; _burn(address(this), claim.netShares); @@ -521,6 +738,140 @@ contract EpochedQueueModule { _exitNonReentrant(); } + // ========================================================================= + // PERFORMANCE FEE CRYSTALLIZATION + // ========================================================================= + // Ported verbatim from QueueModule.sol: crystallization is independent of + // which queue-settlement mechanism a vault uses (no QueueStorage/epoch + // dependency in this logic at all) — it was only ever colocated with + // QueueModule because that was the only queue module wired at the time. + + /// @notice End epoch and crystallize performance fee. Permissionless. + function endEpochCrystallize() external { + _crystallize(); + _updateNavSmooth(); + } + + function _pps() internal view returns (uint256) { + uint256 ts = _totalSupply(); + return ts == 0 ? FixedPoint.WAD : FixedPoint.divWadDown(_totalAssets(), ts); + } + + function _crystallize() internal returns (uint256 newHwm, uint256 feeAssets) { + FeeStorage.Layout storage f = FeeStorage.layout(); + CoreStorage.Layout storage core = CoreStorage.layout(); + + uint256 ts = _totalSupply(); + if (ts == 0) { + // Escape-hatch guard: only reset the fee baseline to WAD when the vault + // is genuinely empty (no residual/dust assets). If assets remain while + // supply is zero (e.g. dust left after a full redemption), keep the + // existing HWM -- otherwise a forced empty-then-refill cycle could wipe + // an already fee-eligible high-water mark while value still sits in the + // vault, letting fresh "profit" be recognised on value that was never + // actually new. + uint256 assetsNow = _totalAssets(); + if (assetsNow == 0) { + // Genuine fresh start: reset the baseline and record the event. + f.highWaterMark = FixedPoint.WAD; + f.lastCrystallize = uint64(block.timestamp); + emit Events.Crystallized(0, FixedPoint.WAD, 0); + return (FixedPoint.WAD, 0); + } + // Dust present: preserve the existing baseline. This is a no-op (no + // fee, no HWM change) so -- same reasoning as the drawdown branch + // below -- lastCrystallize is deliberately left untouched, and the + // storage slot is only written if it needs initialising. + uint256 preserved = f.highWaterMark == 0 ? FixedPoint.WAD : f.highWaterMark; + if (f.highWaterMark == 0) f.highWaterMark = preserved; + emit Events.Crystallized(0, preserved, 0); + return (preserved, 0); + } + + uint256 pps = _pps(); + uint256 old = f.highWaterMark == 0 ? FixedPoint.WAD : f.highWaterMark; + + if (pps <= old) { + // HWM is monotonically non-decreasing. Initialise the storage slot on + // the very first crystallise (highWaterMark == 0 means "use WAD as + // default" but the value is never persisted until here); otherwise + // this write would just re-store the same value, so it's skipped. + if (f.highWaterMark == 0) f.highWaterMark = old; + // Deliberately NOT touching lastCrystallize here: this call is a no-op + // (no profit crystallized). Since endEpochCrystallize() is ROLE_PUBLIC, + // bumping the timer on every no-op call would let anyone repeatedly + // push lastCrystallize forward for free, indefinitely delaying the next + // legitimate (profitable) crystallization. The interval clock should + // only advance on a real crystallization event. + emit Events.Crystallized(old, pps, 0); + return (old, 0); + } + + // Interval guard: block fee extraction within the minimum crystallise interval. + // Guard is skipped on the very first crystallise (highWaterMark == 0). + uint64 minInterval = f.minCrystallizeInterval; + if (f.highWaterMark != 0 && minInterval > 0 && + block.timestamp < uint256(f.lastCrystallize) + uint256(minInterval)) { + return (old, 0); + } + + uint256 total = _totalAssets(); + uint256 oldAssets = FixedPoint.mulWadDown(old, ts); + uint256 profit = total > oldAssets ? total - oldAssets : 0; + feeAssets = FixedPoint.mulWadDown(profit, f.perfRateX); + + if (feeAssets > 0) { + uint256 ppsBefore = pps; + uint256 feeShares = _previewDeposit(feeAssets); + if (feeShares > 0) { + _mint(core.feeCollector, feeShares); + emit Events.PerfFeeMinted(old, ppsBefore, feeShares, _pps()); + } + } + + newHwm = _pps(); + f.highWaterMark = newHwm; + f.lastCrystallize = uint64(block.timestamp); + emit Events.Crystallized(old, newHwm, feeAssets); + } + + function _updateNavSmooth() internal { + CoreStorage.Layout storage core = CoreStorage.layout(); + if (address(core.params) == address(0)) return; + + IParamsProvider.NavSmoothingParams memory nsp = + core.params.getNavSmoothingParams(address(this)); + if (!nsp.enabled) return; + + uint256 navReal = _totalAssets(); + bool initialized = + (core.packedFlags & CoreStorage.FLAG_NAV_SMOOTH_INIT) != 0; + + if (!initialized) { + core.navSmooth = navReal; + core.lastNavSmoothUpdate = uint64(block.timestamp); + core.packedFlags |= CoreStorage.FLAG_NAV_SMOOTH_INIT; + emit Events.NavSmoothUpdated(navReal, navReal, block.timestamp); + return; + } + + if ( + block.timestamp + < uint256(core.lastNavSmoothUpdate) + nsp.interval + ) { + return; + } + + uint256 alpha = nsp.alphaBps; + uint256 newSmooth = + (alpha * navReal + (10000 - alpha) * core.navSmooth) / 10000; + + core.navSmooth = newSmooth; + core.lastNavSmoothUpdate = uint64(block.timestamp); + + emit Events.NavSmoothUpdated(navReal, newSmooth, block.timestamp); + } + // ========================================================================= // INSTANT WITHDRAWAL (preserved from QueueModule, cap-gated) // ========================================================================= @@ -542,8 +893,14 @@ contract EpochedQueueModule { _trySoftRefreshWarmNav(); + // Anti-spam floor, enforced BEFORE the cap/liquidity branch so the + // outcome tracks the caller's input rather than vault state. Without + // this, the same sub-floor request settled while the cap had room and + // reverted once it was exhausted. + _checkMinClaimAmount(core, shares); + bool rolled = ExitEngineLib.rollEpochIfNeeded(core); - if (rolled) emit Events.EpochRolled(core.epochStart); + if (rolled) emit Events.WithdrawalCapEpochRolled(core.epochStart); IParamsProvider.WithdrawalParams memory wp = core.params.getWithdrawalParams(address(this)); uint256 gross = _convertToAssets(shares); @@ -595,6 +952,14 @@ contract EpochedQueueModule { return EpochQueueStorage.layout().currentEpochId; } + /// @notice Claim count of the currently open epoch — used by keepers as an + /// anti-churn check before closeCurrentEpoch() (skip closing an + /// epoch with nothing in it). + function currentEpochClaimCount() external view returns (uint256) { + EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); + return eq.epochs[eq.currentEpochId].claimCount; + } + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory) @@ -617,13 +982,44 @@ contract EpochedQueueModule { return EpochQueueStorage.layout().escrowedShares; } - /// @notice Returns the shortfall in hot assets to fund a specific epoch. - /// Returns 0 if the epoch is already funded or hot >= totalNetAssets. + /// @notice Total unclaimed claims across ALL epochs — the dynamic-cap + /// "queue depth" signal. See _epochCapRemaining(). + function outstandingClaimCount() external view returns (uint256) { + return EpochQueueStorage.layout().outstandingClaimCount; + } + + /// @notice Oldest epoch that is CLOSED but not yet FUNDED (the epoch-model + /// equivalent of QueueStorage.head) — what a keeper should call + /// fundEpoch() on next. Equal to currentEpochId() when there is no + /// funding backlog (nothing closed-and-unfunded exists yet). + function oldestUnfundedEpochId() external view returns (uint256) { + return EpochQueueStorage.layout().oldestUnfundedEpochId; + } + + /// @notice Returns the shortfall in hot assets to fund a specific epoch, + /// net of everything already reserved for other funded epochs -- + /// matches fundEpoch()'s actual "needed" requirement. + /// Returns 0 if the epoch is already funded or fully covered. function epochDeficit(uint256 epochId) external view returns (uint256) { - EpochQueueStorage.EpochData storage e = EpochQueueStorage.layout().epochs[epochId]; + EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); + EpochQueueStorage.EpochData storage e = eq.epochs[epochId]; if (e.state != EpochQueueStorage.EpochState.Closed) return 0; uint256 hot = IERC20(_asset()).balanceOf(address(this)); - return hot < e.totalNetAssets ? e.totalNetAssets - hot : 0; + uint256 needed = e.totalNetAssets + eq.reservedForClaims; + return hot < needed ? needed - hot : 0; + } + + /// @notice Assets earmarked for FUNDED-but-unclaimed claims across ALL + /// funded epochs. This much of the hot balance is off-limits to + /// instant exits, strategy deploys, and funding any other epoch. + function reservedForClaims() external view returns (uint256) { + return EpochQueueStorage.layout().reservedForClaims; + } + + /// @notice Locked-pps liability for CLOSED-but-not-yet-FUNDED epochs. + /// Not yet backed by reserved cash (see reservedForClaims()). + function closedPendingAssets() external view returns (uint256) { + return EpochQueueStorage.layout().closedPendingAssets; } /// @notice Returns true when the current open epoch can be closed. @@ -683,6 +1079,15 @@ contract EpochedQueueModule { ICoreVault(address(this)).processorBurn(from, amount); } + /// @dev Raw asset-to-share conversion WITHOUT deposit fee. Used for perf fee minting. + function _previewDeposit(uint256 assets) internal view returns (uint256) { + return IERC4626(address(this)).convertToShares(assets); + } + + function _mint(address to, uint256 amount) internal { + ICoreVault(address(this)).processorMint(to, amount); + } + // ========================================================================= // INTERNAL: NAV FRESHNESS + INCENTIVES + REENTRANCY // ========================================================================= @@ -740,18 +1145,27 @@ contract EpochedQueueModule { // ExitEngineLib.calculateCapRemaining uses), including dynamic cap support. if (gross > _epochCapRemaining(core, wp, _totalAssets())) return (false, address(0)); - // Liquidity + // Liquidity -- hot balance net of everything already reserved for + // FUNDED-but-unclaimed epochs; an instant exit must never dip into + // cash another epoch's claimants already own. assetAddr = _asset(); - if (IERC20(assetAddr).balanceOf(address(this)) < gross) return (false, address(0)); + uint256 hot = IERC20(assetAddr).balanceOf(address(this)); + uint256 reserved = EpochQueueStorage.layout().reservedForClaims; + uint256 free = hot > reserved ? hot - reserved : 0; + if (free < gross) return (false, address(0)); return (true, assetAddr); } /// @dev Remaining immediate-withdrawal capacity for the current cap epoch. /// Mirrors ExitEngineLib.calculateCapRemaining's bps-selection logic, but - /// uses this module's own open-epoch claimCount as the "queue depth" - /// signal for dynamic-cap scaling (EpochQueueStorage has no flat queue - /// array to measure, unlike QueueStorage). + /// uses eq.outstandingClaimCount (total unclaimed claims across ALL + /// epochs) as the "queue depth" signal for dynamic-cap scaling. + /// NOTE: this MUST be a cross-epoch running total, not the current + /// open epoch's EpochData.claimCount — that counter resets to 0 every + /// closeCurrentEpoch(), which would let dynamic-cap stress detection + /// be dodged by simply waiting for the next epoch to open while a + /// large backlog sits unfunded/unclaimed in prior epochs. function _epochCapRemaining( CoreStorage.Layout storage core, IParamsProvider.WithdrawalParams memory wp, @@ -764,8 +1178,7 @@ contract EpochedQueueModule { if (dcp.minBps == 0 || dcp.maxBps == 0) { cap = wp.capPerEpochBps; } else { - EpochQueueStorage.Layout storage eq = EpochQueueStorage.layout(); - uint256 queueDepth = eq.epochs[eq.currentEpochId].claimCount; + uint256 queueDepth = EpochQueueStorage.layout().outstandingClaimCount; cap = WithdrawalCapLib.calculateDynamicCapBps( dcp.minBps, dcp.maxBps, dcp.queueStressThreshold, queueDepth ); diff --git a/src/core/modules/FeeCollector.sol b/src/core/modules/FeeCollector.sol index 68803ae..9f8a09d 100644 --- a/src/core/modules/FeeCollector.sol +++ b/src/core/modules/FeeCollector.sol @@ -53,10 +53,41 @@ contract FeeCollector is ReentrancyGuard, Pausable { bool public allowlistEnabled; /// @notice Tracks shares queued during AUTO_HARVEST fallback (epoch cap exhausted) - /// @dev When requestClaim(true) falls back to queue, shares leave FeeCollector balance - /// but underlying is not yet delivered. Call harvestQueued() after settlement. + /// @dev When requestInstantWithdrawal falls back to the epoch queue, shares leave + /// FeeCollector balance but underlying is not yet delivered. Call harvestQueued() + /// after settlement. mapping(address => uint256) public pendingHarvestShares; // shareToken => shares queued + /// @notice One queued epoch claim awaiting settlement. + struct PendingHarvestClaim { + uint128 epochId; + uint128 claimId; + } + + /// @dev A LIST, not a single slot. Single-slot bookkeeping forced + /// distribute() to revert on a second queued harvest for the same + /// token, so one epoch that never funded blocked that token's fee + /// distribution outright until governance changed its share mode. + /// Accumulating without a list would be worse: the second claim would + /// overwrite the first one's coordinates and strand its shares in vault + /// escrow permanently. + mapping(address => PendingHarvestClaim[]) private _pendingHarvestClaims; + + /// @dev Storage backstop, not a functional limit: reaching it defers a + /// harvest, it does not revert one. + /// + /// An entry is appended only when an instant harvest cannot settle AND + /// the epoch it falls into never funds before the next distribute(). + /// One entry therefore costs one full epoch (a day or more) of + /// simultaneous cap exhaustion and funding failure, and any single + /// successful harvestQueued() drains every ready entry at once. Sixty + /// four is roughly two months of that at a daily cadence, which is far + /// beyond the point at which the funding failure itself -- visible via + /// EpochFundingShortfall -- would have been dealt with. It exists so an + /// indefinitely stalled queue cannot grow the array without bound, not + /// because the workload is expected to approach it. + uint256 public constant MAX_PENDING_HARVEST_CLAIMS = 64; + // Events event Distributed( address indexed token, @@ -83,6 +114,15 @@ contract FeeCollector is ReentrancyGuard, Pausable { address indexed shareToken, address indexed underlying, uint256 sharesIn, uint256 assetsOut ); event HarvestQueued(address indexed token, uint256 shares); + /// @notice An instant harvest settled but rounded down to zero underlying: + /// the shares are gone and there is nothing to queue or distribute. + event HarvestDustBurned(address indexed token, uint256 shares); + /// @notice A queued claim could not be pulled yet (its epoch is not funded). + event HarvestClaimNotReady(address indexed token, uint256 epochId, uint256 claimId); + /// @notice The harvest could not proceed this round and the shares were left + /// in place to be retried. Never a revert: fee distribution must not + /// be blockable by the state of the queue. + event HarvestDeferred(address indexed token, uint256 shares, string reason); event HarvestSettled( address indexed token, address indexed underlying, uint256 sharesRedeemed, uint256 underlyingOut ); @@ -207,23 +247,77 @@ contract FeeCollector is ReentrancyGuard, Pausable { if (sc.mode == ShareMode.AUTO_HARVEST) { require(sc.underlying != address(0), "FeeCollector: no underlying"); + // Queue full: defer rather than revert. Checked BEFORE calling + // the vault, deliberately -- deferring afterwards would mean the + // claim already exists in vault escrow with nowhere to record + // its coordinates, which is the untracked-escrow failure the + // single-slot bookkeeping was originally guarding against. The + // cost of checking first is that a harvest which would have + // settled inline is also deferred; the shares simply stay here + // and are picked up by the next distribute() once harvestQueued + // has drained the list. + if (_pendingHarvestClaims[token].length >= MAX_PENDING_HARVEST_CLAIMS) { + emit HarvestDeferred(token, bal, "pending harvest queue full"); + return; + } + + // Snapshot underlying balance before the call uint256 underBefore = IERC20(sc.underlying).balanceOf(address(this)); - // requestClaim(true) settles inline if cap+liquidity OK; - // falls back to queue (no revert) when epoch cap is exhausted. - IQueueModule(token).requestClaim(true, bal); + // requestInstantWithdrawal settles inline if cap+liquidity OK; + // falls back to the epoch queue (no revert) when the cap is + // exhausted. FeeCollector is exempt from the queue's + // minClaimAmount floor precisely so this contract holds: small + // fee accruals queue instead of reverting the distribution. + // try/catch, not a bare call: the vault can legitimately refuse + // this exit -- the queue's minClaimAmount floor rejects fee + // accruals below it, and withdrawals can be paused -- and a fee + // distribution must never be the thing that breaks. The floor + // applies to every caller with no address carve-out, so the + // handling belongs here, on the side that cannot tolerate the + // revert, rather than as an exemption inside the check itself. + bool settledImmediately; + uint256 epochId; + uint256 claimId; + try IQueueModule(token).requestInstantWithdrawal(bal) returns ( + bool settled_, uint256 epochId_, uint256 claimId_ + ) { + settledImmediately = settled_; + epochId = epochId_; + claimId = claimId_; + } catch { + // Shares stay here and accumulate; the next distribute() + // retries with a larger balance. + emit HarvestDeferred(token, bal, "vault refused the exit"); + return; + } uint256 out = IERC20(sc.underlying).balanceOf(address(this)) - underBefore; - if (out > 0) { - // HAPPY PATH: instant settlement delivered underlying in this tx - emit Harvested(token, sc.underlying, bal, out); - _distributeUnderlying(sc.underlying, out); + if (settledImmediately) { + // Branch on the flag alone. Branching on `out > 0` as well + // sent a dust-rounded instant settlement down the fallback + // path, where it recorded the (0, 0) sentinel this function + // returns for inline settlements as if it were a real claim + // handle -- harvestQueued would then call + // claimEpochAssets(0, 0), revert NotClaimOwner forever, and + // leave pendingHarvestShares permanently non-zero. + if (out > 0) { + emit Harvested(token, sc.underlying, bal, out); + _distributeUnderlying(sc.underlying, out); + } else { + // Shares were burned, the payout rounded to zero. There + // is nothing to queue and nothing to distribute. + emit HarvestDustBurned(token, bal); + } } else { - // FALLBACK: shares moved to vault queue escrow; underlying not yet delivered. - // Call harvestQueued(token) after vault processes the queue. + // FALLBACK: shares moved to vault epoch escrow; underlying not yet delivered. + // Call harvestQueued(token) once the epoch is FUNDED to pull the claim. pendingHarvestShares[token] += bal; + _pendingHarvestClaims[token].push( + PendingHarvestClaim({ epochId: uint128(epochId), claimId: uint128(claimId) }) + ); emit HarvestQueued(token, bal); } return; @@ -251,9 +345,12 @@ contract FeeCollector is ReentrancyGuard, Pausable { emit Distributed(token, bal, toTreasury, toOps, toSafetyReserve); } - /// @notice Process underlying delivered by a previously-queued AUTO_HARVEST fallback claim. - /// @dev Call after someone has settled the pending queue entry via processQueuedRedemptions(). - /// Anyone can call — idempotent if underlying balance is 0. + /// @notice Pull underlying for a previously-queued AUTO_HARVEST fallback claim. + /// @notice Pull underlying for every queued AUTO_HARVEST claim that is ready. + /// @dev Iterates the token's pending claims and settles the ones whose epoch + /// has been funded, leaving the rest queued. One epoch that never funds + /// therefore delays only its own claim instead of blocking the token. + /// Anyone can call; reverts only if nothing at all could be settled. function harvestQueued(address token) external nonReentrant whenNotPaused { ShareConfig memory sc = shareConfigs[token]; require(sc.isSet && sc.mode == ShareMode.AUTO_HARVEST, "FeeCollector: not AUTO_HARVEST"); @@ -262,18 +359,53 @@ contract FeeCollector is ReentrancyGuard, Pausable { uint256 pending = pendingHarvestShares[token]; require(pending > 0, "FeeCollector: no pending harvest"); - // Shares should be 0 (settled by vault — escrowed shares are gone) - require(IERC20(token).balanceOf(address(this)) == 0, "FeeCollector: shares still escrowed"); + uint256 underBefore = IERC20(sc.underlying).balanceOf(address(this)); + + PendingHarvestClaim[] storage claims = _pendingHarvestClaims[token]; + uint256 settled; + uint256 i; + while (i < claims.length) { + PendingHarvestClaim memory c = claims[i]; + try IQueueModule(token).claimEpochAssets(c.epochId, c.claimId) { + // Swap-and-pop, so do not advance i: a new element now sits here. + claims[i] = claims[claims.length - 1]; + claims.pop(); + unchecked { ++settled; } + } catch { + emit HarvestClaimNotReady(token, c.epochId, c.claimId); + unchecked { ++i; } + } + } - uint256 underBal = IERC20(sc.underlying).balanceOf(address(this)); + uint256 underBal = IERC20(sc.underlying).balanceOf(address(this)) - underBefore; + require(settled > 0, "FeeCollector: no claim ready"); require(underBal > 0, "FeeCollector: no underlying received"); - pendingHarvestShares[token] = 0; + // Only clear the share tally once every claim has been drained; a + // partial settlement leaves the remainder queued and retryable. + if (claims.length == 0) { + pendingHarvestShares[token] = 0; + } emit HarvestSettled(token, sc.underlying, pending, underBal); _distributeUnderlying(sc.underlying, underBal); } + /// @notice Number of queued epoch claims awaiting settlement for `token`. + function pendingHarvestClaimCount(address token) external view returns (uint256) { + return _pendingHarvestClaims[token].length; + } + + /// @notice Coordinates of the queued claim at `index` for `token`. + function pendingHarvestClaimAt(address token, uint256 index) + external + view + returns (uint256 epochId, uint256 claimId) + { + PendingHarvestClaim memory c = _pendingHarvestClaims[token][index]; + return (c.epochId, c.claimId); + } + function _distributeUnderlying(address underlying, uint256 amount) internal { if (allowlistEnabled) { require(allowedToken[underlying], "FeeCollector: underlying not allowed"); diff --git a/src/core/modules/FixedMaturityModule.sol b/src/core/modules/FixedMaturityModule.sol index a71c0e0..ca25633 100644 --- a/src/core/modules/FixedMaturityModule.sol +++ b/src/core/modules/FixedMaturityModule.sol @@ -13,9 +13,9 @@ import { FixedMaturityLogicLib } from "../libraries/FixedMaturityLogicLib.sol"; import { Events } from "../libraries/Events.sol"; import { IBufferManager } from "../../interfaces/IBufferManager.sol"; import { IStrategyRouter } from "../../interfaces/IStrategyRouter.sol"; -import { IQueueModule } from "../../interfaces/IQueueModule.sol"; import { ICoreVault } from "../../interfaces/ICoreVault.sol"; import { FixedPoint } from "../../libs/FixedPoint.sol"; +import { EpochQueueStorage } from "./EpochedQueueModule.sol"; // Errors from FixedMaturityStorage.sol (imported via transitive import): // DepositsClosedForVaultState, StandardExitNotAvailablePreMaturity, @@ -58,6 +58,15 @@ contract FixedMaturityModule { CoreStorage.Layout storage core = CoreStorage.layout(); if (fm.vaultMode != VaultMode.OpenEnded) revert InvalidVaultMode(); if (core.packedFlags & CoreStorage.FLAG_ROUTING_FROZEN != 0) revert InvalidVaultMode(); + // The FixedMaturity lifecycle moves capital out of the vault in states + // where the epoch queue is gated off (activateFixedMaturityCycle in + // Starting, refundClaim in FundingFailed), so neither consults + // reservedForClaims. That is only sound while no epoch claim can be + // outstanding across the switch -- claimEpochAssets has no FixedMaturity + // gate and would otherwise race the cycle deployment for the same cash. + if (EpochQueueStorage.layout().outstandingClaimCount != 0) { + revert CloseNotAllowedWithPendingShares(); + } fm.vaultMode = VaultMode.FixedMaturity; fm.vaultState = VaultState.Funding; emit Events.VaultModeConfigured(1); @@ -160,15 +169,18 @@ contract FixedMaturityModule { emit Events.FixedMaturityCycleActivated(fm.startTs, fm.maturityTs, amount); } - /// @notice Matured → Closed (requires pendingShares == 0, dust assets allowed). + /// @notice Matured → Closed (requires outstandingClaimCount == 0, dust assets allowed). function closeFixedMaturityCycle() external { FixedMaturityStorage.Layout storage fm = FixedMaturityStorage.layout(); if (fm.vaultState != VaultState.Matured) revert InvalidVaultState(); - // pendingShares == 0 is the only hard requirement. Dust assets are allowed. - if (IQueueModule(address(this)).pendingShares() != 0) { - revert CloseNotAllowedWithPendingShares(); - } + // outstandingClaimCount == 0 is the only hard requirement (no claim left + // unclaimed across any epoch — open, closed-unfunded, or funded-unclaimed). + // Dust assets are allowed. + (bool ok, bytes memory data) = address(this).staticcall( + abi.encodeWithSignature("outstandingClaimCount()") + ); + if (!ok || abi.decode(data, (uint256)) != 0) revert CloseNotAllowedWithPendingShares(); fm.vaultState = VaultState.Closed; emit Events.FixedMaturityClosed(); @@ -362,7 +374,7 @@ contract FixedMaturityModule { /// @dev Apply final performance fee using the immutable snapshot in finalPerformanceFeeBaseAssets. /// CEI pattern: set applied=true BEFORE any external calls. /// - /// This mints fee shares the same way QueueModule._crystallize() does (preview + /// This mints fee shares the same way EpochedQueueModule._crystallize() does (preview /// deposit, mint to feeCollector), but it is NOT a duplicate of that function and /// does not need its HWM-monotonicity or min-crystallize-interval fixes: /// - No persistent high-water mark: profit is `finalPerformanceFeeBaseAssets - @@ -389,7 +401,7 @@ contract FixedMaturityModule { uint256 feeAssets = FixedPoint.mulWadDown(profit, f.perfRateX); if (feeAssets == 0) return; - // Mint fee shares to feeCollector — same pattern as _crystallize() in QueueModule. + // Mint fee shares to feeCollector — same pattern as _crystallize() in EpochedQueueModule. uint256 feeShares = _previewDeposit(feeAssets); if (feeShares == 0) return; diff --git a/src/core/modules/LiquidityOpsModule.sol b/src/core/modules/LiquidityOpsModule.sol index e60aea7..5255d97 100644 --- a/src/core/modules/LiquidityOpsModule.sol +++ b/src/core/modules/LiquidityOpsModule.sol @@ -15,6 +15,7 @@ import { IExecutionMemory } from "../../interfaces/IExecutionMemory.sol"; import { AllocationTypes } from "../../interfaces/IAllocationTypes.sol"; import { ICoreVault } from "../../interfaces/ICoreVault.sol"; import { AllocationInvariantLib } from "../libraries/AllocationInvariantLib.sol"; +import { EpochQueueStorage } from "./EpochedQueueModule.sol"; import { FixedMaturityStorage, _checkOpenEndedDeployAllowed @@ -72,6 +73,12 @@ contract LiquidityOpsModule { (uint256 nav, uint256 hot, uint256 warm) = abi.decode(data, (uint256, uint256, uint256)); if (nav == 0) return false; + // Cash earmarked for FUNDED-but-unclaimed epoch claims is never + // deployable -- otherwise a strategy deploy could spend money a + // claimant already owns. + uint256 reserved = EpochQueueStorage.layout().reservedForClaims; + hot = hot > reserved ? hot - reserved : 0; + // Compute surplus after reserve + warm headroom IBufferManager.BufferConfig memory cfg = bm.getConfig(); uint256 reserveHot = @@ -425,15 +432,21 @@ contract LiquidityOpsModule { } /// @dev Build QueueSafetyContext from current vault state. - /// queueReservedUsd / queuePressureBps derived via staticcalls on self (delegatecall context = vault). + /// queueReservedUsd / queuePressureBps derived from EpochQueueStorage directly -- + /// all modules share the vault's storage via delegatecall, so this is both + /// correct and avoids a cross-selector staticcall to a function that may not + /// be routed (the old QueueModule.pendingShares() staticcall this replaced + /// silently failed post-EpochedQueueModule cutover: no module wires + /// ClaimsMixin anymore, so the staticcall always returned ok=false and + /// queuePressureBps stayed permanently 0). function _buildQueueSafetyContext(uint256 tvl) internal view returns (AllocationTypes.QueueSafetyContext memory qs) { - // Read pendingShares → queueReservedUsd approximation - (bool ok, bytes memory data) = address(this).staticcall(abi.encodeWithSignature("pendingShares()")); - if (ok && data.length == 32) { - uint256 pending = abi.decode(data, (uint256)); - // Approx: pendingShares * pps ≈ reserved USD. We use a crude lower bound. + // escrowedShares → queueReservedUsd approximation (shares currently sitting + // in escrow across all epochs, open + closed + funded-unclaimed) + { + uint256 pending = EpochQueueStorage.layout().escrowedShares; + // Approx: escrowedShares * pps ≈ reserved USD. We use a crude lower bound. qs.queueReservedUsd = pending; // caller may refine; this is fail-safe upper estimate in shares if (tvl > 0) { uint256 pressure = (pending * 10_000) / (tvl + 1); @@ -602,6 +615,11 @@ contract LiquidityOpsModule { address assetAddr = ICoreVault(address(this)).asset(); + // Cash earmarked for FUNDED-but-unclaimed epoch claims is never + // deployable -- see canDeploy(). + uint256 reserved = EpochQueueStorage.layout().reservedForClaims; + hot = hot > reserved ? hot - reserved : 0; + // Compute deployable surplus (same formula as canDeploy) IBufferManager.BufferConfig memory cfg = bm.getConfig(); uint256 reserveHot = diff --git a/src/core/modules/QueueModule.sol b/src/core/modules/QueueModule.sol deleted file mode 100644 index 0ae00b8..0000000 --- a/src/core/modules/QueueModule.sol +++ /dev/null @@ -1,876 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { CoreStorage } from "../storage/CoreStorage.sol"; -import { QueueStorage } from "../storage/QueueStorage.sol"; -import { FeeStorage } from "../storage/FeeStorage.sol"; -import { Events } from "../libraries/Events.sol"; -import { Percentage } from "../../libs/Percentage.sol"; -import { FixedPoint } from "../../libs/FixedPoint.sol"; -import { IParamsProvider } from "../../interfaces/IParamsProvider.sol"; -import { IBufferManager } from "../../interfaces/IBufferManager.sol"; -import { IStrategyRouter } from "../../interfaces/IStrategyRouter.sol"; -import { QueueLib } from "../libraries/QueueLib.sol"; -import { ExitEngineLib } from "../libraries/ExitEngineLib.sol"; -import { IIncentivesEngine } from "../../interfaces/IIncentivesEngine.sol"; -import { ICoreVault } from "../../interfaces/ICoreVault.sol"; -import { - FixedMaturityStorage, - _checkStandardExitAllowed, _checkSettlementAllowed -} from "../storage/FixedMaturityStorage.sol"; - -/// @title QueueModule v6 (ExitEngineLib Architecture) -/// @notice Handles queue processing, claims, and epoch management. -/// @dev v9 changes (ExitEngineLib refactor): -/// - ALL exit policy via ExitEngineLib (epoch rollover, cap, fee computation) -/// - Fee settlement via share TRANSFER (not mint) — no dilution -/// - Reentrancy guard on requestClaim -/// - NAV freshness: _trySoftRefreshWarmNav() before convertToAssets -/// - Escrow invariant check in _settleScan -/// -/// EXIT FEE SEMANTICS (via ExitEngineLib → ExitFeeLib): -/// INSTANT (requestClaim(true)): witBps + immediateExitPenaltyBps -/// STANDARD (requestClaim(false)): witBps only -/// FORCE: handled by ERC4626Module -/// -/// INVARIANTS: -/// 1. totalSupply NEVER increases on exit (no _mint in exit paths) -/// 2. feeShares always from owner/escrow via transfer -/// 3. epochWithdrawn <= cap (INSTANT only) -/// 4. simulateExit == runtime execution -contract QueueModule { - using SafeERC20 for IERC20; - - // ═══════════════════════════════════════════════════════════════════════════════ - // ERRORS - // ═══════════════════════════════════════════════════════════════════════════════ - error ZeroAmount(); - error ClaimTooSmall(); - error TooManyClaimsThisEpoch(); - error ClaimCooldownActive(); - error NotClaimOwner(); - error AlreadySettled(); - error ReentrancyGuardLocked(); - - uint256 public constant MAX_BATCH = 100; - uint256 public constant MAX_WARM_NAV_AGE = 15 minutes; - - // Bounded pre-scan parameters (provisional — calibration required) - uint256 internal constant MAX_SCAN_MULTIPLIER = 2; - uint256 internal constant MAX_CONSECUTIVE_INELIGIBLE = 32; - - /// @notice Result of bounded pre-scan — single source of truth for settle - struct PrescanResult { - uint256 requiredHot; // total USDC needed for eligible claims - uint256 eligibleCount; // number of eligible claims found - uint256 inspectedCount; // total entries inspected - uint256 scanWindowEnd; // half-open: settle loop uses [head, scanWindowEnd) - bool hitEarlyExit; // true if scan stopped by bound, not by maxClaims - } - - /// @dev Bundles the cached totalAssets/totalSupply snapshot into a single - /// memory-struct pointer (one stack slot) instead of two separate - /// uint256 params -- _settleLoop is already a stack-tight, via-IR - /// -sensitive function extracted specifically to avoid stack-too-deep. - struct NavSnapshot { - uint256 ta; - uint256 ts; - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // QUEUE MANAGEMENT (called via delegatecall) - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @notice Request a claim (scheduled or immediate) - /// @dev Called via delegatecall from CoreVault. - /// INSTANT: settles immediately if cap + liquidity OK (fee via transfer). - /// STANDARD: queues shares as escrow, settled by keeper. - function requestClaim(bool immediate, uint256 shares) external { - // FixedMaturity gate: requestClaim only allowed in Matured (or OpenEnded). - // Blocked in: Funding, Starting, Active, FundingFailed, Closed. - _checkStandardExitAllowed(FixedMaturityStorage.layout(), immediate); - - _enterNonReentrant(); - - if (shares == 0) revert ZeroAmount(); - - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - FeeStorage.Layout storage f = FeeStorage.layout(); - - // NAV freshness before any convertToAssets (W2: never block) - _trySoftRefreshWarmNav(); - - // Epoch rollover (parametric via ExitEngineLib) - bool rolled = ExitEngineLib.rollEpochIfNeeded(core); - if (rolled) { - emit Events.EpochRolled(core.epochStart); - } - - // Get withdrawal params - IParamsProvider.WithdrawalParams memory wp = - core.params.getWithdrawalParams(address(this)); - - // Check minimum claim amount - uint256 gross = _convertToAssets(shares); - if (wp.minClaimAmount > 0 && gross < wp.minClaimAmount) revert ClaimTooSmall(); - - // Check anti-spam - _checkQueueAntiSpam(msg.sender); - - // _canSettleInstant only fetches assetAddr lazily (once it actually - // reaches the liquidity check, after the cheaper lock/cap checks) and - // hands it back so the transfer below reuses it instead of a second - // _asset() external call. Ternary short-circuits: _canSettleInstant is - // not called at all when immediate == false. - (bool instantOk, address assetAddr) = - immediate ? _canSettleInstant(gross, wp, core) : (false, address(0)); - - // Try INSTANT settlement if requested and conditions met - if (instantOk) { - // Compute fee shares via ExitEngineLib (rounded UP) - (uint256 feeShares, uint256 userShares) = - ExitEngineLib.computeFeeShares(shares, ExitEngineLib.ExitMode.INSTANT, f.fee); - - uint256 netAssets = _convertToAssets(userShares); - - // Sync incentives BEFORE burn (assets-based, try/catch) - _notifyIncentivesExit(msg.sender, gross, core); - - // Fee via TRANSFER (not mint) — no dilution - if (feeShares > 0) { - _transferShares(msg.sender, core.feeCollector, feeShares); - emit Events.FeePaid(msg.sender, core.feeCollector, feeShares); - } - - // Burn user shares - _burn(msg.sender, userShares); - - // Transfer net assets to user - IERC20(assetAddr).safeTransfer(msg.sender, netAssets); - - // Consume epoch cap (INSTANT only) - ExitEngineLib.consumeEpochCap(core, gross); - - // Emit events - emit Events.ClaimRequested(0, msg.sender, shares, true); - emit Events.ClaimSettled(0, msg.sender, netAssets); - emit Events.InstantExit(msg.sender, shares, netAssets, feeShares); - emit IERC4626.Withdraw(msg.sender, msg.sender, msg.sender, gross, shares); - } else { - // Transfer shares to vault as escrow (will be settled later) - _transferShares(msg.sender, address(this), shares); - - // Create claim in queue — ALWAYS as STANDARD (immediate=false). - // If an instant claim falls back to queue, it must NOT remain subject - // to epoch cap at settlement. Queued = standard = no cap, only lock period. - uint256 claimId = ++q.nextClaimId; - q.claims[claimId] = QueueStorage.Claim({ - user: msg.sender, - ts: uint64(block.timestamp), - immediate: false, - settled: false, - shares: shares - }); - - q.pendingShares += shares; - q.queue.push(claimId); - - emit Events.ClaimRequested(claimId, msg.sender, shares, false); - emit Events.SharesFrozen(msg.sender, shares, claimId); - emit Events.ClaimQueued(claimId); - } - - _exitNonReentrant(); - } - - /// @notice Cancel a pending claim - function cancelClaim(uint256 claimId) external { - QueueStorage.Layout storage q = QueueStorage.layout(); - QueueStorage.Claim storage c = q.claims[claimId]; - - if (c.user != msg.sender) revert NotClaimOwner(); - if (c.settled) revert AlreadySettled(); - if (c.shares == 0) revert ZeroAmount(); - - uint256 shares = c.shares; - c.shares = 0; - c.settled = true; - q.pendingShares -= shares; - - // Return shares to user - _transferShares(address(this), msg.sender, shares); - - emit Events.ClaimCancelled(claimId, msg.sender); - emit Events.SharesUnfrozen(msg.sender, shares, claimId); - } - - /// @notice Process queued redemptions - function processQueuedRedemptions(uint256 maxClaims) external { - if (maxClaims == 0 || maxClaims > MAX_BATCH) revert ZeroAmount(); - - CoreStorage.Layout storage core = CoreStorage.layout(); - bool rolled = ExitEngineLib.rollEpochIfNeeded(core); - if (rolled) emit Events.EpochRolled(core.epochStart); - - uint256 cachedTA = _totalAssets(); - uint256 cachedTS = _totalSupply(); - _settleScan(maxClaims, type(uint256).max, cachedTA, cachedTS); - } - - /// @notice Settle fees and process queue with cap enforcement - function settleFeesAndProcessQueue(uint256 maxClaims) external { - // FixedMaturity gate: settlement only allowed in Matured state (or OpenEnded). - _checkSettlementAllowed(FixedMaturityStorage.layout()); - - if (maxClaims == 0 || maxClaims > MAX_BATCH) revert ZeroAmount(); - - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - - bool rolled = ExitEngineLib.rollEpochIfNeeded(core); - if (rolled) emit Events.EpochRolled(core.epochStart); - - // Cache totalAssets/totalSupply ONCE for entire settle batch. - // Deterministic pricing: all claims in the same tx use the same PPS. - uint256 cachedTA = _totalAssets(); - uint256 cachedTS = _totalSupply(); - - uint256 capRem = ExitEngineLib.calculateCapRemaining( - core, q, cachedTA, address(this) - ); - _settleScan(maxClaims, capRem, cachedTA, cachedTS); - - // Emit PPS snapshot (reuse cached values — no second totalAssets call) - emit Events.VaultPpsSnapshot( - uint64(block.timestamp), cachedTA, cachedTS, - cachedTS == 0 ? 1e18 : (cachedTA * 1e30) / cachedTS - ); - } - - /// @notice End epoch and crystallize performance fee - function endEpochCrystallize() external { - _crystallize(); - _updateNavSmooth(); - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // VIEW FUNCTIONS - // ═══════════════════════════════════════════════════════════════════════════════ - - function nextClaimId() external view returns (uint256) { - return QueueStorage.layout().nextClaimId; - } - - function queueLength() external view returns (uint256) { - QueueStorage.Layout storage q = QueueStorage.layout(); - uint256 len = q.queue.length; // cache: avoids a second SLOAD of the array length - return len > q.head ? len - q.head : 0; - } - - function pendingShares() external view returns (uint256) { - return QueueStorage.layout().pendingShares; - } - - /// @notice Compute total hot USDC required to settle the next batch of claims. - /// @dev Used by CoreVault.deficitForQueue and VaultUpkeep scheduler. - /// VIEW-only: no state changes, ~2K gas per claim. - function requiredHotForBatch(uint256 maxClaims) external view returns (uint256 required) { - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - IParamsProvider.WithdrawalParams memory wp = - core.params.getWithdrawalParams(address(this)); - uint256 cachedTA = _totalAssets(); - uint256 cachedTS = _totalSupply(); - uint256 capRem = ExitEngineLib.calculateCapRemaining( - core, q, cachedTA, address(this) - ); - PrescanResult memory pr = _boundedPreScan(maxClaims, capRem, wp, cachedTA, cachedTS); - required = pr.requiredHot; - } - - /// @notice Preview what settle would do. Used by VaultUpkeep to avoid churn. - function settlePreview(uint256 maxClaims) external view returns ( - uint256 eligibleCount, - uint256 requiredHot, - uint256 inspectedCount, - bool hitEarlyExit - ) { - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - IParamsProvider.WithdrawalParams memory wp = - core.params.getWithdrawalParams(address(this)); - // Cache valuation — identical to execution path for consistency - uint256 cachedTA = _totalAssets(); - uint256 cachedTS = _totalSupply(); - uint256 capRem = ExitEngineLib.calculateCapRemaining( - core, q, cachedTA, address(this) - ); - PrescanResult memory pr = _boundedPreScan(maxClaims, capRem, wp, cachedTA, cachedTS); - return (pr.eligibleCount, pr.requiredHot, pr.inspectedCount, pr.hitEarlyExit); - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: BOUNDED PRE-SCAN - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @dev Single source of truth for pre-scan. Bounded by maxClaims * MAX_SCAN_MULTIPLIER - /// entries and MAX_CONSECUTIVE_INELIGIBLE consecutive ineligible claims. - function _boundedPreScan( - uint256 maxClaims, - uint256 capRem, - IParamsProvider.WithdrawalParams memory wp, - uint256 cachedTA, - uint256 cachedTS - ) internal view returns (PrescanResult memory result) { - QueueStorage.Layout storage q = QueueStorage.layout(); - uint256 j = q.head; - uint256 jLen = q.queue.length; - uint256 maxEntries = maxClaims * MAX_SCAN_MULTIPLIER; - uint256 consecutiveIneligible = 0; - - // Invariant: scanWindowEnd >= head (start at head) - result.scanWindowEnd = j; - - while (j < jLen && result.eligibleCount < maxClaims && result.inspectedCount < maxEntries) { - QueueStorage.Claim storage sc = q.claims[q.queue[j]]; - unchecked { ++result.inspectedCount; } - - if (sc.settled || sc.shares == 0) { - unchecked { ++j; } - result.scanWindowEnd = j; - continue; - } - - uint256 gross = _convertToAssetsCached(sc.shares, cachedTA, cachedTS); - bool eligible = sc.immediate - ? (gross <= capRem) - : (wp.lockPeriod == 0 || block.timestamp >= uint256(sc.ts) + wp.lockPeriod); - - if (eligible) { - result.requiredHot += gross; - unchecked { ++result.eligibleCount; } - consecutiveIneligible = 0; - } else { - unchecked { ++consecutiveIneligible; } - if (consecutiveIneligible >= MAX_CONSECUTIVE_INELIGIBLE) { - result.hitEarlyExit = true; - result.scanWindowEnd = j + 1; // include this entry in window - break; - } - } - unchecked { ++j; } - result.scanWindowEnd = j; - } - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: SETTLEMENT SCAN - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @dev O(1) per-claim settlement. No array shifting. - /// Iterates from q.head, skips settled/ghost claims, advances head. - /// Compaction is a SEPARATE operation (compactQueue), never in settle path. - function _settleScan(uint256 maxC, uint256 capRem, uint256 cachedTA, uint256 cachedTS) internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - FeeStorage.Layout storage f = FeeStorage.layout(); - - IParamsProvider.WithdrawalParams memory wp = - core.params.getWithdrawalParams(address(this)); - IERC20 token = IERC20(_asset()); - - // NAV freshness — one refresh per scan (W2: never block) - _trySoftRefreshWarmNav(); - - // ─── STEP A: Bounded pre-scan (single source of truth) ─────── - PrescanResult memory pr = _boundedPreScan(maxC, capRem, wp, cachedTA, cachedTS); - - if (pr.hitEarlyExit) { - emit Events.QueuePrescanBoundHit( - q.head, pr.inspectedCount, pr.eligibleCount, - pr.requiredHot, true - ); - } - - if (pr.eligibleCount == 0) return; // nothing to settle - - // ─── STEP B: Warm refill ONLY (no strategy redeem) ─────────── - uint256 hot = token.balanceOf(address(this)); - { - if (pr.requiredHot > 0 && hot < pr.requiredHot) { - IBufferManager bm = core.bufferManager; - if (address(bm) != address(0)) { - uint256 warmGap = pr.requiredHot - hot; - (uint256 warmNav,, bool valid) = bm.warmNavState(); - if (valid && warmNav > 0) { - uint256 refillAmt = warmGap < warmNav ? warmGap : warmNav; - try bm.refill(refillAmt) {} - catch (bytes memory reason) { - emit Events.QueueWarmRefillFailed(0, refillAmt, reason); - } - } - hot = token.balanceOf(address(this)); - } - } - } - - // ─── STEP C: Settle loop [head, scanWindowEnd) ─────────────── - _settleLoop( - pr.scanWindowEnd, pr.eligibleCount, wp.lockPeriod, hot, capRem, - NavSnapshot({ ta: cachedTA, ts: cachedTS }) - ); - } - - /// @dev Inner settle loop, extracted to avoid stack-too-deep. - function _settleLoop( - uint256 scanWindowEnd, - uint256 maxProc, - uint256 lockPeriod, - uint256 hot, - uint256 capRem, - NavSnapshot memory nav - ) internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - QueueStorage.Layout storage q = QueueStorage.layout(); - FeeStorage.Layout storage f = FeeStorage.layout(); - IERC20 token = IERC20(_asset()); - - uint256 proc = 0; - uint256 i = q.head; - uint256 ew = core.epochWithdrawn; - uint256 ps = q.pendingShares; - - while (i < scanWindowEnd && proc < maxProc && gasleft() > 150_000) { - uint256 id = q.queue[i]; - QueueStorage.Claim storage c = q.claims[id]; - - // Skip settled/ghost claims - if (c.settled || c.shares == 0) { - unchecked { ++i; } - continue; - } - - // Escrow invariant check - { - uint256 escrowBalance = _balanceOf(address(this)); - if (escrowBalance < c.shares) { - emit Events.QueueClaimSkippedEscrowUnderflow( - id, c.user, c.shares, escrowBalance - ); - unchecked { ++i; } - continue; - } - } - - uint256 gross = _convertToAssetsCached(c.shares, nav.ta, nav.ts); - { - bool ok = c.immediate - ? (gross <= capRem) - : (lockPeriod == 0 - || block.timestamp >= uint256(c.ts) + lockPeriod); - - if (!ok) { - unchecked { ++i; } - continue; - } - } - - // Check hot liquidity (in-memory tracker, no external call) - if (hot < gross) { - emit Events.QueueClaimSkippedInsufficientHot(id, hot, gross); - unchecked { ++i; } - continue; - } - - // Sync incentives BEFORE burn (assets-based, try/catch) - _notifyIncentivesExit(c.user, gross, core); - - { - ExitEngineLib.ExitMode mode = c.immediate - ? ExitEngineLib.ExitMode.INSTANT - : ExitEngineLib.ExitMode.STANDARD; - (uint256 feeShares, uint256 userShares) = - ExitEngineLib.computeFeeShares(c.shares, mode, f.fee); - - if (feeShares > 0) { - _transferShares(address(this), core.feeCollector, feeShares); - emit Events.FeePaid(c.user, core.feeCollector, feeShares); - } - - // CRITICAL: compute net BEFORE burn, using cached TA/TS snapshot. - uint256 net = _convertToAssetsCached(userShares, nav.ta, nav.ts); - _burn(address(this), userShares); - - ps -= c.shares; - c.settled = true; - token.safeTransfer(c.user, net); - - // Update in-memory hot tracker (avoid redundant balanceOf) - hot -= net; - - emit Events.ClaimSettled(id, c.user, net); - emit IERC4626.Withdraw(address(this), c.user, c.user, gross, c.shares); - } - - if (c.immediate) { - capRem = capRem >= gross ? capRem - gross : 0; - ew += gross; - } - - unchecked { ++i; ++proc; } - } - - // Advance head past leading settled/ghost claims - { - uint256 h = q.head; - uint256 qLen = q.queue.length; - while (h < qLen) { - QueueStorage.Claim storage hc = q.claims[q.queue[h]]; - if (!hc.settled && hc.shares > 0) break; - unchecked { ++h; } - } - q.head = h; - } - - // Batch commit storage - core.epochWithdrawn = ew; - q.pendingShares = ps; - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: INSTANT SETTLEMENT CHECK - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @dev Check if instant settlement is possible. Returns the resolved - /// asset address alongside the result so callers that proceed with - /// settlement can reuse it instead of a second _asset() call -- - /// assetAddr is only fetched lazily, once the cheaper lock/cap checks - /// have already passed, so failing fast still costs zero extra calls. - function _canSettleInstant( - uint256 grossAssets, - IParamsProvider.WithdrawalParams memory wp, - CoreStorage.Layout storage core - ) internal view returns (bool ok, address assetAddr) { - // Lock period check - if ( - wp.lockPeriod > 0 - && block.timestamp < uint256(core.lastDepositTs[msg.sender]) + wp.lockPeriod - ) { - return (false, address(0)); - } - - // Epoch cap check via ExitEngineLib - QueueStorage.Layout storage q = QueueStorage.layout(); - uint256 capRemaining = ExitEngineLib.calculateCapRemaining( - core, q, _totalAssets(), address(this) - ); - if (grossAssets > capRemaining) return (false, address(0)); - - // Liquidity check - assetAddr = _asset(); - uint256 hot = IERC20(assetAddr).balanceOf(address(this)); - if (hot < grossAssets) return (false, address(0)); - - return (true, assetAddr); - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: QUEUE MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @notice Compact the queue array by removing processed head entries. - /// NEVER called in settle path (O(n)). Call separately when gas is available. - /// Safe to call by anyone — idempotent, no economic impact. - function compactQueue() external { - QueueStorage.Layout storage q = QueueStorage.layout(); - uint256 h = q.head; - uint256 len = q.queue.length; - if (h == 0 || len == 0) return; - - uint256 newLen = len - h; - for (uint256 i = 0; i < newLen;) { - q.queue[i] = q.queue[i + h]; - unchecked { ++i; } - } - for (uint256 i = 0; i < h;) { - q.queue.pop(); - unchecked { ++i; } - } - q.head = 0; - } - - function _checkQueueAntiSpam(address user) internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - - IParamsProvider.QueueParams memory qp = - core.params.getQueueParams(address(this)); - if (qp.maxClaimsPerUserPerEpoch == 0 && qp.cooldownPerClaim == 0) return; - - (bool adv, uint64 ns) = QueueLib.shouldAdvanceEpoch( - block.timestamp, core.lastEpochReset, qp.epochDuration - ); - if (adv) { - unchecked { ++core.currentEpochNumber; } - core.lastEpochReset = ns; - } - - if ( - QueueLib.isCooldownActive( - core.userLastClaimTime[user], block.timestamp, qp.cooldownPerClaim - ) - ) { - revert ClaimCooldownActive(); - } - - if (qp.maxClaimsPerUserPerEpoch > 0) { - uint64 epoch = core.currentEpochNumber; - if (core.userLastClaimEpoch[user] < epoch) { - core.userClaimsCount[user] = 0; - core.userLastClaimEpoch[user] = epoch; - } - if ( - QueueLib.isClaimCountExceeded( - core.userClaimsCount[user], qp.maxClaimsPerUserPerEpoch - ) - ) { - revert TooManyClaimsThisEpoch(); - } - unchecked { ++core.userClaimsCount[user]; } - } - - core.userLastClaimTime[user] = uint64(block.timestamp); - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: NAV FRESHNESS (W2 = never block exits) - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @dev Best-effort soft NAV refresh. GUARANTEED non-reverting. - /// If refresh fails → silently ignored, exit proceeds with stale NAV. - function _trySoftRefreshWarmNav() internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - IBufferManager bm = core.bufferManager; - if (address(bm) == address(0)) return; - - (, uint40 ts,) = bm.warmNavState(); - if (block.timestamp > ts + MAX_WARM_NAV_AGE) { - try bm.refreshWarmNav() {} catch {} - } - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: INCENTIVES EXIT SYNC - // ═══════════════════════════════════════════════════════════════════════════════ - - /// @dev Notify IncentivesEngine on exit. Assets-based, try/catch, never blocks. - function _notifyIncentivesExit( - address user, - uint256 assetsExited, - CoreStorage.Layout storage core - ) internal { - IIncentivesEngine eng = core.incentivesEngine; - if (address(eng) == address(0)) return; - try eng.onExitLight(user, assetsExited * 1e12) {} catch {} - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // INTERNAL: REENTRANCY GUARD - // ═══════════════════════════════════════════════════════════════════════════════ - - function _enterNonReentrant() internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - if (core.packedFlags & CoreStorage.FLAG_REENTRANCY_LOCKED != 0) { - revert ReentrancyGuardLocked(); - } - core.packedFlags |= CoreStorage.FLAG_REENTRANCY_LOCKED; - } - - function _exitNonReentrant() internal { - CoreStorage.layout().packedFlags &= ~CoreStorage.FLAG_REENTRANCY_LOCKED; - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // VAULT INTERFACE CALLS (delegatecall context → address(this) IS CoreVault) - // ═══════════════════════════════════════════════════════════════════════════════ - - // NOTE: direct interface calls, not low-level staticcall/call + - // abi.encodeWithSignature. Same external-call semantics (delegatecall - // context means address(this) is still the vault), but the compiler - // resolves the selector at compile time and skips the manual bytes-memory - // encode/decode + require(success, "...") boilerplate on every call site. - function _asset() internal view returns (address) { - return IERC4626(address(this)).asset(); - } - - function _totalAssets() internal view returns (uint256) { - return IERC4626(address(this)).totalAssets(); - } - - function _totalSupply() internal view returns (uint256) { - return IERC20(address(this)).totalSupply(); - } - - /// @dev Cached conversion: uses pre-computed totalAssets/totalSupply snapshot. - /// Settle uses snapshot pricing for deterministic intra-batch valuation. - /// Intra-batch asset movements do NOT affect other users' conversion rate. - function _convertToAssetsCached(uint256 shares, uint256 cachedTA, uint256 cachedTS) - internal pure returns (uint256) - { - if (cachedTS == 0) return 0; - return (shares * cachedTA) / cachedTS; - } - - function _convertToAssets(uint256 shares) internal view returns (uint256) { - return IERC4626(address(this)).convertToAssets(shares); - } - - function _balanceOf(address account) internal view returns (uint256) { - return IERC20(address(this)).balanceOf(account); - } - - /// @dev Raw asset-to-share conversion WITHOUT deposit fee. - /// Used for perf fee minting and settle fee calculation. - function _previewDeposit(uint256 assets) internal view returns (uint256) { - return IERC4626(address(this)).convertToShares(assets); - } - - function _transferShares(address from, address to, uint256 amount) internal { - ICoreVault(address(this)).processorTransfer(from, to, amount); - } - - function _mint(address to, uint256 amount) internal { - ICoreVault(address(this)).processorMint(to, amount); - } - - function _burn(address from, uint256 amount) internal { - ICoreVault(address(this)).processorBurn(from, amount); - } - - // ═══════════════════════════════════════════════════════════════════════════════ - // PERFORMANCE FEE CRYSTALLIZATION - // ═══════════════════════════════════════════════════════════════════════════════ - - function _pps() internal view returns (uint256) { - uint256 ts = _totalSupply(); - return ts == 0 ? FixedPoint.WAD : FixedPoint.divWadDown(_totalAssets(), ts); - } - - function _crystallize() internal returns (uint256 newHwm, uint256 feeAssets) { - FeeStorage.Layout storage f = FeeStorage.layout(); - CoreStorage.Layout storage core = CoreStorage.layout(); - - uint256 ts = _totalSupply(); - if (ts == 0) { - // Escape-hatch guard: only reset the fee baseline to WAD when the vault - // is genuinely empty (no residual/dust assets). If assets remain while - // supply is zero (e.g. dust left after a full redemption), keep the - // existing HWM -- otherwise a forced empty-then-refill cycle could wipe - // an already fee-eligible high-water mark while value still sits in the - // vault, letting fresh "profit" be recognised on value that was never - // actually new. - uint256 assetsNow = _totalAssets(); - if (assetsNow == 0) { - // Genuine fresh start: reset the baseline and record the event. - f.highWaterMark = FixedPoint.WAD; - f.lastCrystallize = uint64(block.timestamp); - emit Events.Crystallized(0, FixedPoint.WAD, 0); - return (FixedPoint.WAD, 0); - } - // Dust present: preserve the existing baseline. This is a no-op (no - // fee, no HWM change) so -- same reasoning as the drawdown branch - // below -- lastCrystallize is deliberately left untouched, and the - // storage slot is only written if it needs initialising. - uint256 preserved = f.highWaterMark == 0 ? FixedPoint.WAD : f.highWaterMark; - if (f.highWaterMark == 0) f.highWaterMark = preserved; - emit Events.Crystallized(0, preserved, 0); - return (preserved, 0); - } - - uint256 pps = _pps(); - uint256 old = f.highWaterMark == 0 ? FixedPoint.WAD : f.highWaterMark; - - if (pps <= old) { - // HWM is monotonically non-decreasing. Initialise the storage slot on - // the very first crystallise (highWaterMark == 0 means "use WAD as - // default" but the value is never persisted until here); otherwise - // this write would just re-store the same value, so it's skipped. - if (f.highWaterMark == 0) f.highWaterMark = old; - // Deliberately NOT touching lastCrystallize here: this call is a no-op - // (no profit crystallized). Since endEpochCrystallize() is ROLE_PUBLIC, - // bumping the timer on every no-op call would let anyone repeatedly - // push lastCrystallize forward for free, indefinitely delaying the next - // legitimate (profitable) crystallization. The interval clock should - // only advance on a real crystallization event. - emit Events.Crystallized(old, pps, 0); - return (old, 0); - } - - // Interval guard: block fee extraction within the minimum crystallise interval. - // Guard is skipped on the very first crystallise (highWaterMark == 0). - uint64 minInterval = f.minCrystallizeInterval; - if (f.highWaterMark != 0 && minInterval > 0 && - block.timestamp < uint256(f.lastCrystallize) + uint256(minInterval)) { - return (old, 0); - } - - uint256 total = _totalAssets(); - uint256 oldAssets = FixedPoint.mulWadDown(old, ts); - uint256 profit = total > oldAssets ? total - oldAssets : 0; - feeAssets = FixedPoint.mulWadDown(profit, f.perfRateX); - - if (feeAssets > 0) { - uint256 ppsBefore = pps; - uint256 feeShares = _previewDeposit(feeAssets); - if (feeShares > 0) { - _mint(core.feeCollector, feeShares); - emit Events.PerfFeeMinted(old, ppsBefore, feeShares, _pps()); - } - } - - newHwm = _pps(); - f.highWaterMark = newHwm; - f.lastCrystallize = uint64(block.timestamp); - emit Events.Crystallized(old, newHwm, feeAssets); - } - - function _updateNavSmooth() internal { - CoreStorage.Layout storage core = CoreStorage.layout(); - if (address(core.params) == address(0)) return; - - IParamsProvider.NavSmoothingParams memory nsp = - core.params.getNavSmoothingParams(address(this)); - if (!nsp.enabled) return; - - uint256 navReal = _totalAssets(); - bool initialized = - (core.packedFlags & CoreStorage.FLAG_NAV_SMOOTH_INIT) != 0; - - if (!initialized) { - core.navSmooth = navReal; - core.lastNavSmoothUpdate = uint64(block.timestamp); - core.packedFlags |= CoreStorage.FLAG_NAV_SMOOTH_INIT; - emit Events.NavSmoothUpdated(navReal, navReal, block.timestamp); - return; - } - - if ( - block.timestamp - < uint256(core.lastNavSmoothUpdate) + nsp.interval - ) { - return; - } - - uint256 alpha = nsp.alphaBps; - uint256 newSmooth = - (alpha * navReal + (10000 - alpha) * core.navSmooth) / 10000; - - core.navSmooth = newSmooth; - core.lastNavSmoothUpdate = uint64(block.timestamp); - - emit Events.NavSmoothUpdated(navReal, newSmooth, block.timestamp); - } -} diff --git a/src/interfaces/IQueueModule.sol b/src/interfaces/IQueueModule.sol index 4130af0..4750d04 100644 --- a/src/interfaces/IQueueModule.sol +++ b/src/interfaces/IQueueModule.sol @@ -1,26 +1,42 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; +import { EpochQueueStorage } from "../core/modules/EpochedQueueModule.sol"; + /// @title IQueueModule -/// @notice Interface for QueueModule functions accessible via CoreVault fallback routing -/// @dev Use this interface to call queue functions on CoreVault: IQueueModule(address(vault)).requestClaim(...) +/// @notice Interface for EpochedQueueModule functions accessible via CoreVault fallback routing +/// @dev Use this interface to call queue functions on CoreVault: IQueueModule(address(vault)).requestEpochWithdrawal(...) +/// "Queue module" = EpochedQueueModule, the sole queue-settlement mechanism. interface IQueueModule { - /// @notice Request a claim (scheduled or immediate) - /// @param immediate If true, claim is processed immediately if liquidity available - /// @param shares Number of shares to claim - function requestClaim(bool immediate, uint256 shares) external; + /// @notice Submit a standard (non-instant) withdrawal into the current open epoch + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + + /// @notice Cancel a pending claim in an OPEN epoch and return shares to the caller + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + + /// @notice Close the currently open epoch, locking PPS for all claims submitted to it + function closeCurrentEpoch() external; + + /// @notice Pull liquidity for a CLOSED epoch, transitioning it to FUNDED once fully covered + function fundEpoch(uint256 epochId) external; - /// @notice Cancel a pending claim and return shares to user - /// @param claimId The ID of the claim to cancel - function cancelClaim(uint256 claimId) external; + /// @notice Self-serve claim of assets for a FUNDED epoch + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); - /// @notice Process queued redemptions - /// @param maxClaims Maximum number of claims to process in this batch - function processQueuedRedemptions(uint256 maxClaims) external; + /// @notice Batch self-serve claim across multiple claim IDs in the same FUNDED epoch + function batchClaimEpochAssets(uint256 epochId, uint256[] calldata claimIds) + external + returns (uint256 totalAssets); - /// @notice Settle performance fees and process queue - /// @param maxClaims Maximum number of claims to process after fee settlement - function settleFeesAndProcessQueue(uint256 maxClaims) external; + /// @notice Immediate settlement for cap-eligible exits; falls back to the epoch queue otherwise + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + + /// @notice Advance the oldest-unfunded epoch cursor past any leading FUNDED epochs + function syncOldestUnfundedEpoch() external; /// @notice End epoch and crystallize performance fee /// @dev Calls performance fee crystallization and updates NAV smoothing @@ -30,12 +46,35 @@ interface IQueueModule { // VIEW FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════════ - /// @notice Get the next claim ID that will be assigned - function nextClaimId() external view returns (uint256); + function currentEpochId() external view returns (uint256); + + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory); + + function epochClaim(uint256 epochId, uint256 claimId) + external + view + returns (EpochQueueStorage.EpochClaim memory); + + function nextClaimIdForEpoch(uint256 epochId) external view returns (uint256); + + function totalEscrowedShares() external view returns (uint256); + + /// @notice Assets earmarked for FUNDED-but-unclaimed claims across all epochs + function reservedForClaims() external view returns (uint256); + + /// @notice Locked-pps liability for CLOSED-but-not-yet-FUNDED epochs + function closedPendingAssets() external view returns (uint256); + + /// @notice Total unclaimed claims across all epochs -- dynamic-cap "queue depth" signal + function outstandingClaimCount() external view returns (uint256); + + /// @notice Oldest epoch that is CLOSED but not yet FUNDED + function oldestUnfundedEpochId() external view returns (uint256); + + function epochDeficit(uint256 epochId) external view returns (uint256); - /// @notice Get the queue length - function queueLength() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); - /// @notice Get pending shares in queue - function pendingShares() external view returns (uint256); + /// @notice Claim count of the currently open epoch + function currentEpochClaimCount() external view returns (uint256); } diff --git a/src/lens/CoreVaultLens.sol b/src/lens/CoreVaultLens.sol index 2b96d36..6c0889f 100644 --- a/src/lens/CoreVaultLens.sol +++ b/src/lens/CoreVaultLens.sol @@ -10,6 +10,7 @@ import { IParamsProvider } from "../interfaces/IParamsProvider.sol"; import { Percentage } from "../libs/Percentage.sol"; import { FixedPoint } from "../libs/FixedPoint.sol"; import { WithdrawalCapLib } from "../core/libraries/WithdrawalCapLib.sol"; +import { EpochQueueStorage } from "../core/modules/EpochedQueueModule.sol"; interface ICoreVaultLensTarget { function asset() external view returns (address); @@ -28,12 +29,6 @@ interface ICoreVaultLensTarget { function navSmooth() external view returns (uint256); function navSmoothInitialized() external view returns (bool); function epochWithdrawn() external view returns (uint256); - function head() external view returns (uint256); - function queue(uint256) external view returns (uint256); - function claims(uint256) - external - view - returns (address user, uint256 shares, uint64 ts, bool immediate, bool settled); function perf() external view @@ -42,7 +37,22 @@ interface ICoreVaultLensTarget { external view returns (uint16 depBps, uint16 witBps, address treasury, bool recipientFrozen); - function pendingShares() external view returns (uint256); + + // Epoch-model queue (EpochedQueueModule, delegatecall-dispatched) + function currentEpochId() external view returns (uint256); + function currentEpochClaimCount() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function oldestUnfundedEpochId() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function nextClaimIdForEpoch(uint256 epochId) external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); + function reservedForClaims() external view returns (uint256); + function closedPendingAssets() external view returns (uint256); + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory); + function epochClaim(uint256 epochId, uint256 claimId) + external + view + returns (EpochQueueStorage.EpochClaim memory); } contract CoreVaultLens { @@ -170,10 +180,13 @@ contract CoreVaultLens { IParamsProvider pp = v.params(); IParamsProvider.DynamicCapParams memory d = pp.getDynamicCapParams(vault); if (d.minBps == 0 || d.maxBps == 0) return pp.getWithdrawalParams(vault).capPerEpochBps; - uint256 qLen = _queueLength(vault); + // Must match EpochedQueueModule._epochCapRemaining()'s signal exactly — + // outstandingClaimCount (cross-epoch total), not a per-epoch count — + // otherwise this preview would show a different cap than the vault enforces. + uint256 queueDepth = v.outstandingClaimCount(); return WithdrawalCapLib.calculateDynamicCapBps( - d.minBps, d.maxBps, d.queueStressThreshold, qLen + d.minBps, d.maxBps, d.queueStressThreshold, queueDepth ); } @@ -191,8 +204,12 @@ contract CoreVaultLens { return m > ew ? m - ew : 0; } + /// @notice True if there's epoch-queue work: a closeable non-empty open + /// epoch, or a closed-but-unfunded backlog. Mirrors CoreVault.canSettle(). function canSettle(address vault) external view returns (bool) { - return _queueLength(vault) > 0; + ICoreVaultLensTarget v = ICoreVaultLensTarget(vault); + if (v.canCloseCurrentEpoch() && v.currentEpochClaimCount() > 0) return true; + return v.oldestUnfundedEpochId() < v.currentEpochId(); } function canCrystallize(address vault) external view returns (bool) { @@ -213,53 +230,48 @@ contract CoreVaultLens { return IERC20(v.asset()).balanceOf(vault) < Percentage.mulBpsDown(v.totalAssets(), t); } - function getClaim(address vault, uint256 id) + /// @notice Fetch a single claim within a specific epoch (claim IDs restart + /// at 1 per epoch in the epoch model — there is no single global ID). + function getEpochClaim(address vault, uint256 epochId, uint256 claimId) external view - returns (address user, uint256 shares, bool immediate, bool settled) + returns (address user, uint256 netShares, bool claimed) { - ICoreVaultLensTarget v = ICoreVaultLensTarget(vault); - (user, shares,, immediate, settled) = v.claims(id); + EpochQueueStorage.EpochClaim memory c = ICoreVaultLensTarget(vault).epochClaim(epochId, claimId); + return (c.user, c.netShares, c.claimed); } - function getUserClaims(address vault, address user) + /// @notice Scan a caller-supplied epoch range for a user's claims. Bounded + /// by the caller (not the whole vault history) — an indexer/frontend + /// already knows which epochs a user interacted with from + /// EpochWithdrawalRequested events, so this avoids an unbounded + /// full-history scan that only grows over the vault's lifetime. + function getUserEpochClaims(address vault, address user, uint256 fromEpoch, uint256 toEpoch) external view - returns (uint256[] memory ids) + returns (uint256[] memory epochIds, uint256[] memory claimIds) { ICoreVaultLensTarget v = ICoreVaultLensTarget(vault); - uint256 h = v.head(); - uint256 n = h + _queueLength(vault); - uint256 c = 0; - for (uint256 i = h; i < n; ++i) { - (address u,,,,) = v.claims(v.queue(i)); - if (u == user) c++; + uint256 count; + for (uint256 e = fromEpoch; e <= toEpoch; ++e) { + uint256 nextId = v.nextClaimIdForEpoch(e); + for (uint256 c = 1; c <= nextId; ++c) { + if (v.epochClaim(e, c).user == user) count++; + } } - ids = new uint256[](c); + epochIds = new uint256[](count); + claimIds = new uint256[](count); uint256 j; - for (uint256 i = h; i < n; ++i) { - uint256 qid = v.queue(i); - (address u,,,,) = v.claims(qid); - if (u == user) ids[j++] = qid; - } - } - - function queueLength(address vault) external view returns (uint256) { - return _queueLength(vault); - } - - function _queueLength(address vault) internal view returns (uint256) { - ICoreVaultLensTarget v = ICoreVaultLensTarget(vault); - uint256 h = v.head(); - uint256 i = h; - while (true) { - try v.queue(i) returns (uint256) { - i++; - } catch { - break; + for (uint256 e = fromEpoch; e <= toEpoch; ++e) { + uint256 nextId = v.nextClaimIdForEpoch(e); + for (uint256 c = 1; c <= nextId; ++c) { + if (v.epochClaim(e, c).user == user) { + epochIds[j] = e; + claimIds[j] = c; + j++; + } } } - return i - h; } function pendingLoyaltyBonus(address vault, address user) external view returns (uint256) { @@ -306,7 +318,7 @@ contract CoreVaultLens { uint256 availableLiquidity; uint256 pendingWithdrawals; uint256 capRemaining; - uint256 queueLen; + uint256 outstandingClaims; bool canSettleNow; bool canCrystallizeNow; } @@ -317,10 +329,20 @@ contract CoreVaultLens { r.totalSupply = v.totalSupply(); r.pricePerShare = pps(vault); r.availableLiquidity = IERC20(v.asset()).balanceOf(vault); - r.pendingWithdrawals = v.convertToAssets(v.pendingShares()); + // Exact, O(1): the still-open epoch's shares haven't locked a pps yet + // (valued live), while closed/funded epochs' liabilities are already + // locked-pps-correct by construction (see EpochQueueStorage.Layout). + // Replaces the old convertToAssets(totalEscrowedShares()) approximation, + // which priced ALL escrowed shares (including already-locked ones) at + // the CURRENT live pps -- wrong whenever pps moved since a closed + // epoch's ppsAtClose. + EpochQueueStorage.EpochData memory openEpoch = v.epochData(v.currentEpochId()); + r.pendingWithdrawals = v.convertToAssets(openEpoch.totalNetShares) + + v.closedPendingAssets() + + v.reservedForClaims(); r.capRemaining = this.calculateCapImmediateRemaining(vault); - r.queueLen = _queueLength(vault); - r.canSettleNow = r.queueLen > 0; + r.outstandingClaims = v.outstandingClaimCount(); + r.canSettleNow = this.canSettle(vault); r.canCrystallizeNow = this.canCrystallize(vault); } @@ -331,7 +353,9 @@ contract CoreVaultLens { uint256 pendingBonus; } - function getUserReport(address vault, address user) + /// @param fromEpoch/toEpoch bounds the epoch scan for pendingClaims — see + /// getUserEpochClaims() for why this isn't a full-history scan. + function getUserReport(address vault, address user, uint256 fromEpoch, uint256 toEpoch) external view returns (UserReport memory r) @@ -339,10 +363,25 @@ contract CoreVaultLens { ICoreVaultLensTarget v = ICoreVaultLensTarget(vault); r.shares = v.balanceOf(user); r.assetsValue = v.convertToAssets(r.shares); - uint256[] memory ids = this.getUserClaims(vault, user); - for (uint256 i = 0; i < ids.length; ++i) { - (, uint256 sh,,, bool settled) = v.claims(ids[i]); - if (!settled) r.pendingClaims += v.convertToAssets(sh); + (uint256[] memory epochIds, uint256[] memory claimIds) = + this.getUserEpochClaims(vault, user, fromEpoch, toEpoch); + // A closed/funded epoch pays at its own locked ppsAtClose, not live + // pps -- cache the last epoch fetched since claims are usually + // grouped by epoch, to avoid refetching per claim. + uint256 cachedEpochId; + EpochQueueStorage.EpochData memory cachedEpoch; + bool haveCached; + for (uint256 i = 0; i < claimIds.length; ++i) { + EpochQueueStorage.EpochClaim memory c = v.epochClaim(epochIds[i], claimIds[i]); + if (c.claimed) continue; + if (!haveCached || cachedEpochId != epochIds[i]) { + cachedEpoch = v.epochData(epochIds[i]); + cachedEpochId = epochIds[i]; + haveCached = true; + } + r.pendingClaims += cachedEpoch.state == EpochQueueStorage.EpochState.Open + ? v.convertToAssets(c.netShares) + : FixedPoint.mulWadDown(c.netShares, cachedEpoch.ppsAtClose); } r.pendingBonus = this.pendingLoyaltyBonus(vault, user); } diff --git a/src/libs/DeployTypes.sol b/src/libs/DeployTypes.sol index 71e222d..3ecedfd 100644 --- a/src/libs/DeployTypes.sol +++ b/src/libs/DeployTypes.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.28; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "../core/CoreVault.sol"; -import { QueueModule } from "../core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../core/modules/AdminModule.sol"; import { ERC4626Module } from "../core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "../core/modules/LiquidityOpsModule.sol"; @@ -32,7 +32,7 @@ library DeployTypes { struct DeployResult { CoreVault vault; - QueueModule queueModule; + EpochedQueueModule queueModule; AdminModule adminModule; ERC4626Module erc4626Module; LiquidityOpsModule liquidityOpsModule; diff --git a/test/helpers/BaseVaultTest.t.sol b/test/helpers/BaseVaultTest.t.sol index e7223e6..43a608a 100644 --- a/test/helpers/BaseVaultTest.t.sol +++ b/test/helpers/BaseVaultTest.t.sol @@ -80,8 +80,6 @@ contract BaseVaultTest is Test { address(0), // buffer manager (optional wiring) address(stubRouter), address(stubConfig), - 25, - 100, type(uint256).max, type(uint256).max, 10, // minRealizeGapBps (0.1%) diff --git a/test/helpers/CoreDeployHelper.sol b/test/helpers/CoreDeployHelper.sol index 970199d..adea51d 100644 --- a/test/helpers/CoreDeployHelper.sol +++ b/test/helpers/CoreDeployHelper.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.28; import { DeployTypes } from "../../src/libs/DeployTypes.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "../../src/core/modules/LiquidityOpsModule.sol"; @@ -18,7 +18,7 @@ library CoreDeployHelper { function deploy( DeployTypes.DeployConfig memory config, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule @@ -66,7 +66,7 @@ library CoreDeployHelper { function _configureRouting( CoreVault vault, - QueueModule queueModule, + EpochedQueueModule queueModule, AdminModule adminModule, ERC4626Module erc4626Module, LiquidityOpsModule liquidityOpsModule diff --git a/test/helpers/CoreHarness.sol b/test/helpers/CoreHarness.sol index 6890c04..c0e4ffb 100644 --- a/test/helpers/CoreHarness.sol +++ b/test/helpers/CoreHarness.sol @@ -6,14 +6,12 @@ import { Vm } from "forge-std/Vm.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; import { CoreStorage } from "../../src/core/storage/CoreStorage.sol"; import { FeeStorage } from "../../src/core/storage/FeeStorage.sol"; -import { QueueStorage } from "../../src/core/storage/QueueStorage.sol"; import { IIncentives } from "../../src/interfaces/IIncentives.sol"; import { IIncentivesEngine } from "../../src/interfaces/IIncentivesEngine.sol"; import { IBufferManager } from "../../src/interfaces/IBufferManager.sol"; import { IStrategyRouter } from "../../src/interfaces/IStrategyRouter.sol"; import { IParamsProvider } from "../../src/interfaces/IParamsProvider.sol"; import { StrategyRouter } from "../../src/core/modules/StrategyRouter.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; @@ -32,8 +30,7 @@ contract CoreHarness is CoreVault { uint16 private _opsFloorBps = 100; // 1% // Track deployed modules for selector registration - QueueModule public queueModule; - EpochedQueueModule public queueEpochModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; ERC4626Module public erc4626Module; LiquidityOpsModule public liquidityOpsModule; @@ -56,44 +53,33 @@ contract CoreHarness is CoreVault { ) { // Deploy modules - queueModule = new QueueModule(); - queueEpochModule = new EpochedQueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); erc4626Module = new ERC4626Module(); liquidityOpsModule = new LiquidityOpsModule(); - // Wire up queue module selectors (PUBLIC) - _setModuleUnsafe(QueueModule.requestClaim.selector, address(queueModule), ROLE_PUBLIC); - _setModuleUnsafe(QueueModule.cancelClaim.selector, address(queueModule), ROLE_PUBLIC); - _setModuleUnsafe( - QueueModule.processQueuedRedemptions.selector, address(queueModule), ROLE_PUBLIC - ); - _setModuleUnsafe( - QueueModule.settleFeesAndProcessQueue.selector, address(queueModule), ROLE_PUBLIC - ); - _setModuleUnsafe( - QueueModule.endEpochCrystallize.selector, address(queueModule), ROLE_PUBLIC - ); - _setModuleUnsafe(QueueModule.pendingShares.selector, address(queueModule), ROLE_PUBLIC); - _setModuleUnsafe(QueueModule.queueLength.selector, address(queueModule), ROLE_PUBLIC); - _setModuleUnsafe(QueueModule.nextClaimId.selector, address(queueModule), ROLE_PUBLIC); - _setModuleUnsafe(QueueModule.compactQueue.selector, address(queueModule), ROLE_PUBLIC); - - // Wire up epoch-bucket queue module (EpochedQueueModule) selectors (PUBLIC) - _setModuleUnsafe(EpochedQueueModule.requestEpochWithdrawal.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.closeCurrentEpoch.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.fundEpoch.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.claimEpochAssets.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.requestInstantWithdrawal.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.currentEpochId.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.epochData.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.epochClaim.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.nextClaimIdForEpoch.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.totalEscrowedShares.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.epochDeficit.selector, address(queueEpochModule), ROLE_PUBLIC); - _setModuleUnsafe(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueEpochModule), ROLE_PUBLIC); + // Wire up queue module (EpochedQueueModule) selectors (PUBLIC) + _setModuleUnsafe(EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.fundEpoch.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.endEpochCrystallize.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.syncOldestUnfundedEpoch.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.currentEpochId.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.currentEpochClaimCount.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.epochData.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.epochClaim.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.nextClaimIdForEpoch.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.oldestUnfundedEpochId.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.epochDeficit.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.reservedForClaims.selector, address(queueModule), ROLE_PUBLIC); + _setModuleUnsafe(EpochedQueueModule.closedPendingAssets.selector, address(queueModule), ROLE_PUBLIC); // Wire up admin module owner selectors (OWNER) _setModuleUnsafe(AdminModule.submitFeeParams.selector, address(adminModule), ROLE_OWNER); @@ -389,16 +375,6 @@ contract CoreHarness is CoreVault { return 1; } - // ---- Queue view helpers ---- - function queueLength() external view returns (uint256) { - QueueStorage.Layout storage q = QueueStorage.layout(); - return q.queue.length > q.head ? q.queue.length - q.head : 0; - } - - function pendingShares() external view returns (uint256) { - return QueueStorage.layout().pendingShares; - } - // ---- Legacy compatibility (for old tests expecting these) ---- function pause() external onlyOwner { CoreStorage.layout().packedFlags |= CoreStorage.FLAG_PAUSED; @@ -429,6 +405,7 @@ contract CoreHarness is CoreVault { CoreStorage.layout().incentivesEngine = IIncentivesEngine(engine); } - // Note: Queue module functions (requestClaim, cancelClaim, processQueuedRedemptions, - // settleFeesAndProcessQueue) are available via fallback routing to QueueModule + // Note: Queue module functions (requestEpochWithdrawal, cancelEpochWithdrawal, + // closeCurrentEpoch, fundEpoch, claimEpochAssets, requestInstantWithdrawal) are + // available via fallback routing to EpochedQueueModule } diff --git a/test/helpers/Interfaces.sol b/test/helpers/Interfaces.sol index 1f3f8b6..23671b9 100644 --- a/test/helpers/Interfaces.sol +++ b/test/helpers/Interfaces.sol @@ -9,7 +9,7 @@ interface ICoreAggregatorVaultV1 { function canSettle() external view returns (bool); function canCrystallize() external view returns (bool); function canRealize() external view returns (bool); - function queueLength() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); function epochStart() external view returns (uint64); // params (read) @@ -17,13 +17,13 @@ interface ICoreAggregatorVaultV1 { function epochWithdrawn() external view returns (uint256); // ops - function processQueuedRedemptions(uint256 maxClaims) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; function endEpochCrystallize() external; function realizeForReserveAndOps(uint256 maxAmount) external; // claim path (user) - function requestClaim(bool isImmediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); // deposit/mint function deposit(uint256 assets, address receiver) external returns (uint256); diff --git a/test/helpers/MockParamsProvider.sol b/test/helpers/MockParamsProvider.sol index c0a248a..e5214db 100644 --- a/test/helpers/MockParamsProvider.sol +++ b/test/helpers/MockParamsProvider.sol @@ -259,18 +259,32 @@ contract MockParamsProvider is IParamsProvider { return address(0); } + /// @dev Oracle wiring, off by default so existing suites keep their old + /// behaviour. Set it when the code under test reaches a path that + /// values the asset -- StrategyRouter.executeRedeemBatch does, and + /// reverts OracleNotConfigured without it. + address private _oracle; + uint256 private _oracleStaleness = 3600; + + function setOracle(address oracle_) external { + _oracle = oracle_; + } + + function setOracleStaleness(uint256 staleness_) external { + _oracleStaleness = staleness_; + } + /// @notice Get oracle + staleness config for (asset, vault) - /// @dev Returns default values (no oracle configured) function oracleConfigFor( address, /* asset */ address /* vault */ ) external - pure + view returns (address oracle, uint256 maxStaleness_) { - return (address(0), 3600); + return (_oracle, _oracleStaleness); } /// @notice Returns permissive max actions (100) diff --git a/test/helpers/TestDeployer.sol b/test/helpers/TestDeployer.sol deleted file mode 100644 index d70eda4..0000000 --- a/test/helpers/TestDeployer.sol +++ /dev/null @@ -1,463 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; -import { AdminModule } from "../../src/core/modules/AdminModule.sol"; -import { CoreStorage } from "../../src/core/storage/CoreStorage.sol"; -import { FeeStorage } from "../../src/core/storage/FeeStorage.sol"; -import { QueueStorage } from "../../src/core/storage/QueueStorage.sol"; -import { IParamsProvider } from "../../src/interfaces/IParamsProvider.sol"; -import { IBufferManager } from "../../src/interfaces/IBufferManager.sol"; -import { IStrategyRouter } from "../../src/interfaces/IStrategyRouter.sol"; -import { IIncentives } from "../../src/interfaces/IIncentives.sol"; -import { StrategyRouter } from "../../src/core/modules/StrategyRouter.sol"; - -/// @title TestDeployer -/// @notice Centralized helper for deploying CoreVault with full configuration for tests -/// @dev Replicates the 14-param legacy constructor behavior using the new 6-param CoreVault -/// plus post-deploy configuration via setters and module wiring -contract TestDeployer { - // Deployed modules (singleton instances reused across tests) - QueueModule public queueModule; - AdminModule public adminModule; - - constructor() { - // Deploy singleton module instances - queueModule = new QueueModule(); - adminModule = new AdminModule(); - } - - /// @notice Deploy a fully configured CoreVault matching legacy 14-param behavior - /// @param asset The underlying asset (e.g., USDC) - /// @param name Vault name - /// @param symbol Vault symbol - /// @param owner_ Initial owner (will have full control) - /// @param guardian_ Guardian address (can emergency pause) - /// @param treasury_ Fee collector address - /// @param bufferManager_ Buffer manager (can be address(0)) - /// @param router_ Strategy router (can be address(0)) - /// @param incentives_ Incentives module (can be address(0)) - /// @param params_ Params provider - /// @param depositFeeBps Initial deposit fee in basis points - /// @param withdrawFeeBps Initial withdraw fee in basis points - /// @param perfRateX Performance fee rate (WAD format, e.g., 1e17 = 10%) - /// @param minCryst Minimum crystallization interval - /// @return vault The deployed and configured CoreVault - function deployVault( - IERC20Metadata asset, - string memory name, - string memory symbol, - address owner_, - address guardian_, - address treasury_, - address bufferManager_, - address router_, - address incentives_, - address params_, - uint16 depositFeeBps, - uint16 withdrawFeeBps, - uint256 perfRateX, - uint64 minCryst - ) external returns (CoreVault vault) { - // Step 1: Deploy CoreVault with 6-param constructor - // Owner is initially this contract so we can configure it - vault = new CoreVault( - asset, - name, - symbol, - address(this), // Temporary owner for configuration - treasury_, - params_ - ); - - // Step 2: Wire up QueueModule selectors (PUBLIC access) - vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.nextClaimId.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - - // Step 3: Wire up AdminModule selectors (OWNER access) - vault.setModule( - AdminModule.submitFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokeFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.submitPerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptPerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokePerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.submitMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokeMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setParams.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setBufferManager.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setRouter.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setHealthRegistry.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.setIncentives.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.setFeeCollector.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setVetoer.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule(AdminModule.freezeParams.selector, address(adminModule), vault.ROLE_OWNER()); - - // AdminModule view selectors (PUBLIC) - vault.setModule( - AdminModule.getPendingFeeParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingPerfParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingMinDelay.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getFeeParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPerfParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule(AdminModule.getMinDelay.selector, address(adminModule), vault.ROLE_PUBLIC()); - vault.setModule( - AdminModule.isParamsFrozen.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - - // Step 4: Set guardian if provided - if (guardian_ != address(0)) { - vault.setGuardian(guardian_); - } - - // Step 5: Configure fee params via direct storage (since we're deploying) - // This simulates what the legacy constructor did - _setFeeStorage( - address(vault), depositFeeBps, withdrawFeeBps, treasury_, perfRateX, minCryst - ); - - // Step 6: Set buffer manager if provided (via AdminModule) - if (bufferManager_ != address(0)) { - _callAdminModule( - address(vault), - abi.encodeWithSelector(AdminModule.setBufferManager.selector, bufferManager_) - ); - } - - // Step 7: Set router if provided (via AdminModule) - if (router_ != address(0)) { - _callAdminModule( - address(vault), abi.encodeWithSelector(AdminModule.setRouter.selector, router_) - ); - } - - // Step 8: Set incentives if provided (via AdminModule) - if (incentives_ != address(0)) { - _callAdminModule( - address(vault), - abi.encodeWithSelector(AdminModule.setIncentives.selector, incentives_) - ); - } - - // Step 9: Transfer ownership to intended owner - if (owner_ != address(this)) { - vault.beginOwnerTransfer(owner_); - // Note: The intended owner must call vault.acceptOwnerTransfer() to complete - } - } - - /// @notice Deploy a minimal CoreVault (no modules, just the 6-param constructor) - function deployMinimalVault( - IERC20Metadata asset, - string memory name, - string memory symbol, - address owner_, - address feeCollector_, - address params_ - ) external returns (CoreVault vault) { - vault = new CoreVault(asset, name, symbol, owner_, feeCollector_, params_); - } - - /// @notice Wire up standard modules to an existing vault - /// @dev Call this if you deployed with deployMinimalVault and need modules - function wireModules(CoreVault vault) external { - // QueueModule selectors (PUBLIC) - vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.nextClaimId.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - - // AdminModule owner selectors (OWNER) - vault.setModule( - AdminModule.submitFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokeFeeParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.submitPerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptPerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokePerfParams.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.submitMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokeMinDelay.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setParams.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setBufferManager.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setRouter.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setHealthRegistry.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.setIncentives.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.setFeeCollector.selector, address(adminModule), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setVetoer.selector, address(adminModule), vault.ROLE_OWNER()); - vault.setModule(AdminModule.freezeParams.selector, address(adminModule), vault.ROLE_OWNER()); - - // AdminModule view selectors (PUBLIC) - vault.setModule( - AdminModule.getPendingFeeParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingPerfParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingMinDelay.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getFeeParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPerfParams.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - vault.setModule(AdminModule.getMinDelay.selector, address(adminModule), vault.ROLE_PUBLIC()); - vault.setModule( - AdminModule.isParamsFrozen.selector, address(adminModule), vault.ROLE_PUBLIC() - ); - } - - /// @dev Internal: Set fee storage directly (called during deployment) - function _setFeeStorage( - address vault, - uint16 depBps, - uint16 witBps, - address treasury, - uint256 perfRateX, - uint64 minCryst - ) internal { - // Access FeeStorage slot directly - // FeeStorage slot = keccak256("corevault.storage.fee") - 1 - bytes32 FEE_SLOT = 0x6c38d2f6b31a4892a4e0e6f7e8f2f2c3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3; - - // We need to use assembly to write to storage since we don't have direct access - // from outside the contract. Instead, we'll use a different approach: - // The TestDeployer is the temporary owner, so we call the AdminModule functions. - - // Actually, since the vault was just deployed with us as owner, we need to set - // the fees before ownership transfer. The cleanest way is to store this in - // the constructor call, but CoreVault doesn't accept fee params. - - // For now, we'll need to accept that fee setup happens via AdminModule timelock - // OR we extend CoreVault to have an initializer. - - // Pragmatic solution: We write directly to storage using assembly - // This is safe because we're the deployer and this is test code. - - assembly { - // FeeStorage.SLOT = keccak256("corevault.storage.fee") - 1 - // Layout: FeeParams fee; uint256 perfRateX; uint64 minCrystallizeInterval; ... - // FeeParams: depBps (uint16), witBps (uint16), treasury (address), recipientFrozen (bool) - - // Calculate the slot - let slot := 0x3f0e62b2a92d3b0e1c5a8d9f4e7c6b5a4d3c2b1a0f9e8d7c6b5a4d3c2b1a0f9e - mstore(0x00, "corevault.storage.fee") - slot := sub(keccak256(0x00, 21), 1) - - // Slot 0: FeeParams struct packed - // depBps (16 bits) | witBps (16 bits) | treasury (160 bits) | recipientFrozen (8 bits) - let feeParamsPacked := or(or(depBps, shl(16, witBps)), shl(32, treasury)) - - // We can't directly write to another contract's storage from here - // This approach won't work - we need a different strategy - } - - // Since we can't write to vault storage from here, we need the vault - // to have an internal way to set initial fees. For tests, we'll use - // the fact that we can call through the AdminModule if we set up - // a bypass for initial configuration. - - // For now, let's skip the direct storage write and rely on tests - // to set fees via the proper AdminModule flow (submit + accept after timelock) - // OR we modify CoreVault to have an initialization function. - } - - /// @dev Internal helper to call AdminModule via vault - function _callAdminModule(address vault, bytes memory data) internal { - (bool success,) = vault.call(data); - require(success, "AdminModule call failed"); - } -} - -/// @title TestDeployerStateless -/// @notice Stateless version that can be used without deploying the helper contract -library TestDeployerLib { - /// @notice Deploy CoreVault with modules and accept ownership in one call - /// @dev Use this from test contracts where msg.sender will be the test contract - function deployAndConfigure( - IERC20Metadata asset, - string memory name, - string memory symbol, - address finalOwner, - address guardian_, - address treasury_, - address params_, - uint16 depositFeeBps, - uint16 withdrawFeeBps - ) internal returns (CoreVault vault, QueueModule queueMod, AdminModule adminMod) { - // Deploy modules - queueMod = new QueueModule(); - adminMod = new AdminModule(); - - // Deploy vault with caller as temporary owner - vault = new CoreVault(asset, name, symbol, address(this), treasury_, params_); - - // Wire QueueModule (PUBLIC) - vault.setModule(QueueModule.requestClaim.selector, address(queueMod), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.cancelClaim.selector, address(queueMod), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueMod), vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, address(queueMod), vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.endEpochCrystallize.selector, address(queueMod), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.nextClaimId.selector, address(queueMod), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.queueLength.selector, address(queueMod), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.pendingShares.selector, address(queueMod), vault.ROLE_PUBLIC()); - - // Wire AdminModule owner functions (OWNER) - vault.setModule(AdminModule.submitFeeParams.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.acceptFeeParams.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.revokeFeeParams.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.submitPerfParams.selector, address(adminMod), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.acceptPerfParams.selector, address(adminMod), vault.ROLE_OWNER() - ); - vault.setModule( - AdminModule.revokePerfParams.selector, address(adminMod), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.submitMinDelay.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.acceptMinDelay.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.revokeMinDelay.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.setParams.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setBufferManager.selector, address(adminMod), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setRouter.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule( - AdminModule.setHealthRegistry.selector, address(adminMod), vault.ROLE_OWNER() - ); - vault.setModule(AdminModule.setIncentives.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.setFeeCollector.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.setVetoer.selector, address(adminMod), vault.ROLE_OWNER()); - vault.setModule(AdminModule.freezeParams.selector, address(adminMod), vault.ROLE_OWNER()); - - // Wire AdminModule view functions (PUBLIC) - vault.setModule( - AdminModule.getPendingFeeParams.selector, address(adminMod), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingPerfParams.selector, address(adminMod), vault.ROLE_PUBLIC() - ); - vault.setModule( - AdminModule.getPendingMinDelay.selector, address(adminMod), vault.ROLE_PUBLIC() - ); - vault.setModule(AdminModule.getFeeParams.selector, address(adminMod), vault.ROLE_PUBLIC()); - vault.setModule(AdminModule.getPerfParams.selector, address(adminMod), vault.ROLE_PUBLIC()); - vault.setModule(AdminModule.getMinDelay.selector, address(adminMod), vault.ROLE_PUBLIC()); - vault.setModule(AdminModule.isParamsFrozen.selector, address(adminMod), vault.ROLE_PUBLIC()); - - // Set guardian - if (guardian_ != address(0)) { - vault.setGuardian(guardian_); - } - - // Transfer ownership to final owner - if (finalOwner != address(this)) { - vault.beginOwnerTransfer(finalOwner); - } - } -} diff --git a/test/integration/BufferManagerFlow.t.sol b/test/integration/BufferManagerFlow.t.sol index 3734162..938993d 100644 --- a/test/integration/BufferManagerFlow.t.sol +++ b/test/integration/BufferManagerFlow.t.sol @@ -123,14 +123,22 @@ contract BufferManagerFlowTest is Test { assertApproxEqAbs(warmBal, amount - (amount / 10), 2); // Now withdraw an amount larger than current hot to trigger refill from warm. - // Async vault: requestClaim (hot insufficient → queues), then keeper settles - // with bm.refill() pulling the shortfall from warm. + // Async vault: requestEpochWithdrawal (hot insufficient → queues into the + // current epoch), then closeCurrentEpoch() + fundEpoch() (which pulls the + // shortfall from warm via bm.refill() internally), then the user self-claims. uint256 withdrawAssets = 50_000e6; // 50k USDC uint256 sharesToClaim = vault.previewWithdraw(withdrawAssets); vm.startPrank(user); - IQueueModule(address(vault)).requestClaim(false, sharesToClaim); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(sharesToClaim); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + + vm.prank(user); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); // User should have received 50k assertEq(MockUSDC(USDC_UNDERLYING).balanceOf(user), 1_000_000e6 - amount + withdrawAssets); diff --git a/test/integration/CoreEngine_Integration_Hardening.t.sol b/test/integration/CoreEngine_Integration_Hardening.t.sol index 185dc57..5458770 100644 --- a/test/integration/CoreEngine_Integration_Hardening.t.sol +++ b/test/integration/CoreEngine_Integration_Hardening.t.sol @@ -12,16 +12,24 @@ import { MockBufferManagerForTests } from "../helpers/MockBufferManagerForTests. import { StrategyMock } from "../helpers/StrategyMock.sol"; import { RevertingStrategyMock } from "../helpers/RevertingStrategyMock.sol"; import { StrategyRouter } from "../../src/core/modules/StrategyRouter.sol"; -import { QueueStorage } from "../../src/core/storage/QueueStorage.sol"; interface IQueueVault { - function requestClaim(bool immediate, uint256 shares) external; - function cancelClaim(uint256 claimId) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function pendingShares() external view returns (uint256); - function nextClaimId() external view returns (uint256); - function queueLength() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function currentEpochId() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); + function oldestUnfundedEpochId() external view returns (uint256); } // ============================================================================ @@ -79,23 +87,98 @@ contract CoreEngine_Integration_Hardening is Test { function _q() internal view returns (IQueueVault) { return IQueueVault(address(vault)); } + // ── epoch-model claim tracking ────────────────────────────────────────── + // QueueModule's settleFeesAndProcessQueue() was a synchronous keeper-push + // scan with no timing gate. EpochedQueueModule instead requires + // close -> fund -> pull-claim, with closeCurrentEpoch() gated on a minimum + // epoch duration. These helpers preserve the old "just call _settle() and + // balances move" ergonomics for the ~20 tests in this file built around + // that push-style expectation, while driving the real epoch lifecycle + // underneath (see _settle below). + struct QueuedClaim { + address user; + uint256 epochId; + uint256 claimId; + bool settled; + bool cancelled; + } + QueuedClaim[] internal _claims; + uint256 internal _settleCursor; + function _requestClaim(address user, uint256 shares, bool immediate) internal returns (uint256 claimId) { - uint256 before = _q().nextClaimId(); vm.prank(user); - _q().requestClaim(immediate, shares); - claimId = before + 1; // nextClaimId is pre-incremented (++q.nextClaimId) + if (immediate) { + (bool settledImmediately, uint256 epochId, uint256 vaultClaimId) = + _q().requestInstantWithdrawal(shares); + if (settledImmediately) { + claimId = type(uint256).max; // sentinel: settled inline, nothing to track + } else { + claimId = _claims.length; + _claims.push(QueuedClaim(user, epochId, vaultClaimId, false, false)); + } + } else { + (uint256 epochId, uint256 vaultClaimId) = _q().requestEpochWithdrawal(shares); + claimId = _claims.length; + _claims.push(QueuedClaim(user, epochId, vaultClaimId, false, false)); + } } function _cancelClaim(address user, uint256 claimId) internal { + require(claimId != type(uint256).max, "test: claim already settled inline"); + QueuedClaim storage c = _claims[claimId]; vm.prank(user); - _q().cancelClaim(claimId); + _q().cancelEpochWithdrawal(c.epochId, c.claimId); + c.cancelled = true; } + /// @dev Closes + funds the current epoch (warping forward if not yet + /// mature), then self-claims (pull-based) up to `maxClaims` of the + /// oldest not-yet-settled/cancelled tracked claims -- mirroring the + /// old keeper's FIFO-windowed settle(maxClaims) semantics. function _settle(uint256 maxClaims) internal { - _q().settleFeesAndProcessQueue(maxClaims); + // Retry funding any already-closed-but-not-yet-funded epoch (e.g. a + // prior fundEpoch() attempt fell short on liquidity and more has + // since become available). + uint256 oldestUnfunded = _q().oldestUnfundedEpochId(); + if (oldestUnfunded < _q().currentEpochId()) { + _q().fundEpoch(oldestUnfunded); + } + + if (_q().currentEpochClaimCount() > 0) { + if (!_q().canCloseCurrentEpoch()) { + vm.warp(block.timestamp + 7 days + 1); + } + uint256 epochId = _q().currentEpochId(); + _q().closeCurrentEpoch(); + _q().fundEpoch(epochId); + } + + uint256 examined; + while (_settleCursor < _claims.length && examined < maxClaims) { + QueuedClaim storage c = _claims[_settleCursor]; + if (c.cancelled || c.settled) { + _settleCursor++; + continue; // ghosts don't consume the budget + } + vm.prank(c.user); + try IQueueVault(address(vault)).claimEpochAssets(c.epochId, c.claimId) { + c.settled = true; + _settleCursor++; + } catch { + // Not yet fundable (e.g. insufficient liquidity) -- leave the + // cursor here so a later _settle() call retries it, once + // more liquidity is available. Still counts toward this + // call's budget so a stuck claim can't loop forever. + } + examined++; + } + } + + function _pendingShares() internal view returns (uint256) { + return _q().totalEscrowedShares(); } function _totalAssets() internal view returns (uint256) { @@ -138,7 +221,7 @@ contract CoreEngine_Integration_Hardening is Test { uint256 claimShares = shares / 2; _requestClaim(user, claimShares, false); - uint256 pendingAfter = _q().pendingShares(); + uint256 pendingAfter = _q().totalEscrowedShares(); assertEq(pendingAfter, claimShares, "pendingShares tracked"); // The pending shares represent ~50% of assets — they must NOT be deployed @@ -188,8 +271,8 @@ contract CoreEngine_Integration_Hardening is Test { // pendingShares stays non-zero: settle is conservative, claim skipped due to insufficient hot // This is the correct behavior: never drain strategy to service queue (idle must be pre-ensured) - assertGt(_q().pendingShares(), 0, "claim skipped: hot < gross, pending shares stay"); - assertEq(_q().pendingShares(), shares, "full claim still pending"); + assertGt(_q().totalEscrowedShares(), 0, "claim skipped: hot < gross, pending shares stay"); + assertEq(_q().totalEscrowedShares(), shares, "full claim still pending"); } // C4 — Queue pressure change alters guard outcome @@ -206,14 +289,14 @@ contract CoreEngine_Integration_Hardening is Test { uint256 totalA = _totalAssets(); // Measure pressure with zero queue - uint256 pendingBefore = _q().pendingShares(); + uint256 pendingBefore = _q().totalEscrowedShares(); assertEq(pendingBefore, 0, "no pending initially"); // Both users queue claims → pressure rises _requestClaim(userA, sharesA, false); _requestClaim(userB, sharesB, false); - uint256 pendingAfter = _q().pendingShares(); + uint256 pendingAfter = _q().totalEscrowedShares(); assertEq(pendingAfter, sharesA + sharesB, "both shares pending"); // queuePressureBps = pendingShares * 10000 / (tvl + 1) @@ -248,17 +331,17 @@ contract CoreEngine_Integration_Hardening is Test { uint256 tsInitial = vault.totalSupply(); // Users 0,1 do immediate claim; users 2,3 queued claim; user 4 stays - vm.prank(users[0]); _q().requestClaim(true, sharesOf[0]); - vm.prank(users[1]); _q().requestClaim(true, sharesOf[1]); - vm.prank(users[2]); _q().requestClaim(false, sharesOf[2]); - vm.prank(users[3]); _q().requestClaim(false, sharesOf[3]); + _requestClaim(users[0], sharesOf[0], true); + _requestClaim(users[1], sharesOf[1], true); + _requestClaim(users[2], sharesOf[2], false); + _requestClaim(users[3], sharesOf[3], false); // Settle once uint256 taBefore = _totalAssets(); _settle(10); // After settle: pendingShares for settled claims must be 0 - uint256 pendingAfter = _q().pendingShares(); + uint256 pendingAfter = _q().totalEscrowedShares(); assertEq(pendingAfter, 0, "all queued claims settled"); // User 4 still holds shares — totalSupply reduced by settled shares @@ -388,16 +471,14 @@ contract CoreEngine_Integration_Hardening is Test { // Immediate claim uint256 balBeforeImm = IERC20(USDC).balanceOf(userImm); - vm.prank(userImm); - _q().requestClaim(true, sharesImm); + _requestClaim(userImm, sharesImm, true); _settle(10); uint256 balAfterImm = IERC20(USDC).balanceOf(userImm); uint256 netImm = balAfterImm - balBeforeImm; // Queued claim (same economic state) uint256 balBeforeQ = IERC20(USDC).balanceOf(userQ); - vm.prank(userQ); - _q().requestClaim(false, sharesQ); + _requestClaim(userQ, sharesQ, false); _settle(10); uint256 balAfterQ = IERC20(USDC).balanceOf(userQ); uint256 netQ = balAfterQ - balBeforeQ; @@ -506,7 +587,7 @@ contract CoreEngine_Integration_Hardening is Test { _requestClaim(userA, sharesA, false); _requestClaim(userB, sharesB, false); - uint256 pendingBefore = _q().pendingShares(); + uint256 pendingBefore = _q().totalEscrowedShares(); assertGt(pendingBefore, 0, "pending shares exist"); uint256 balABefore = IERC20(USDC).balanceOf(userA); @@ -532,7 +613,7 @@ contract CoreEngine_Integration_Hardening is Test { // pendingShares still > 0 if not fully served if (totalPaid < (sharesA + sharesB) * amt / vault.totalSupply() + totalPaid) { // some may remain pending - assertGe(_q().pendingShares() + (paidA > 0 ? sharesA : 0) + (paidB > 0 ? sharesB : 0), + assertGe(_q().totalEscrowedShares() + (paidA > 0 ? sharesA : 0) + (paidB > 0 ? sharesB : 0), pendingBefore * 9 / 10, "pending reduced by served amount"); } } @@ -561,11 +642,11 @@ contract CoreEngine_Integration_Hardening is Test { // Settle — failing strategy should not corrupt state // (settle uses idle; if stratFailing is not in path, no issue) - uint256 pendingBefore = _q().pendingShares(); + uint256 pendingBefore = _q().totalEscrowedShares(); _settle(10); // Queue state must be coherent (no overflow, no ghost) - uint256 pendingAfter = _q().pendingShares(); + uint256 pendingAfter = _q().totalEscrowedShares(); assertLe(pendingAfter, pendingBefore, "pendingShares only decreases on settle"); // totalAssets must be >= 0 and coherent @@ -600,7 +681,7 @@ contract CoreEngine_Integration_Hardening is Test { _requestClaim(user, shares, false); _settle(10); - assertEq(_q().pendingShares(), 0, "exit processed despite deposits paused"); + assertEq(_q().totalEscrowedShares(), 0, "exit processed despite deposits paused"); assertGt(IERC20(USDC).balanceOf(user), 0, "user received funds on exit"); } @@ -621,7 +702,7 @@ contract CoreEngine_Integration_Hardening is Test { uint256 claimId = _requestClaim(userA, sharesA, false); _cancelClaim(userA, claimId); - assertEq(_q().pendingShares(), 0, "pendingShares = 0 after cancel"); + assertEq(_q().totalEscrowedShares(), 0, "pendingShares = 0 after cancel"); // userB queues and settles _requestClaim(userB, sharesB, false); @@ -632,7 +713,7 @@ contract CoreEngine_Integration_Hardening is Test { // userB must have received their assets — cancel of A did not block queue assertGt(balBAfter - balBBefore, 0, "userB settle succeeded despite A cancel ghost"); - assertEq(_q().pendingShares(), 0, "queue fully cleared"); + assertEq(_q().totalEscrowedShares(), 0, "queue fully cleared"); } // G2 — Settle with only ghost (cancelled) entries does not corrupt queue metrics @@ -645,13 +726,13 @@ contract CoreEngine_Integration_Hardening is Test { uint256 claimId = _requestClaim(user, shares / 2, false); _cancelClaim(user, claimId); - uint256 pendingBefore = _q().pendingShares(); - uint256 qLenBefore = _q().queueLength(); + uint256 pendingBefore = _q().totalEscrowedShares(); + uint256 qLenBefore = _q().outstandingClaimCount(); // Settle with only ghost entries _settle(10); - uint256 pendingAfter = _q().pendingShares(); + uint256 pendingAfter = _q().totalEscrowedShares(); uint256 taAfter = _totalAssets(); // Metrics must not be corrupted @@ -708,7 +789,7 @@ contract CoreEngine_Integration_Hardening is Test { // Queue half the shares _requestClaim(user, shares / 2, false); - uint256 pending = _q().pendingShares(); + uint256 pending = _q().totalEscrowedShares(); uint256 ts = vault.totalSupply(); uint256 ta = _totalAssets(); @@ -871,7 +952,7 @@ contract CoreEngine_Integration_Hardening is Test { // User claims full amount — hot(5k) < gross(100k) → skipped _requestClaim(user, shares, false); _settle(10); - assertGt(_q().pendingShares(), 0, "E4: claim skipped due to shortfall"); + assertGt(_q().totalEscrowedShares(), 0, "E4: claim skipped due to shortfall"); // Strategy returns funds (simulate rebalance/rebalance) uint256 stratBal = IERC20(USDC).balanceOf(address(stratA)); @@ -880,7 +961,7 @@ contract CoreEngine_Integration_Hardening is Test { // Now idle covers the claim — settle again _settle(10); - assertEq(_q().pendingShares(), 0, "E4: claim settled after buffer refill"); + assertEq(_q().totalEscrowedShares(), 0, "E4: claim settled after buffer refill"); uint256 net = IERC20(USDC).balanceOf(user); assertGt(net, 0, "E4: user received assets after refill settlement"); @@ -948,7 +1029,7 @@ contract CoreEngine_Integration_Hardening is Test { // Queue claim during pause — requestClaim is a queue operation, not immediate withdrawal uint256 userShares = vault.balanceOf(user); _requestClaim(user, userShares, false); - assertGt(_q().pendingShares(), 0, "F2: claim queued while withdrawals paused"); + assertGt(_q().totalEscrowedShares(), 0, "F2: claim queued while withdrawals paused"); // Settlement is the withdrawal — should be blocked or skipped // (pauseWithdrawalsOnly blocks the settle path) diff --git a/test/integration/DeploymentEquivalence.t.sol b/test/integration/DeploymentEquivalence.t.sol index 65355dc..4c6984b 100644 --- a/test/integration/DeploymentEquivalence.t.sol +++ b/test/integration/DeploymentEquivalence.t.sol @@ -7,7 +7,7 @@ import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol" // Core import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { BufferManager } from "../../src/core/modules/BufferManager.sol"; import { StrategyRouter } from "../../src/core/modules/StrategyRouter.sol"; @@ -76,7 +76,7 @@ contract DeploymentEquivalence_Test is Test { struct FullDeployResult { CoreVault vault; - QueueModule queueModule; + EpochedQueueModule queueModule; AdminModule adminModule; BufferManager bufferManager; StrategyRouter strategyRouter; @@ -156,7 +156,7 @@ contract DeploymentEquivalence_Test is Test { // Set SelectorRegistry BEFORE any routing result.vault.setSelectorRegistry(address(result.selectorRegistry)); - result.queueModule = new QueueModule(); + result.queueModule = new EpochedQueueModule(); result.adminModule = new AdminModule(); // Phase 4: Ecosystem @@ -296,7 +296,7 @@ contract DeploymentEquivalence_Test is Test { // Set SelectorRegistry BEFORE any routing result.vault.setSelectorRegistry(address(result.selectorRegistry)); - result.queueModule = new QueueModule(); + result.queueModule = new EpochedQueueModule(); result.adminModule = new AdminModule(); // === DeployCoreSystem Phase 4: Ecosystem Base === diff --git a/test/integration/VaultFactory_Integration.t.sol b/test/integration/VaultFactory_Integration.t.sol index 6be7499..d0e2fdf 100644 --- a/test/integration/VaultFactory_Integration.t.sol +++ b/test/integration/VaultFactory_Integration.t.sol @@ -8,7 +8,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { VaultFactory } from "../../src/factory/VaultFactory.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "../../src/core/modules/LiquidityOpsModule.sol"; @@ -106,7 +106,7 @@ contract MockStrategyRouter { /// @notice Integration tests for VaultFactory deployment flow (off-chain deploy, on-chain register) contract VaultFactory_Integration is Test { VaultFactory public factory; - QueueModule public sharedQueue; + EpochedQueueModule public sharedQueue; AdminModule public sharedAdmin; ERC4626Module public sharedERC4626; LiquidityOpsModule public sharedLiquidityOps; @@ -120,7 +120,7 @@ contract VaultFactory_Integration is Test { function setUp() public { factory = new VaultFactory(); - sharedQueue = new QueueModule(); + sharedQueue = new EpochedQueueModule(); sharedAdmin = new AdminModule(); sharedERC4626 = new ERC4626Module(); sharedLiquidityOps = new LiquidityOpsModule(); @@ -247,7 +247,7 @@ contract VaultFactory_Integration is Test { ); vm.prank(alice); - IQueueModule(address(vault)).requestClaim(true, depositAmount); + IQueueModule(address(vault)).requestInstantWithdrawal(depositAmount); assertEq(usdc.balanceOf(alice), 1_000_000e6, "Alice should get assets back"); } @@ -445,7 +445,7 @@ contract VaultFactory_Integration is Test { assertEq( address(result1.queueModule), address(result2.queueModule), - "QueueModule should be shared" + "EpochedQueueModule should be shared" ); assertEq( address(result1.adminModule), diff --git a/test/invariants/CoreVault_Adversarial_Invariants.t.sol b/test/invariants/CoreVault_Adversarial_Invariants.t.sol index 1fa507a..22427e3 100644 --- a/test/invariants/CoreVault_Adversarial_Invariants.t.sol +++ b/test/invariants/CoreVault_Adversarial_Invariants.t.sol @@ -9,7 +9,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { ERC20Mock } from "../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../helpers/MockParamsProvider.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../src/interfaces/IQueueModule.sol"; @@ -27,7 +27,7 @@ contract CoreVault_Adversarial_Invariants is StdInvariant, Test { MockParamsProvider public params; ERC20Mock public usdc; AdversarialHandler public handler; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; address public owner = address(0xA11CE); @@ -44,7 +44,7 @@ contract CoreVault_Adversarial_Invariants is StdInvariant, Test { feeCollector = new FeeCollector(owner, treasury, opsSafe, safetyReserve, 7000, 200, 3000); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy vault with 6-param constructor @@ -79,23 +79,26 @@ contract CoreVault_Adversarial_Invariants is StdInvariant, Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() ); + vault.setModule(EpochedQueueModule.currentEpochId.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.currentEpochClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -110,14 +113,14 @@ contract CoreVault_Adversarial_Invariants is StdInvariant, Test { } /** - * @notice CRITICAL INVARIANT: Escrowed shares MUST equal pendingShares + * @notice CRITICAL INVARIANT: Escrowed shares MUST equal totalEscrowedShares * @dev This was the bug found in the original test */ function invariant_escrowedShares_equalsPendingShares() public view { uint256 vaultShares = vault.balanceOf(address(vault)); - uint256 pending = IQueueModule(address(vault)).pendingShares(); + uint256 pending = IQueueModule(address(vault)).totalEscrowedShares(); - assertEq(vaultShares, pending, "ESCROW: Vault shares must equal pendingShares"); + assertEq(vaultShares, pending, "ESCROW: Vault shares must equal totalEscrowedShares"); } /** @@ -181,10 +184,10 @@ contract CoreVault_Adversarial_Invariants is StdInvariant, Test { * @dev This would indicate a double-counting bug */ function invariant_pendingShares_bounded() public view { - uint256 pending = IQueueModule(address(vault)).pendingShares(); + uint256 pending = IQueueModule(address(vault)).totalEscrowedShares(); uint256 supply = vault.totalSupply(); - assertLe(pending, supply, "PENDING: pendingShares cannot exceed totalSupply"); + assertLe(pending, supply, "PENDING: totalEscrowedShares cannot exceed totalSupply"); } function invariant_callSummary() public view { @@ -257,7 +260,7 @@ contract AdversarialHandler is Test { uint256 grossAssets = vault.convertToAssets(withdrawShares); uint256 balBefore = usdc.balanceOf(actor); - try IQueueModule(address(vault)).requestClaim(true, withdrawShares) { + try IQueueModule(address(vault)).requestInstantWithdrawal(withdrawShares) { uint256 balAfter = usdc.balanceOf(actor); // Only track if immediate settlement happened (user received assets) // If claim went to queue (insufficient liquidity/cap), track nothing here - @@ -288,16 +291,24 @@ contract AdversarialHandler is Test { usdc._mint(address(vault), yieldAmount); ghost_totalYield += yieldAmount; - // Crystallize and process queue - // Track assets leaving vault during queue processing - uint256 vaultBalBefore = usdc.balanceOf(address(vault)); - vm.warp(block.timestamp + 2 hours); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); - uint256 vaultBalAfter = usdc.balanceOf(address(vault)); - - // If vault balance decreased, assets were withdrawn from queue - if (vaultBalBefore > vaultBalAfter) { - ghost_totalWithdrawn += (vaultBalBefore - vaultBalAfter); + // Crystallize fees, and close/fund the current epoch if eligible so the + // epoch-close + fundEpoch() liquidity-pull code path gets exercised too. + // Unlike QueueModule.settleFeesAndProcessQueue, fundEpoch() only pulls + // liquidity into hot -- it doesn't pay users (that's the pull-based + // claimEpochAssets(), out of scope for this handler) -- so it never + // reduces vault balance, and there's nothing to add to + // ghost_totalWithdrawn here. + vm.warp(block.timestamp + 7 days + 1); + try IQueueModule(address(vault)).endEpochCrystallize() { } catch { } + + if ( + IQueueModule(address(vault)).canCloseCurrentEpoch() + && IQueueModule(address(vault)).currentEpochClaimCount() > 0 + ) { + uint256 epochId = IQueueModule(address(vault)).currentEpochId(); + try IQueueModule(address(vault)).closeCurrentEpoch() { + try IQueueModule(address(vault)).fundEpoch(epochId) { } catch { } + } catch { } } calls_yield++; diff --git a/test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol b/test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol index 65dd887..d529e87 100644 --- a/test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol +++ b/test/invariants/CoreVault_ClaimsQueue_Invariants.t.sol @@ -9,7 +9,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { ERC20Mock } from "../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../helpers/MockParamsProvider.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../src/interfaces/IQueueModule.sol"; @@ -34,7 +34,7 @@ contract CoreVault_ClaimsQueue_Invariants is StdInvariant, Test { MockParamsProvider public params; ERC20Mock public usdc; ClaimQueueHandler public handler; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; /* ========== ADDRESSES ========== */ @@ -64,7 +64,7 @@ contract CoreVault_ClaimsQueue_Invariants is StdInvariant, Test { ); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreVault with 6-param constructor @@ -103,23 +103,31 @@ contract CoreVault_ClaimsQueue_Invariants is StdInvariant, Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() ); + vault.setModule(EpochedQueueModule.currentEpochId.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule( + EpochedQueueModule.reservedForClaims.selector, address(queueModule), vault.ROLE_PUBLIC() + ); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.currentEpochClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -139,7 +147,7 @@ contract CoreVault_ClaimsQueue_Invariants is StdInvariant, Test { * @notice Queue length should match handler's tracked open claims */ function invariant_queueLength() public view { - uint256 vaultQueueLen = IQueueModule(address(vault)).queueLength(); + uint256 vaultQueueLen = IQueueModule(address(vault)).outstandingClaimCount(); uint256 handlerQueueLen = handler.ghost_openClaims(); // Handler tracks claims it created that haven't been processed @@ -213,6 +221,24 @@ contract CoreVault_ClaimsQueue_Invariants is StdInvariant, Test { /** * @notice Vault should remain solvent through all queue operations */ + /** + * @notice The reservation must always be backed. Every consumer of the hot + * balance is supposed to treat `hot - reservedForClaims` as the only + * spendable amount, so the vault can never hold less than it has + * already promised to FUNDED-but-unclaimed claimants. This is the + * property the split close/fund/claim handlers exist to stress: it + * is only interesting once the fuzzer can reach a backlog. + */ + function invariant_reservationIsAlwaysBacked() public view { + uint256 reserved = IQueueModule(address(vault)).reservedForClaims(); + if (reserved == 0) return; + assertGe( + usdc.balanceOf(address(vault)), + reserved, + "RESERVE: hot balance must cover every funded-but-unclaimed claim" + ); + } + function invariant_vaultSolvency() public view { uint256 totalAssets = vault.totalAssets(); uint256 totalSupply = vault.totalSupply(); @@ -276,12 +302,18 @@ contract ClaimQueueHandler is Test { uint256 public calls_immediateClaim; uint256 public calls_cancelClaim; uint256 public calls_processQueue; + uint256 public calls_closeEpoch; + uint256 public calls_fundEpoch; uint256 public calls_yield; - // Claim tracking + // Claim tracking -- handler-side sequential IDs (the epoch model's own + // claimId is only unique WITHIN an epoch, not globally, so the handler + // keeps its own flat ID space and maps it to the real (epochId, claimId)). mapping(uint256 => address) public claimOwners; mapping(uint256 => bool) public claimSettled; mapping(uint256 => bool) public claimCancelled; + mapping(uint256 => uint256) public claimVaultEpochId; + mapping(uint256 => uint256) public claimVaultClaimId; uint256 public nextClaimId; // Actor tracking @@ -331,8 +363,12 @@ contract ClaimQueueHandler is Test { uint256 claimShares = (shares * sharePct) / 100; if (claimShares == 0) claimShares = 1; - try queueModule.requestClaim(false, claimShares) { + try queueModule.requestEpochWithdrawal(claimShares) returns ( + uint256 epochId, uint256 vaultClaimId + ) { claimOwners[nextClaimId] = actor; + claimVaultEpochId[nextClaimId] = epochId; + claimVaultClaimId[nextClaimId] = vaultClaimId; actorClaims[actor].push(nextClaimId); ghost_totalClaimsCreated++; ghost_openClaims++; @@ -352,10 +388,23 @@ contract ClaimQueueHandler is Test { uint256 balBefore = usdc.balanceOf(actor); - try queueModule.requestClaim(true, claimShares) { - // Immediate claims are processed inline - uint256 balAfter = usdc.balanceOf(actor); - ghost_totalWithdrawn += (balAfter - balBefore); + try queueModule.requestInstantWithdrawal(claimShares) returns ( + bool settledImmediately, uint256 epochId, uint256 vaultClaimId + ) { + if (settledImmediately) { + // Immediate claims are processed inline + uint256 balAfter = usdc.balanceOf(actor); + ghost_totalWithdrawn += (balAfter - balBefore); + } else { + // Cap-exhausted fallback -- became a standard epoch claim + claimOwners[nextClaimId] = actor; + claimVaultEpochId[nextClaimId] = epochId; + claimVaultClaimId[nextClaimId] = vaultClaimId; + actorClaims[actor].push(nextClaimId); + ghost_totalClaimsCreated++; + ghost_openClaims++; + nextClaimId++; + } calls_immediateClaim++; } catch { } } @@ -371,7 +420,7 @@ contract ClaimQueueHandler is Test { if (claimSettled[claimId] || claimCancelled[claimId]) return; - try queueModule.cancelClaim(claimId) { + try queueModule.cancelEpochWithdrawal(claimVaultEpochId[claimId], claimVaultClaimId[claimId]) { claimCancelled[claimId] = true; ghost_cancelledClaims++; if (ghost_openClaims > 0) ghost_openClaims--; @@ -397,7 +446,7 @@ contract ClaimQueueHandler is Test { ghost_unauthorizedCancelAttempts++; - try queueModule.cancelClaim(claimId) { + try queueModule.cancelEpochWithdrawal(claimVaultEpochId[claimId], claimVaultClaimId[claimId]) { // Should not succeed - this would be a bug } catch { @@ -405,23 +454,70 @@ contract ClaimQueueHandler is Test { } } - function processQueue(uint256 maxClaims) public { - maxClaims = bound(maxClaims, 1, 25); + // Close, fund and claim are SEPARATE handler actions on purpose. Fusing + // them into one call meant the fuzzer could never reach a state with an + // epoch closed-but-unfunded while a later epoch also closed -- which is + // exactly the state space the reservation bugs lived in. - // Warp to ensure claims can be settled + /// @notice Close the open epoch. Does not fund it and does not settle it. + function closeEpoch() public { vm.warp(block.timestamp + 7 days); + if (!queueModule.canCloseCurrentEpoch() || queueModule.currentEpochClaimCount() == 0) { + return; + } + try queueModule.closeCurrentEpoch() { + calls_closeEpoch++; + } catch { } + } + + /// @notice Attempt to fund an ARBITRARY epoch, not necessarily the oldest, + /// so out-of-order funding and repeated failed attempts are both + /// reachable. + function fundSomeEpoch(uint256 epochSeed) public { + uint256 current = queueModule.currentEpochId(); + if (current == 0) return; + uint256 target = bound(epochSeed, 0, current - 1); + try queueModule.fundEpoch(target) { + calls_fundEpoch++; + } catch { } + } - uint256 queueLenBefore = queueModule.queueLength(); + /// @notice Settle up to `maxClaims` tracked claims that are actually + /// claimable, across ANY epoch. + function claimReady(uint256 maxClaims) public { + maxClaims = bound(maxClaims, 1, 25); + uint256 processed; + for (uint256 i = 0; i < nextClaimId && processed < maxClaims; i++) { + if (claimSettled[i] || claimCancelled[i] || claimOwners[i] == address(0)) continue; + vm.prank(claimOwners[i]); + try queueModule.claimEpochAssets(claimVaultEpochId[i], claimVaultClaimId[i]) { + claimSettled[i] = true; + processed++; + if (ghost_openClaims > 0) ghost_openClaims--; + } catch { } + } + ghost_processedClaims += processed; + if (processed > 0) calls_processQueue++; + } - try queueModule.settleFeesAndProcessQueue(maxClaims) { - uint256 queueLenAfter = queueModule.queueLength(); - uint256 processed = queueLenBefore - queueLenAfter; - ghost_processedClaims += processed; - if (ghost_openClaims >= processed) { - ghost_openClaims -= processed; + /// @dev Shared close-side helper: funds `epochId` and self-claims (pull-based) + /// up to `maxClaims` of the tracked open claims that landed in it. + function _fundAndSettleEpoch(uint256 epochId, uint256 maxClaims) internal returns (uint256 processed) { + try queueModule.fundEpoch(epochId) { } catch { } + + for (uint256 i = 0; i < nextClaimId && processed < maxClaims; i++) { + if ( + claimVaultEpochId[i] == epochId && !claimSettled[i] && !claimCancelled[i] + && claimOwners[i] != address(0) + ) { + vm.prank(claimOwners[i]); + try queueModule.claimEpochAssets(epochId, claimVaultClaimId[i]) { + claimSettled[i] = true; + processed++; + if (ghost_openClaims > 0) ghost_openClaims--; + } catch { } } - calls_processQueue++; - } catch { } + } } function simulateYield(uint256 yieldAmount) public { @@ -451,24 +547,18 @@ contract ClaimQueueHandler is Test { usdc._mint(address(vault), yieldAmount); ghost_totalYield += yieldAmount; - // Crystallize to update share price + // Crystallize to update share price -- independent of queue settlement + // in the epoch model (see EpochedQueueModule.endEpochCrystallize). vm.warp(block.timestamp + 2 hours); - - // Track queue length before processing to update ghost_openClaims - uint256 queueLenBefore = queueModule.queueLength(); - - queueModule.settleFeesAndProcessQueue(1); - - // Update ghost state if claims were processed - uint256 queueLenAfter = queueModule.queueLength(); - uint256 processed = queueLenBefore - queueLenAfter; - if (processed > 0) { - ghost_processedClaims += processed; - if (ghost_openClaims >= processed) { - ghost_openClaims -= processed; - } else { - ghost_openClaims = 0; - } + try queueModule.endEpochCrystallize() { } catch { } + + // Close + fund + settle 1 claim from the current epoch, if eligible. + if (queueModule.canCloseCurrentEpoch() && queueModule.currentEpochClaimCount() > 0) { + uint256 epochId = queueModule.currentEpochId(); + try queueModule.closeCurrentEpoch() { + uint256 processed = _fundAndSettleEpoch(epochId, 1); + ghost_processedClaims += processed; + } catch { } } calls_yield++; diff --git a/test/invariants/CoreVault_System_Invariants.t.sol b/test/invariants/CoreVault_System_Invariants.t.sol index 81db63f..f9561ff 100644 --- a/test/invariants/CoreVault_System_Invariants.t.sol +++ b/test/invariants/CoreVault_System_Invariants.t.sol @@ -9,7 +9,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { ERC20Mock } from "../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../helpers/MockParamsProvider.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../src/interfaces/IQueueModule.sol"; @@ -33,7 +33,7 @@ contract CoreVault_System_Invariants is StdInvariant, Test { MockParamsProvider public params; ERC20Mock public usdc; VaultHandler public handler; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; /* ========== ADDRESSES ========== */ @@ -63,7 +63,7 @@ contract CoreVault_System_Invariants is StdInvariant, Test { ); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreVault with 6-param constructor @@ -102,23 +102,31 @@ contract CoreVault_System_Invariants is StdInvariant, Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() ); + vault.setModule(EpochedQueueModule.currentEpochId.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule( + EpochedQueueModule.reservedForClaims.selector, address(queueModule), vault.ROLE_PUBLIC() + ); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.currentEpochClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -277,13 +285,31 @@ contract CoreVault_System_Invariants is StdInvariant, Test { * @notice Queue state must be consistent * @dev O(1) - reads pendingShares and vault balance */ + /** + * @notice The reservation must always be backed. Every consumer of the hot + * balance is supposed to treat `hot - reservedForClaims` as the only + * spendable amount, so the vault can never hold less than it has + * already promised to FUNDED-but-unclaimed claimants. This is the + * property the split close/fund/claim handlers exist to stress: it + * is only interesting once the fuzzer can reach a backlog. + */ + function invariant_reservationIsAlwaysBacked() public view { + uint256 reserved = IQueueModule(address(vault)).reservedForClaims(); + if (reserved == 0) return; + assertGe( + usdc.balanceOf(address(vault)), + reserved, + "RESERVE: hot balance must cover every funded-but-unclaimed claim" + ); + } + function invariant_queue_integrity() public view { - uint256 pendingShares = IQueueModule(address(vault)).pendingShares(); + uint256 pendingShares = IQueueModule(address(vault)).totalEscrowedShares(); if (pendingShares > 0) { uint256 vaultOwnShares = vault.balanceOf(address(vault)); assertEq( - vaultOwnShares, pendingShares, "QUEUE: Escrowed shares must equal pendingShares" + vaultOwnShares, pendingShares, "QUEUE: Escrowed shares must equal totalEscrowedShares" ); } } @@ -395,6 +421,14 @@ contract VaultHandler is Test { address[10] public actors; mapping(address => uint256) public actorClaimCount; + /* ========== EPOCH CLAIM TRACKING ========== */ + struct PendingClaim { + address actor; + uint256 epochId; + uint256 claimId; + } + PendingClaim[] public pendingClaims; + modifier useActor(uint256 seed) { address actor = actors[seed % MAX_ACTORS]; vm.startPrank(actor); @@ -545,7 +579,7 @@ contract VaultHandler is Test { } // STEP 4: Cap queue size - uint256 queueLen = IQueueModule(address(vault)).queueLength(); + uint256 queueLen = IQueueModule(address(vault)).outstandingClaimCount(); if (queueLen >= MAX_QUEUE_SIZE) { reverts_requestClaim++; return; @@ -558,37 +592,68 @@ contract VaultHandler is Test { uint256 grossAssets = vault.convertToAssets(shares); uint256 balBefore = usdc.balanceOf(actor); - try IQueueModule(address(vault)).requestClaim(immediate, shares) { - uint256 balAfter = usdc.balanceOf(actor); + if (immediate) { + try IQueueModule(address(vault)).requestInstantWithdrawal(shares) returns ( + bool settledImmediately, uint256 epochId, uint256 claimId + ) { + uint256 balAfter = usdc.balanceOf(actor); - if (balAfter > balBefore) { - ghost_totalWithdrawn += grossAssets; - } else { + if (settledImmediately && balAfter > balBefore) { + ghost_totalWithdrawn += grossAssets; + } else { + pendingClaims.push(PendingClaim(actor, epochId, claimId)); + ghost_pendingClaims++; + actorClaimCount[actor]++; + } + calls_requestClaim++; + } catch { + reverts_requestClaim++; + } + } else { + try IQueueModule(address(vault)).requestEpochWithdrawal(shares) returns ( + uint256 epochId, uint256 claimId + ) { + pendingClaims.push(PendingClaim(actor, epochId, claimId)); ghost_pendingClaims++; actorClaimCount[actor]++; + calls_requestClaim++; + } catch { + reverts_requestClaim++; } - calls_requestClaim++; - } catch { - reverts_requestClaim++; } } /* ========== HANDLER: CANCEL_CLAIM ========== */ - function cancelClaim(uint256 actorSeed, uint256 claimId) public useActor(actorSeed) { + function cancelClaim(uint256 actorSeed, uint256 claimIdx) public useActor(actorSeed) { address actor = actors[actorSeed % MAX_ACTORS]; // STEP 3: Guard clause - only cancel if actor has claims - if (actorClaimCount[actor] == 0) { + if (actorClaimCount[actor] == 0 || pendingClaims.length == 0) { reverts_cancelClaim++; return; } - claimId = bound(claimId, 0, 100); + // Find one of this actor's pending claims (fuzzed index selects among them) + uint256 found = type(uint256).max; + uint256 startIdx = bound(claimIdx, 0, pendingClaims.length - 1); + for (uint256 i = 0; i < pendingClaims.length; i++) { + uint256 idx = (startIdx + i) % pendingClaims.length; + if (pendingClaims[idx].actor == actor) { + found = idx; + break; + } + } + if (found == type(uint256).max) { + reverts_cancelClaim++; + return; + } - try IQueueModule(address(vault)).cancelClaim(claimId) { + PendingClaim memory pc = pendingClaims[found]; + try IQueueModule(address(vault)).cancelEpochWithdrawal(pc.epochId, pc.claimId) { if (ghost_pendingClaims > 0) ghost_pendingClaims--; if (actorClaimCount[actor] > 0) actorClaimCount[actor]--; + _removePendingClaim(found); calls_cancelClaim++; } catch { reverts_cancelClaim++; @@ -597,9 +662,77 @@ contract VaultHandler is Test { /* ========== HANDLER: SETTLE_QUEUE ========== */ + // Close, fund and claim are SEPARATE handler actions on purpose. Fusing + // them into one settleQueue call meant the fuzzer never reached a state + // with an epoch closed-but-unfunded while a later epoch also closed, which + // is the state space the reservation bugs lived in. + + /// @notice Close the open epoch and stop there. + function closeEpoch() public { + vm.warp(block.timestamp + 7 days); + if ( + !IQueueModule(address(vault)).canCloseCurrentEpoch() + || IQueueModule(address(vault)).currentEpochClaimCount() == 0 + ) { + reverts_settle++; + return; + } + try IQueueModule(address(vault)).closeCurrentEpoch() { + calls_settle++; + } catch { + reverts_settle++; + } + } + + /// @notice Attempt to fund an arbitrary epoch, so out-of-order funding and + /// repeated failed attempts are both reachable. + function fundSomeEpoch(uint256 epochSeed) public { + uint256 current = IQueueModule(address(vault)).currentEpochId(); + if (current == 0) { + reverts_settle++; + return; + } + uint256 target = bound(epochSeed, 0, current - 1); + try IQueueModule(address(vault)).fundEpoch(target) { + calls_settle++; + } catch { + reverts_settle++; + } + } + + /// @notice Settle up to `maxClaims` tracked claims that are claimable, in + /// any epoch, rather than only the one just closed. + function claimReady(uint256 maxClaims) public { + maxClaims = bound(maxClaims, 1, 20); + + uint256 processed = 0; + uint256 i = 0; + while (i < pendingClaims.length && processed < maxClaims) { + PendingClaim memory pc = pendingClaims[i]; + vm.prank(pc.actor); + try IQueueModule(address(vault)).claimEpochAssets(pc.epochId, pc.claimId) { + processed++; + if (ghost_pendingClaims > 0) ghost_pendingClaims--; + if (actorClaimCount[pc.actor] > 0) actorClaimCount[pc.actor]--; + _removePendingClaim(i); + continue; // don't advance i -- swap-removed a new element into place + } catch { } + i++; + } + ghost_processedClaims += processed; + if (processed > 0) calls_settle++; + } + + function _removePendingClaim(uint256 idx) internal { + pendingClaims[idx] = pendingClaims[pendingClaims.length - 1]; + pendingClaims.pop(); + } + + /* ========== HANDLER: SETTLE_QUEUE ========== */ + function settleQueue(uint256 maxClaims) public { // STEP 3: Guard clause - skip if queue empty - uint256 queueLen = IQueueModule(address(vault)).queueLength(); + uint256 queueLen = IQueueModule(address(vault)).outstandingClaimCount(); if (queueLen == 0) { reverts_settle++; return; @@ -610,15 +743,38 @@ contract VaultHandler is Test { vm.warp(block.timestamp + 7 days); - uint256 queueLenBefore = queueLen; + // Close + fund whatever epoch is currently eligible, then self-claim + // (pull-based) up to maxClaims of the tracked pending claims that now + // belong to a FUNDED epoch. + if ( + !IQueueModule(address(vault)).canCloseCurrentEpoch() + || IQueueModule(address(vault)).currentEpochClaimCount() == 0 + ) { + reverts_settle++; + return; + } - try IQueueModule(address(vault)).settleFeesAndProcessQueue(maxClaims) { - uint256 queueLenAfter = IQueueModule(address(vault)).queueLength(); - uint256 processed = queueLenBefore > queueLenAfter ? queueLenBefore - queueLenAfter : 0; - ghost_processedClaims += processed; - if (ghost_pendingClaims >= processed) { - ghost_pendingClaims -= processed; + uint256 epochId = IQueueModule(address(vault)).currentEpochId(); + try IQueueModule(address(vault)).closeCurrentEpoch() { + try IQueueModule(address(vault)).fundEpoch(epochId) { } catch { } + + uint256 processed = 0; + uint256 i = 0; + while (i < pendingClaims.length && processed < maxClaims) { + if (pendingClaims[i].epochId == epochId) { + PendingClaim memory pc = pendingClaims[i]; + vm.prank(pc.actor); + try IQueueModule(address(vault)).claimEpochAssets(pc.epochId, pc.claimId) { + processed++; + if (ghost_pendingClaims > 0) ghost_pendingClaims--; + if (actorClaimCount[pc.actor] > 0) actorClaimCount[pc.actor]--; + _removePendingClaim(i); + continue; // don't advance i -- swap-removed a new element into place + } catch { } + } + i++; } + ghost_processedClaims += processed; calls_settle++; } catch { reverts_settle++; @@ -778,9 +934,10 @@ contract VaultHandler is Test { vm.warp(block.timestamp + 2 hours); - uint256 queueLenBefore = IQueueModule(address(vault)).queueLength(); - - try IQueueModule(address(vault)).settleFeesAndProcessQueue(1) { + // Crystallization is independent of queue settlement in the epoch model + // (see EpochedQueueModule.endEpochCrystallize) -- no queue-length delta + // to track here anymore. + try IQueueModule(address(vault)).endEpochCrystallize() { uint256 fcSharesAfter = vault.balanceOf(address(feeCollector)); uint256 perfFeeSharesMinted = fcSharesAfter > fcSharesBefore ? fcSharesAfter - fcSharesBefore : 0; @@ -791,17 +948,6 @@ contract VaultHandler is Test { ghost_totalFees += perfFeeAssets; ghost_feesCollected += perfFeeAssets; } - - uint256 queueLenAfter = IQueueModule(address(vault)).queueLength(); - uint256 processed = queueLenBefore > queueLenAfter ? queueLenBefore - queueLenAfter : 0; - if (processed > 0) { - ghost_processedClaims += processed; - if (ghost_pendingClaims >= processed) { - ghost_pendingClaims -= processed; - } else { - ghost_pendingClaims = 0; - } - } } catch { } } diff --git a/test/invariants/Governance_Seal_Invariants.t.sol b/test/invariants/Governance_Seal_Invariants.t.sol index c848abc..5ce57f6 100644 --- a/test/invariants/Governance_Seal_Invariants.t.sol +++ b/test/invariants/Governance_Seal_Invariants.t.sol @@ -7,7 +7,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I // Core import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { FeeCollector } from "../../src/core/modules/FeeCollector.sol"; import { BufferManager } from "../../src/core/modules/BufferManager.sol"; @@ -53,7 +53,7 @@ contract Governance_Seal_Invariants is StdInvariant, Test { GlobalConfig public globalConfig; SelectorRegistry public selectorRegistry; SystemSealer public systemSealer; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; BufferManager public bufferManager; StrategyRouter public strategyRouter; @@ -113,7 +113,7 @@ contract Governance_Seal_Invariants is StdInvariant, Test { systemSealer = new SystemSealer(); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreVault with deployer as initial owner @@ -190,27 +190,29 @@ contract Governance_Seal_Invariants is StdInvariant, Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() - ); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule(QueueModule.nextClaimId.selector, address(queueModule), vault.ROLE_PUBLIC()); - vault.setModule( - QueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.endEpochCrystallize.selector, address(queueModule), vault.ROLE_PUBLIC() ); + vault.setModule(EpochedQueueModule.currentEpochId.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.epochData.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.epochClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.nextClaimIdForEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.oldestUnfundedEpochId.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.epochDeficit.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.canCloseCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.currentEpochClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule owner selectors (OWNER) - subset for testing vault.setModule( diff --git a/test/invariants/RolesTimelock.invariants.t.sol b/test/invariants/RolesTimelock.invariants.t.sol index 2110c00..bac48f8 100644 --- a/test/invariants/RolesTimelock.invariants.t.sol +++ b/test/invariants/RolesTimelock.invariants.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { IAdminModule } from "../../src/interfaces/IAdminModule.sol"; import { StrategyRouter } from "../../src/core/modules/StrategyRouter.sol"; import { BufferManager } from "../../src/core/modules/BufferManager.sol"; @@ -55,7 +55,7 @@ contract RolesTimelockInvariants is Test { // ═══════════════════════════════════════════════════════════════════════════════ CoreVault vault; AdminModule adminModule; - QueueModule queueModule; + EpochedQueueModule queueModule; StrategyRouter router; BufferManager buffer; StrategyHealthRegistry healthReg; @@ -119,7 +119,7 @@ contract RolesTimelockInvariants is Test { // Deploy modules adminModule = new AdminModule(); - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); // Deploy components IBufferManager.BufferConfig memory bufferConfig = IBufferManager.BufferConfig({ @@ -180,7 +180,7 @@ contract RolesTimelockInvariants is Test { bytes4[] memory adminOwnerSels = SelectorLib.getAdminModuleOwnerSelectors(); bytes4[] memory adminViewSels = SelectorLib.getAdminModuleViewSelectors(); - // Wire QueueModule (PUBLIC) + // Wire EpochedQueueModule (PUBLIC) address[] memory queueModules = new address[](queueSels.length); uint8[] memory queueRoles = new uint8[](queueSels.length); for (uint256 i = 0; i < queueSels.length; i++) { @@ -189,7 +189,7 @@ contract RolesTimelockInvariants is Test { } vault.setModulesBatch(queueSels, queueModules, queueRoles); - // Wire QueueModule views (PUBLIC) + // Wire EpochedQueueModule views (PUBLIC) address[] memory queueViewModules = new address[](queueViewSels.length); uint8[] memory queueViewRoles = new uint8[](queueViewSels.length); for (uint256 i = 0; i < queueViewSels.length; i++) { @@ -276,12 +276,12 @@ contract RolesTimelockInvariants is Test { } } - /// @notice QueueModule selectors must be PUBLIC + /// @notice EpochedQueueModule selectors must be PUBLIC function test_queueSelectorsArePublic() public view { bytes4[] memory selectors = SelectorLib.getQueueModuleSelectors(); for (uint256 i = 0; i < selectors.length; i++) { - assertEq(vault.roleOf(selectors[i]), ROLE_PUBLIC, "QueueModule selector must be PUBLIC"); + assertEq(vault.roleOf(selectors[i]), ROLE_PUBLIC, "EpochedQueueModule selector must be PUBLIC"); } } @@ -776,14 +776,17 @@ contract RolesTimelockInvariants is Test { console.log("\n=== QUEUEMODULE SELECTORS (ROLE_PUBLIC = 0) ==="); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); - // string[6]: index 5 = "compactQueue" added when QUEUE_MODULE_SELECTORS grew from 5 to 6. - string[6] memory queueNames = [ - "requestClaim", - "cancelClaim", - "processQueuedRedemptions", - "settleFeesAndProcessQueue", + // "Queue module" = EpochedQueueModule (the sole queue-settlement mechanism). + string[9] memory queueNames = [ + "requestEpochWithdrawal", + "cancelEpochWithdrawal", + "closeCurrentEpoch", + "fundEpoch", + "claimEpochAssets", + "batchClaimEpochAssets", + "requestInstantWithdrawal", "endEpochCrystallize", - "compactQueue" + "syncOldestUnfundedEpoch" ]; for (uint256 i = 0; i < queueSels.length; i++) { diff --git a/test/security/EIP7201Compliance.t.sol b/test/security/EIP7201Compliance.t.sol index ee402c7..9ae664a 100644 --- a/test/security/EIP7201Compliance.t.sol +++ b/test/security/EIP7201Compliance.t.sol @@ -6,6 +6,7 @@ import { CoreStorage } from "src/core/storage/CoreStorage.sol"; import { FeeStorage } from "src/core/storage/FeeStorage.sol"; import { QueueStorage } from "src/core/storage/QueueStorage.sol"; import { FixedMaturityStorage } from "src/core/storage/FixedMaturityStorage.sol"; +import { EpochQueueStorage } from "src/core/modules/EpochedQueueModule.sol"; /// @title EIP-7201 Compliance Test /// @notice Verifies that all storage library SLOT constants are computed @@ -37,12 +38,29 @@ contract EIP7201ComplianceTest is Test { assertEq(FixedMaturityStorage.SLOT, expected, "FixedMaturityStorage SLOT must match EIP-7201 formula"); } + /// @notice PR #13 review fix: EpochQueueStorage.SLOT was hand-typed, not a + /// real keccak output -- didn't match the very formula its own + /// comment claimed, and this suite never caught it because + /// EpochQueueStorage (colocated in EpochedQueueModule.sol, not + /// under src/core/storage/) was never added here. + function test_EpochQueueStorage_SLOT_matches_EIP7201() public { + bytes32 expected = _eip7201Slot("multyr.storage.EpochQueue.v1"); + assertEq(EpochQueueStorage.SLOT, expected, "EpochQueueStorage SLOT must match EIP-7201 formula"); + } + /// @notice Sanity check: no two namespaces produce colliding storage slots. function test_namespace_uniqueness() public { bytes32 a = _eip7201Slot("dsf.core.main.storage.v1"); bytes32 b = _eip7201Slot("dsf.core.fee.storage.v1"); bytes32 c = _eip7201Slot("dsf.core.queue.storage.v1"); bytes32 d = _eip7201Slot("dsf.core.fixedmaturity.storage.v1"); - assertTrue(a != b && a != c && a != d && b != c && b != d && c != d, "namespaces must be unique"); + bytes32 e = _eip7201Slot("multyr.storage.EpochQueue.v1"); + assertTrue( + a != b && a != c && a != d && a != e && + b != c && b != d && b != e && + c != d && c != e && + d != e, + "namespaces must be unique" + ); } } diff --git a/test/security/core/CoreVaultSecuritySuite.t.sol b/test/security/core/CoreVaultSecuritySuite.t.sol index 2fce159..d531be5 100644 --- a/test/security/core/CoreVaultSecuritySuite.t.sol +++ b/test/security/core/CoreVaultSecuritySuite.t.sol @@ -7,7 +7,7 @@ import { CoreVault } from "../../../src/core/CoreVault.sol"; import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../../src/interfaces/IQueueModule.sol"; import { IAdminModule } from "../../../src/interfaces/IAdminModule.sol"; @@ -29,7 +29,7 @@ contract CoreVaultSecuritySuite is Test { CoreHarness internal vault; ERC20Mock internal usdc; MockParamsProvider internal params; - QueueModule internal queueModule; + EpochedQueueModule internal queueModule; AdminModule internal adminModule; address internal owner = address(0xA11CE); @@ -53,7 +53,7 @@ contract CoreVaultSecuritySuite is Test { params = new MockParamsProvider(); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreHarness (wires all modules + unpauses automatically) @@ -91,23 +91,22 @@ contract CoreVaultSecuritySuite is Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -137,9 +136,8 @@ contract CoreVaultSecuritySuite is Test { vm.startPrank(attacker); uint256 shares = vault.deposit(amount, attacker); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 endBalance = usdc.balanceOf(attacker); assertLe(endBalance, startBalance, "flash loan never profitable"); @@ -165,8 +163,7 @@ contract CoreVaultSecuritySuite is Test { uint256 sharesToWithdraw = vault.previewWithdraw(withdraw1); vm.prank(attacker); - IQueueModule(address(vault)).requestClaim(true, sharesToWithdraw); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).requestInstantWithdrawal(sharesToWithdraw); uint256 priceAfter = vault.convertToAssets(1e6); @@ -189,9 +186,8 @@ contract CoreVaultSecuritySuite is Test { // Whale enters and exits vm.startPrank(attacker); uint256 whaleShares = vault.deposit(whaleSize, attacker); - IQueueModule(address(vault)).requestClaim(true, whaleShares); + IQueueModule(address(vault)).requestInstantWithdrawal(whaleShares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); // Retail value unchanged uint256 retailValueAfter = vault.convertToAssets(retailShares); @@ -219,8 +215,7 @@ contract CoreVaultSecuritySuite is Test { // Back-run vm.prank(attacker); - IQueueModule(address(vault)).requestClaim(true, attackerShares); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).requestInstantWithdrawal(attackerShares); uint256 attackerEnd = usdc.balanceOf(attacker); assertLe(attackerEnd, attackerStart + 1, "no sandwich profit"); @@ -373,9 +368,8 @@ contract CoreVaultSecuritySuite is Test { vm.startPrank(attacker); uint256 shares = vault.deposit(amount, attacker); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 balanceAfter = usdc.balanceOf(attacker); @@ -403,9 +397,8 @@ contract CoreVaultSecuritySuite is Test { uint256 shares = vault.balanceOf(user1); uint256 usdcBefore = usdc.balanceOf(user1); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 received = usdc.balanceOf(user1) - usdcBefore; // Allow 0.1% tolerance for rounding across many operations @@ -487,8 +480,7 @@ contract CoreVaultSecuritySuite is Test { uint256 shares = vault.previewWithdraw(500_000e6); vm.prank(attacker); uint256 gasStart = gasleft(); - IQueueModule(address(vault)).requestClaim(true, shares); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 gasUsed = gasStart - gasleft(); assertLt(gasUsed, 2_000_000, "withdraw gas bounded"); @@ -507,12 +499,9 @@ contract CoreVaultSecuritySuite is Test { if (i % 2 == 0) { uint256 sh = vault.previewWithdraw(50_000e6); - IQueueModule(address(vault)).requestClaim(false, sh); + IQueueModule(address(vault)).requestEpochWithdrawal(sh); } vm.stopPrank(); - if (i % 2 == 0) { - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); - } } uint256 totalSupply = vault.totalSupply(); diff --git a/test/security/core/LockPeriodProtection.t.sol b/test/security/core/LockPeriodProtection.t.sol index 8143996..8ed9469 100644 --- a/test/security/core/LockPeriodProtection.t.sol +++ b/test/security/core/LockPeriodProtection.t.sol @@ -7,7 +7,7 @@ import { CoreVault } from "../../../src/core/CoreVault.sol"; import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../../src/interfaces/IQueueModule.sol"; @@ -22,7 +22,7 @@ contract LockPeriodProtection is Test { CoreHarness internal vault; ERC20Mock internal usdc; MockParamsProvider internal params; - QueueModule internal queueModule; + EpochedQueueModule internal queueModule; AdminModule internal adminModule; address internal owner = address(0xA11CE); @@ -45,7 +45,7 @@ contract LockPeriodProtection is Test { params.setLockPeriod(LOCK_PERIOD); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreHarness (wires all modules + unpauses automatically) @@ -81,23 +81,22 @@ contract LockPeriodProtection is Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -158,9 +157,8 @@ contract LockPeriodProtection is Test { // Now withdrawal should work (async pattern) uint256 usdcBefore = usdc.balanceOf(attacker); uint256 shares = vault.previewWithdraw(FLASH_AMOUNT); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 assets = usdc.balanceOf(attacker) - usdcBefore; assertGt(assets, 0, "withdraw works after lock"); } @@ -179,9 +177,8 @@ contract LockPeriodProtection is Test { // Now redeem should work (async pattern) uint256 usdcBefore = usdc.balanceOf(attacker); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 assets = usdc.balanceOf(attacker) - usdcBefore; assertGt(assets, 0, "redeem works after lock"); } @@ -268,9 +265,8 @@ contract LockPeriodProtection is Test { // Now should work (129602 > 129601, async pattern) uint256 sh = vault.previewWithdraw(1e6); - IQueueModule(address(vault)).requestClaim(true, sh); + IQueueModule(address(vault)).requestInstantWithdrawal(sh); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); } /* ========== FUZZ TESTS ========== */ @@ -313,9 +309,8 @@ contract LockPeriodProtection is Test { // Should work (async pattern) uint256 usdcBefore = usdc.balanceOf(attacker); uint256 sh = vault.previewWithdraw(amount / 2); - IQueueModule(address(vault)).requestClaim(true, sh); + IQueueModule(address(vault)).requestInstantWithdrawal(sh); vm.stopPrank(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); uint256 received = usdc.balanceOf(attacker) - usdcBefore; assertGt(received, 0, "withdraw works after lock"); } @@ -380,7 +375,7 @@ contract LockPeriodProtection is Test { uint256 shares = vault.balanceOf(attacker); // Request claim immediately - try IQueueModule(address(vault)).requestClaim(true, shares) { + try IQueueModule(address(vault)).requestInstantWithdrawal(shares) { // If immediate claim allowed, funds should not be transferred yet // (claim goes to queue or is blocked) uint256 attackerBalance = usdc.balanceOf(attacker); diff --git a/test/security/core/SharePriceCollapse_Security.t.sol b/test/security/core/SharePriceCollapse_Security.t.sol index 57fa124..00e47c0 100644 --- a/test/security/core/SharePriceCollapse_Security.t.sol +++ b/test/security/core/SharePriceCollapse_Security.t.sol @@ -8,7 +8,7 @@ import { FeeCollector } from "../../../src/core/modules/FeeCollector.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; import { IQueueModule } from "../../../src/interfaces/IQueueModule.sol"; @@ -22,7 +22,7 @@ contract SharePriceCollapse_Security is Test { FeeCollector public feeCollector; MockParamsProvider public params; ERC20Mock public usdc; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; address public owner = address(0xA11CE); @@ -50,7 +50,7 @@ contract SharePriceCollapse_Security is Test { ); // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Deploy CoreHarness (wires all modules + unpauses automatically) @@ -76,23 +76,23 @@ contract SharePriceCollapse_Security is Test { } function _wireModules() internal { - // QueueModule selectors (PUBLIC) + // EpochedQueueModule selectors (PUBLIC) vault.setModule( - QueueModule.requestClaim.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule(QueueModule.cancelClaim.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.cancelEpochWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.closeCurrentEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.fundEpoch.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.claimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.batchClaimEpochAssets.selector, address(queueModule), vault.ROLE_PUBLIC()); vault.setModule( - QueueModule.processQueuedRedemptions.selector, address(queueModule), vault.ROLE_PUBLIC() + EpochedQueueModule.requestInstantWithdrawal.selector, address(queueModule), vault.ROLE_PUBLIC() ); vault.setModule( - QueueModule.settleFeesAndProcessQueue.selector, - address(queueModule), - vault.ROLE_PUBLIC() + EpochedQueueModule.totalEscrowedShares.selector, address(queueModule), vault.ROLE_PUBLIC() ); - vault.setModule( - QueueModule.pendingShares.selector, address(queueModule), vault.ROLE_PUBLIC() - ); - vault.setModule(QueueModule.queueLength.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.outstandingClaimCount.selector, address(queueModule), vault.ROLE_PUBLIC()); + vault.setModule(EpochedQueueModule.reservedForClaims.selector, address(queueModule), vault.ROLE_PUBLIC()); // AdminModule selectors (OWNER) vault.setModule( @@ -150,7 +150,8 @@ contract SharePriceCollapse_Security is Test { // Alice requests claim vm.startPrank(alice); - IQueueModule(address(vault)).requestClaim(false, aliceShares); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(aliceShares); vm.stopPrank(); // Check claim value @@ -161,9 +162,12 @@ contract SharePriceCollapse_Security is Test { // This is economically correct BUT the system should handle it gracefully assertGt(claimValue, 0, "Claim should have value"); - // Try to settle + // Try to settle: close AFTER the collapse locks PPS at the reduced price vm.warp(block.timestamp + 7 days); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(alice); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); // Verify Alice received something (not 0) uint256 aliceBalance = usdc.balanceOf(alice); @@ -228,10 +232,11 @@ contract SharePriceCollapse_Security is Test { // Alice requests claim (shares escrowed) vm.startPrank(alice); - IQueueModule(address(vault)).requestClaim(false, aliceShares); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(aliceShares); vm.stopPrank(); - uint256 pendingShares = IQueueModule(address(vault)).pendingShares(); + uint256 pendingShares = IQueueModule(address(vault)).totalEscrowedShares(); assertEq(pendingShares, aliceShares, "Shares should be escrowed"); // Check that escrowed shares are held by vault @@ -248,9 +253,12 @@ contract SharePriceCollapse_Security is Test { uint256 sharePrice = vault.convertToAssets(1e18); console2.log("Share price after collapse:", sharePrice); - // Settle Alice's claim + // Settle Alice's claim: close AFTER the collapse locks PPS at the reduced price vm.warp(block.timestamp + 7 days); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(alice); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); // Alice should receive proportional assets uint256 aliceBalance = usdc.balanceOf(alice); @@ -259,7 +267,7 @@ contract SharePriceCollapse_Security is Test { // pendingShares should be reduced assertEq( - IQueueModule(address(vault)).pendingShares(), 0, "Pending shares should be cleared" + IQueueModule(address(vault)).totalEscrowedShares(), 0, "Pending shares should be cleared" ); assertEq(vault.balanceOf(address(vault)), 0, "Vault should not hold shares"); } @@ -281,13 +289,14 @@ contract SharePriceCollapse_Security is Test { // Request 50% claim uint256 claimShares = initialSupply / 2; vm.startPrank(alice); - IQueueModule(address(vault)).requestClaim(false, claimShares); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(claimShares); vm.stopPrank(); // CORRECT accounting check BEFORE collapse - // Escrowed shares are in vault balance AND counted in pendingShares + // Escrowed shares are in vault balance AND counted in totalEscrowedShares uint256 vaultBalance = vault.balanceOf(address(vault)); - uint256 pendingShares = IQueueModule(address(vault)).pendingShares(); + uint256 pendingShares = IQueueModule(address(vault)).totalEscrowedShares(); assertEq(vaultBalance, pendingShares, "Vault balance should equal pending shares"); // totalSupply stays the same (shares transferred, not burned) @@ -301,18 +310,88 @@ contract SharePriceCollapse_Security is Test { // Accounting check AFTER collapse (before settlement) assertEq( vault.balanceOf(address(vault)), - IQueueModule(address(vault)).pendingShares(), + IQueueModule(address(vault)).totalEscrowedShares(), "Escrow accounting should survive collapse" ); assertEq(vault.totalSupply(), initialSupply, "totalSupply still unchanged"); - // Settle + // Settle: close AFTER the collapse locks PPS at the reduced price vm.warp(block.timestamp + 7 days); - IQueueModule(address(vault)).settleFeesAndProcessQueue(1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(alice); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); // After settlement, escrowed shares are burned assertTrue(vault.totalSupply() < initialSupply, "Shares should be burned"); - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "No pending shares"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "No pending shares"); assertEq(vault.balanceOf(address(vault)), 0, "Vault should not hold shares"); } + + /** + * @notice THE ORDER THE EPOCH MODEL INTRODUCES: close first, collapse + * after. Every other collapse test here closes the epoch AFTER the + * loss, which locks ppsAtClose at the already-reduced price and is + * the harmless direction. Closing first locks the price HIGH and + * then removes the assets backing it, which is where the original + * unpayable-claimant bug lived. + * @dev The reservation is what makes this safe now: epoch 0 can only be + * marked Funded while the vault genuinely holds its liability, and + * once reserved that cash cannot be spent by anything else. + */ + function test_closeBeforeCollapse_fundedClaimIsStillHonoured() public { + usdc._mint(alice, 1_000_000e6); + vm.startPrank(alice); + usdc.approve(address(vault), 1_000_000e6); + vault.deposit(1_000_000e6, alice); + vm.stopPrank(); + + usdc._mint(bob, 1_000_000e6); + vm.startPrank(bob); + usdc.approve(address(vault), 1_000_000e6); + vault.deposit(1_000_000e6, bob); + vm.stopPrank(); + + uint256 aliceShares = vault.balanceOf(alice); + vm.prank(alice); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(aliceShares); + + // CLOSE FIRST: ppsAtClose is locked at the healthy price. + vm.warp(block.timestamp + 7 days); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + + uint256 reserved = IQueueModule(address(vault)).reservedForClaims(); + assertGt(reserved, 0, "alice's payout is reserved at the pre-collapse price"); + + // COLLAPSE AFTER: 50% of the vault's assets vanish. + uint256 totalAssets = vault.totalAssets(); + vm.prank(address(vault)); + usdc.transfer(address(0xDEAD), totalAssets / 2); + + // Bob's force exit may only spend free liquidity, so it cannot eat into + // what alice is owed. + vm.prank(bob); + IForceExitAll(address(vault)).forceWithdrawAll(bob, 0); + assertGe( + usdc.balanceOf(address(vault)), + IQueueModule(address(vault)).reservedForClaims(), + "hot still covers the reservation after the collapse and a force exit" + ); + + // Alice is paid in full at the price locked before the collapse. + uint256 aliceBefore = usdc.balanceOf(alice); + vm.prank(alice); + uint256 paid = IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); + + assertEq(paid, reserved, "paid exactly the reserved amount"); + assertEq(usdc.balanceOf(alice) - aliceBefore, paid, "and it actually arrived"); + assertEq(IQueueModule(address(vault)).reservedForClaims(), 0, "reservation released"); + } +} + +interface IForceExitAll { + function forceWithdrawAll(address receiver, uint256 minAssetsOut) + external returns (uint256 assetsReceived); } diff --git a/test/sprint-test/HWM_Drawdown_POC.t.sol b/test/sprint-test/HWM_Drawdown_POC.t.sol index 3548e1b..dfcc54a 100644 --- a/test/sprint-test/HWM_Drawdown_POC.t.sol +++ b/test/sprint-test/HWM_Drawdown_POC.t.sol @@ -54,7 +54,7 @@ import { CoreHarness } from "../helpers/CoreHarness.sol"; import { MockUSDC } from "../helpers/MockUSDC.sol"; import { MockParamsProvider } from "../helpers/MockParamsProvider.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; contract HWM_Drawdown_POC is Test { @@ -121,7 +121,7 @@ contract HWM_Drawdown_POC is Test { } function _crystallize() internal { - QueueModule(address(core)).endEpochCrystallize(); + EpochedQueueModule(address(core)).endEpochCrystallize(); } // ========================================================================= @@ -278,7 +278,7 @@ contract HWM_Drawdown_POC is Test { // Griever attempts HWM reset via the ROLE_PUBLIC endEpochCrystallize() address griever = makeAddr("griever"); vm.prank(griever); - QueueModule(address(core)).endEpochCrystallize(); // FIX: HWM stays at 10.0 + EpochedQueueModule(address(core)).endEpochCrystallize(); // FIX: HWM stays at 10.0 // Enable 20 % perf fee for the recovery phase core.setPerfParamsUnsafe(PERF_RATE_20PCT, 0); @@ -348,7 +348,7 @@ contract HWM_Drawdown_POC is Test { for (uint256 i = 0; i < 3; i++) { vm.warp(block.timestamp + 1 hours); vm.prank(griever); - QueueModule(address(core)).endEpochCrystallize(); + EpochedQueueModule(address(core)).endEpochCrystallize(); } (,, uint256 hwmAfterDust, uint256 lastCrystAfterDustCalls) = @@ -419,7 +419,7 @@ contract HWM_Drawdown_POC is Test { for (uint256 i = 0; i < 5; i++) { vm.warp(block.timestamp + 1 hours); vm.prank(griever); - QueueModule(address(core)).endEpochCrystallize(); + EpochedQueueModule(address(core)).endEpochCrystallize(); } (,, uint256 hwmAfterGrief, uint256 lastCrystAfterGrief) = diff --git a/test/sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol b/test/sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol index f042508..d704b03 100644 --- a/test/sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol +++ b/test/sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol @@ -73,6 +73,7 @@ contract MockQueueEpochParamsProvider is IParamsProvider { uint256 private _dcpThreshold; bool private _dcpEnabled; uint64 private _epochDuration = 1 days; + uint256 private _minClaimAmount; function setLockPeriod(uint64 v) external { _lockPeriod = v; } function setCapPerEpochBps(uint16 v) external { _capPerEpochBps = v; } @@ -83,6 +84,7 @@ contract MockQueueEpochParamsProvider is IParamsProvider { _dcpThreshold = threshold; } function setEpochDuration(uint64 v) external { _epochDuration = v; } + function setMinClaimAmount(uint256 v) external { _minClaimAmount = v; } function getFeeParams(address) external pure returns (FeeParams memory) { return FeeParams({ depositFeeBps: 0, withdrawFeeBps: 0, perfRateX: 0, minCrystallizeInterval: 0, treasury: address(0) }); @@ -92,7 +94,7 @@ contract MockQueueEpochParamsProvider is IParamsProvider { capPerEpochBps: _capPerEpochBps, maxWithdrawalPerBlock: 0, maxWithdrawalPerTx: 0, - minClaimAmount: 0, + minClaimAmount: _minClaimAmount, lockPeriod: _lockPeriod }); } diff --git a/test/sprint-test/SystemSealer_DecimalsGuard.t.sol b/test/sprint-test/SystemSealer_DecimalsGuard.t.sol index 6c703b1..8fcf36b 100644 --- a/test/sprint-test/SystemSealer_DecimalsGuard.t.sol +++ b/test/sprint-test/SystemSealer_DecimalsGuard.t.sol @@ -24,7 +24,7 @@ import { Test } from "forge-std/Test.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "../../src/core/modules/LiquidityOpsModule.sol"; @@ -316,7 +316,7 @@ contract SystemSealer_DecimalsGuard_Test is Test { function _wireModules(address selectorRegistry) internal { vault.setSelectorRegistry(selectorRegistry); - QueueModule qm = new QueueModule(); + EpochedQueueModule qm = new EpochedQueueModule(); AdminModule am = new AdminModule(); ERC4626Module e4626 = new ERC4626Module(); LiquidityOpsModule lo = new LiquidityOpsModule(); diff --git a/test/sprint-test/SystemSealer_TimestampHash_POC.t.sol b/test/sprint-test/SystemSealer_TimestampHash_POC.t.sol index 49b251b..854fb8c 100644 --- a/test/sprint-test/SystemSealer_TimestampHash_POC.t.sol +++ b/test/sprint-test/SystemSealer_TimestampHash_POC.t.sol @@ -25,7 +25,7 @@ import { Test, console2 } from "forge-std/Test.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "../../src/core/CoreVault.sol"; -import { QueueModule } from "../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../src/core/modules/ERC4626Module.sol"; import { LiquidityOpsModule } from "../../src/core/modules/LiquidityOpsModule.sol"; @@ -367,7 +367,7 @@ contract SystemSealer_TimestampHash_POC is Test { function _wireModules(address selectorRegistry) internal { vault.setSelectorRegistry(selectorRegistry); - QueueModule qm = new QueueModule(); + EpochedQueueModule qm = new EpochedQueueModule(); AdminModule am = new AdminModule(); ERC4626Module e4626 = new ERC4626Module(); LiquidityOpsModule lo = new LiquidityOpsModule(); diff --git a/test/unit/core/CoreVault_DiamondLite_AccessControl.t.sol b/test/unit/core/CoreVault_DiamondLite_AccessControl.t.sol index 2110ce6..8e83a3b 100644 --- a/test/unit/core/CoreVault_DiamondLite_AccessControl.t.sol +++ b/test/unit/core/CoreVault_DiamondLite_AccessControl.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.28; import { Test } from "forge-std/Test.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "src/core/CoreVault.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { SelectorLib } from "src/core/libraries/SelectorLib.sol"; import { Events } from "src/core/libraries/Events.sol"; @@ -16,14 +16,16 @@ import { MockBufferManagerForTests } from "test/helpers/MockBufferManagerForTest import { ExitEngineLib } from "src/core/libraries/ExitEngineLib.sol"; interface IQueueModule_AC { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } /// @title CoreVault Access Control Tests /// @notice Tests for timelock ownership, guardian restrictions, and role enforcement contract CoreVault_AccessControl_Test is Test { CoreVault public router; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; ERC20Mock public usdc; MockParamsProvider public params; @@ -58,7 +60,7 @@ contract CoreVault_AccessControl_Test is Test { router = _harness; // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Configure routing from timelock @@ -389,9 +391,9 @@ contract CoreVault_AccessControl_Test is Test { usdc.approve(address(router), 1000e6); router.deposit(1000e6, user); - // redeem() always reverts now; verify requestClaim works for any user + // redeem() always reverts now; verify requestInstantWithdrawal works for any user uint256 shares = router.balanceOf(user); - IQueueModule_AC(address(router)).requestClaim(true, shares); + IQueueModule_AC(address(router)).requestInstantWithdrawal(shares); // Just verify it doesn't revert (role check passes) vm.stopPrank(); } diff --git a/test/unit/core/CoreVault_DiamondLite_ERC4626.t.sol b/test/unit/core/CoreVault_DiamondLite_ERC4626.t.sol index 855e1c8..3a24e3f 100644 --- a/test/unit/core/CoreVault_DiamondLite_ERC4626.t.sol +++ b/test/unit/core/CoreVault_DiamondLite_ERC4626.t.sol @@ -5,7 +5,7 @@ import { Test } from "forge-std/Test.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { CoreVault } from "src/core/CoreVault.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { ERC4626Module } from "src/core/modules/ERC4626Module.sol"; import { SelectorLib } from "src/core/libraries/SelectorLib.sol"; @@ -17,7 +17,9 @@ import { CoreHarness } from "test/helpers/CoreHarness.sol"; import { MockBufferManagerForTests } from "test/helpers/MockBufferManagerForTests.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } /// @title CoreVault ERC4626 Golden Tests @@ -27,7 +29,7 @@ contract CoreVault_ERC4626_Test is Test { CoreVault public vault; ERC20Mock public usdc; MockParamsProvider public params; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; address public owner = address(this); @@ -62,7 +64,7 @@ contract CoreVault_ERC4626_Test is Test { vault = _harness; // Deploy and configure modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); @@ -170,7 +172,7 @@ contract CoreVault_ERC4626_Test is Test { // ═══════════════════════════════════════════════════════════════════════════════ // WITHDRAWALS — QUEUED PROTOCOL (ExitEngineLib Architecture) // withdraw()/redeem() ALWAYS revert AsyncWithdrawalRequired. - // Users must use requestClaim(true) for instant or requestClaim(false) for queued. + // Users must use requestInstantWithdrawal() for instant or requestEpochWithdrawal() for queued. // ═══════════════════════════════════════════════════════════════════════════════ /// @notice withdraw() always reverts with AsyncWithdrawalRequired @@ -195,7 +197,7 @@ contract CoreVault_ERC4626_Test is Test { vm.stopPrank(); } - /// @notice requestClaim(true) settles instantly when cap + liquidity OK + /// @notice requestInstantWithdrawal() settles instantly when cap + liquidity OK function test_golden_requestClaimInstant_settles() public { vm.startPrank(user1); usdc.approve(address(vault), 1000e6); @@ -205,7 +207,7 @@ contract CoreVault_ERC4626_Test is Test { uint256 sharesBefore = vault.balanceOf(user1); // Instant claim for 400 shares - IQueueModule(address(vault)).requestClaim(true, 400e6); + IQueueModule(address(vault)).requestInstantWithdrawal(400e6); vm.stopPrank(); uint256 sharesAfter = vault.balanceOf(user1); @@ -373,13 +375,13 @@ contract CoreVault_ERC4626_Test is Test { // Full instant claim uint256 shares = vault.balanceOf(user1); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); assertEq(vault.balanceOf(user1), 0, "Golden: no dust shares"); } - /// @notice Full instant claim via requestClaim + /// @notice Full instant claim via requestInstantWithdrawal function test_golden_fullWithdraw_noDust() public { vm.startPrank(user1); usdc.approve(address(vault), 1000e6); @@ -387,7 +389,7 @@ contract CoreVault_ERC4626_Test is Test { // Full instant claim uint256 shares = vault.balanceOf(user1); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); vm.stopPrank(); assertEq(vault.balanceOf(user1), 0, "Golden: no dust after full claim"); diff --git a/test/unit/core/CoreVault_DiamondLite_Reentrancy.t.sol b/test/unit/core/CoreVault_DiamondLite_Reentrancy.t.sol index fb9ca18..7e74e4c 100644 --- a/test/unit/core/CoreVault_DiamondLite_Reentrancy.t.sol +++ b/test/unit/core/CoreVault_DiamondLite_Reentrancy.t.sol @@ -6,7 +6,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { CoreVault } from "src/core/CoreVault.sol"; import { SelectorLib } from "src/core/libraries/SelectorLib.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { MockParamsProvider } from "test/helpers/MockParamsProvider.sol"; import { ModuleSetter } from "test/helpers/ModuleSetter.sol"; @@ -128,7 +128,7 @@ contract CoreVault_Reentrancy_Test is Test { CoreVault public router; MaliciousToken public malToken; MockParamsProvider public params; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; address public owner = address(this); @@ -154,7 +154,7 @@ contract CoreVault_Reentrancy_Test is Test { router = _harness; // Deploy and configure modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); diff --git a/test/unit/core/CoreVault_DiamondLite_Routing.t.sol b/test/unit/core/CoreVault_DiamondLite_Routing.t.sol index 03ce072..7ed7b45 100644 --- a/test/unit/core/CoreVault_DiamondLite_Routing.t.sol +++ b/test/unit/core/CoreVault_DiamondLite_Routing.t.sol @@ -6,7 +6,7 @@ import { Vm } from "forge-std/Vm.sol"; import { console } from "forge-std/console.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "src/core/CoreVault.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { SelectorLib } from "src/core/libraries/SelectorLib.sol"; import { ERC20Mock } from "src/mocks/ERC20Mock.sol"; @@ -17,14 +17,16 @@ import { MockBufferManagerForTests } from "test/helpers/MockBufferManagerForTest import { ExitEngineLib } from "src/core/libraries/ExitEngineLib.sol"; interface IQueueModule_Routing { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } /// @title CoreVault Routing Tests /// @notice Tests for the Diamond-lite routing mechanism contract CoreVault_Routing_Test is Test { CoreVault public router; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; ERC20Mock public usdc; MockParamsProvider public params; @@ -64,7 +66,7 @@ contract CoreVault_Routing_Test is Test { router = _harness; // Deploy modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); // Configure routing using SelectorLib (source of truth) @@ -103,12 +105,12 @@ contract CoreVault_Routing_Test is Test { // ═══════════════════════════════════════════════════════════════════════════════ function test_moduleOf_returnsCorrectModule() public view { - assertEq(router.moduleOf(QueueModule.requestClaim.selector), address(queueModule)); + assertEq(router.moduleOf(EpochedQueueModule.requestEpochWithdrawal.selector), address(queueModule)); assertEq(router.moduleOf(AdminModule.submitFeeParams.selector), address(adminModule)); } function test_roleOf_returnsCorrectRole() public view { - assertEq(router.roleOf(QueueModule.requestClaim.selector), ROLE_PUBLIC); + assertEq(router.roleOf(EpochedQueueModule.requestEpochWithdrawal.selector), ROLE_PUBLIC); assertEq(router.roleOf(AdminModule.submitFeeParams.selector), ROLE_OWNER); } @@ -235,9 +237,9 @@ contract CoreVault_Routing_Test is Test { assertTrue(router.pausedDeposits()); assertFalse(router.pausedWithdrawals()); - // withdraw() always reverts now; verify requestClaim works when deposits paused + // withdraw() always reverts now; verify requestInstantWithdrawal works when deposits paused uint256 shares = router.balanceOf(address(this)); - IQueueModule_Routing(address(router)).requestClaim(true, shares / 2); + IQueueModule_Routing(address(router)).requestInstantWithdrawal(shares / 2); } function test_pauseWithdrawals_allowsDeposits() public { @@ -424,7 +426,7 @@ contract CoreVault_Routing_Test is Test { function test_getExpectedRole_returnsCorrect() public pure { // Queue module functions should be PUBLIC - (uint8 role, bool found) = SelectorLib.getExpectedRole(QueueModule.requestClaim.selector); + (uint8 role, bool found) = SelectorLib.getExpectedRole(EpochedQueueModule.requestEpochWithdrawal.selector); assertTrue(found); assertEq(role, SelectorLib.ROLE_PUBLIC); diff --git a/test/unit/core/CoreVault_ERC4626.t.sol b/test/unit/core/CoreVault_ERC4626.t.sol index 0ee9190..4867f59 100644 --- a/test/unit/core/CoreVault_ERC4626.t.sol +++ b/test/unit/core/CoreVault_ERC4626.t.sol @@ -30,7 +30,7 @@ contract CoreVault_ERC4626 is BaseVaultTest { uint256 assets = amt / 2; uint256 expectedShares = vault.previewWithdraw(assets); assertGt(expectedShares, 0, "previewWithdraw should return non-zero shares"); - // withdraw() now always reverts — users must use requestClaim(true) + // withdraw() now always reverts — users must use requestInstantWithdrawal() vm.expectRevert(ExitEngineLib.AsyncWithdrawalRequired.selector); CoreAggregatorVaultLike(address(vaultAddr)).withdraw(assets, user, user); vm.stopPrank(); diff --git a/test/unit/core/CoreVault_Events.t.sol b/test/unit/core/CoreVault_Events.t.sol index babea9c..37e7bbd 100644 --- a/test/unit/core/CoreVault_Events.t.sol +++ b/test/unit/core/CoreVault_Events.t.sol @@ -4,20 +4,22 @@ pragma solidity ^0.8.28; import { BaseVaultTest } from "../../helpers/BaseVaultTest.t.sol"; import { Vm } from "forge-std/Vm.sol"; import { CoreHarness } from "../../helpers/CoreHarness.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; import { ModuleSetter } from "../../helpers/ModuleSetter.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } contract CoreVault_Events is BaseVaultTest { function setUp() public override { super.setUp(); - // Wire QueueModule for requestClaim - QueueModule qm = new QueueModule(); + // Wire EpochedQueueModule for requestInstantWithdrawal + EpochedQueueModule qm = new EpochedQueueModule(); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); ModuleSetter.setModulesSame( vaultAddr, queueSels, address(qm), SelectorLib.ROLE_PUBLIC @@ -53,12 +55,12 @@ contract CoreVault_Events is BaseVaultTest { } assertTrue(depFound, "DepositFeeTaken not emitted"); - // instant claim and check FeePaid event (queued protocol: use requestClaim(true)) + // instant claim and check FeePaid event (queued protocol: use requestInstantWithdrawal) vm.recordLogs(); vm.startPrank(user); uint256 userShares = vault.balanceOf(user); uint256 claimShares = userShares / 10; // claim 10% - IQueueModule(vaultAddr).requestClaim(true, claimShares); + IQueueModule(vaultAddr).requestInstantWithdrawal(claimShares); vm.stopPrank(); logs = vm.getRecordedLogs(); bytes32 feePaidSig = keccak256("FeePaid(address,address,uint256)"); diff --git a/test/unit/core/CoreVault_FeeMixin.t.sol b/test/unit/core/CoreVault_FeeMixin.t.sol index 52fc283..51f2c0d 100644 --- a/test/unit/core/CoreVault_FeeMixin.t.sol +++ b/test/unit/core/CoreVault_FeeMixin.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.28; import { Test } from "forge-std/Test.sol"; import { CoreVault } from "../../../src/core/CoreVault.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { IAdminModule } from "../../../src/interfaces/IAdminModule.sol"; import { IQueueModule } from "../../../src/interfaces/IQueueModule.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; @@ -24,7 +24,7 @@ import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTes contract CoreVaultFeeMixinTest is Test { CoreVault public vault; AdminModule public adminModule; - QueueModule public queueModule; + EpochedQueueModule public queueModule; MockUSDC public usdc; address public vaultAddr; @@ -59,9 +59,9 @@ contract CoreVaultFeeMixinTest is Test { vault = _harness; vaultAddr = address(vault); - // Deploy AdminModule and QueueModule + // Deploy AdminModule and EpochedQueueModule adminModule = new AdminModule(); - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); // Wire AdminModule owner selectors bytes4[] memory adminOwnerSelectors = SelectorLib.getAdminModuleOwnerSelectors(); @@ -76,7 +76,7 @@ contract CoreVaultFeeMixinTest is Test { address(vault), adminViewSelectors, address(adminModule), ROLE_PUBLIC ); - // Wire QueueModule selectors (PUBLIC) + // Wire EpochedQueueModule selectors (PUBLIC) bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); ModuleSetter.setModulesSame( vaultAddr, queueSels, address(queueModule), ROLE_PUBLIC @@ -173,8 +173,8 @@ contract CoreVaultFeeMixinTest is Test { vm.stopPrank(); } - /// @notice Test 5: Verify fee rounding on tiny amounts (withdraw via requestClaim) - /// @dev withdraw() always reverts with AsyncWithdrawalRequired, so we use requestClaim. + /// @notice Test 5: Verify fee rounding on tiny amounts (withdraw via requestInstantWithdrawal) + /// @dev withdraw() always reverts with AsyncWithdrawalRequired, so we use requestInstantWithdrawal. /// Fee is observable via share transfer to feeCollector. function test_fee_rounding_edge_cases_tiny_withdraw_amounts() public { // Deposit first @@ -182,12 +182,12 @@ contract CoreVaultFeeMixinTest is Test { usdc.approve(address(vault), 1_000e6); vault.deposit(1000e6, user); - // Withdraw ~1 USDC worth of shares with 0.5% fee via requestClaim + // Withdraw ~1 USDC worth of shares with 0.5% fee via requestInstantWithdrawal uint256 shares = vault.previewWithdraw(1e6); uint256 assetsBefore = usdc.balanceOf(user); uint256 feeSharesBefore = vault.balanceOf(treasury); - IQueueModule(vaultAddr).requestClaim(true, shares); + IQueueModule(vaultAddr).requestInstantWithdrawal(shares); uint256 assetsReceived = usdc.balanceOf(user) - assetsBefore; uint256 feeSharesAfter = vault.balanceOf(treasury); @@ -245,11 +245,11 @@ contract CoreVaultFeeMixinTest is Test { assertEq(shares, 10_000e6, "No deposit fee"); assertEq(vault.balanceOf(treasury), treasurySharesBefore, "Treasury unchanged"); - // Withdraw with zero fee via requestClaim + // Withdraw with zero fee via requestInstantWithdrawal uint256 sharesToClaim = vault.previewWithdraw(1_000e6); uint256 assetsBefore = usdc.balanceOf(user); uint256 treasurySharesMid = vault.balanceOf(treasury); - IQueueModule(vaultAddr).requestClaim(true, sharesToClaim); + IQueueModule(vaultAddr).requestInstantWithdrawal(sharesToClaim); uint256 assetsReceived = usdc.balanceOf(user) - assetsBefore; assertEq(assetsReceived, 1_000e6, "No withdraw fee"); diff --git a/test/unit/core/CoreVault_PauseControls.t.sol b/test/unit/core/CoreVault_PauseControls.t.sol index 040dd77..25c303b 100644 --- a/test/unit/core/CoreVault_PauseControls.t.sol +++ b/test/unit/core/CoreVault_PauseControls.t.sol @@ -8,13 +8,15 @@ import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; import { ModuleSetter } from "../../helpers/ModuleSetter.sol"; import { ExitEngineLib } from "../../../src/core/libraries/ExitEngineLib.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } /** @@ -59,8 +61,8 @@ contract CoreVault_PauseControls is Test { _harness.setBufferManagerUnsafe(address(mockBM)); vault = _harness; - // Wire QueueModule for requestClaim tests - QueueModule queueModule = new QueueModule(); + // Wire EpochedQueueModule for requestInstantWithdrawal tests + EpochedQueueModule queueModule = new EpochedQueueModule(); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); vm.startPrank(owner); ModuleSetter.setModulesSame(address(vault), queueSels, address(queueModule), 0); @@ -176,11 +178,11 @@ contract CoreVault_PauseControls is Test { vault.pauseDepositsOnly(true); // withdraw() always reverts with AsyncWithdrawalRequired now; - // verify requestClaim(true) still works when only deposits are paused + // verify requestInstantWithdrawal() still works when only deposits are paused vm.startPrank(user); uint256 userShares = vault.balanceOf(user); require(userShares > 0, "user should have shares"); - IQueueModule(address(vault)).requestClaim(true, userShares / 10); + IQueueModule(address(vault)).requestInstantWithdrawal(userShares / 10); // Just verify it doesn't revert vm.stopPrank(); } @@ -190,13 +192,13 @@ contract CoreVault_PauseControls is Test { vault.pauseDepositsOnly(true); // redeem() always reverts with AsyncWithdrawalRequired now; - // verify requestClaim(true) still works when only deposits are paused + // verify requestInstantWithdrawal() still works when only deposits are paused vm.startPrank(user); uint256 userShares = vault.balanceOf(user); require(userShares > 0, "user should have shares"); uint256 sharesToRedeem = userShares / 10; // Redeem 10% of shares - IQueueModule(address(vault)).requestClaim(true, sharesToRedeem); + IQueueModule(address(vault)).requestInstantWithdrawal(sharesToRedeem); // Just verify it doesn't revert vm.stopPrank(); } diff --git a/test/unit/core/CoreVault_PerfFeeMixin.t.sol b/test/unit/core/CoreVault_PerfFeeMixin.t.sol index 0da714a..cb5e6f6 100644 --- a/test/unit/core/CoreVault_PerfFeeMixin.t.sol +++ b/test/unit/core/CoreVault_PerfFeeMixin.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.28; import { Test } from "forge-std/Test.sol"; import { CoreVault } from "../../../src/core/CoreVault.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { IAdminModule } from "../../../src/interfaces/IAdminModule.sol"; import { IQueueModule } from "../../../src/interfaces/IQueueModule.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; @@ -20,12 +20,12 @@ import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTes * @title CoreVault_PerfFeeMixin Test Suite * @notice Comprehensive test coverage for performance fee handling in modular CoreVault * @dev Tests HWM tracking, crystallization logic, fee calculations, and interval enforcement - * Updated for Diamond-lite architecture with AdminModule/QueueModule wiring + * Updated for Diamond-lite architecture with AdminModule/EpochedQueueModule wiring */ contract CoreVaultPerfFeeMixinTest is Test { CoreVault public vault; AdminModule public adminModule; - QueueModule public queueModule; + EpochedQueueModule public queueModule; MockUSDC public usdc; MockParamsProvider public params; @@ -46,9 +46,9 @@ contract CoreVaultPerfFeeMixinTest is Test { usdc = new MockUSDC(); params = new MockParamsProvider(); - // Deploy AdminModule and QueueModule + // Deploy AdminModule and EpochedQueueModule adminModule = new AdminModule(); - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); // Deploy vault with 6-param constructor (via CoreHarness for setBufferManagerUnsafe) vm.prank(owner); @@ -77,13 +77,16 @@ contract CoreVaultPerfFeeMixinTest is Test { address(vault), adminViewSelectors, address(adminModule), ROLE_PUBLIC ); - // Wire QueueModule selectors (PUBLIC) - includes endEpochCrystallize - bytes4[] memory queueSelectors = new bytes4[](5); - queueSelectors[0] = QueueModule.requestClaim.selector; - queueSelectors[1] = QueueModule.cancelClaim.selector; - queueSelectors[2] = QueueModule.processQueuedRedemptions.selector; - queueSelectors[3] = QueueModule.settleFeesAndProcessQueue.selector; - queueSelectors[4] = QueueModule.endEpochCrystallize.selector; + // Wire EpochedQueueModule selectors (PUBLIC) - includes endEpochCrystallize + bytes4[] memory queueSelectors = new bytes4[](8); + queueSelectors[0] = EpochedQueueModule.requestEpochWithdrawal.selector; + queueSelectors[1] = EpochedQueueModule.cancelEpochWithdrawal.selector; + queueSelectors[2] = EpochedQueueModule.closeCurrentEpoch.selector; + queueSelectors[3] = EpochedQueueModule.fundEpoch.selector; + queueSelectors[4] = EpochedQueueModule.claimEpochAssets.selector; + queueSelectors[5] = EpochedQueueModule.requestInstantWithdrawal.selector; + queueSelectors[6] = EpochedQueueModule.batchClaimEpochAssets.selector; + queueSelectors[7] = EpochedQueueModule.endEpochCrystallize.selector; ModuleSetter.setModulesSame( address(vault), queueSelectors, address(queueModule), ROLE_PUBLIC ); @@ -195,10 +198,10 @@ contract CoreVaultPerfFeeMixinTest is Test { uint256 feeCollectorSharesBefore = vault.balanceOf(feeCollector); - // Simulate loss: withdraw almost everything via requestClaim + // Simulate loss: withdraw almost everything via requestInstantWithdrawal uint256 sharesToClaim = vault.convertToShares(500e6); vm.prank(user); - IQueueModule(address(vault)).requestClaim(true, sharesToClaim); + IQueueModule(address(vault)).requestInstantWithdrawal(sharesToClaim); // Wait interval vm.warp(block.timestamp + MIN_INTERVAL + 1); @@ -284,12 +287,15 @@ contract CoreVaultPerfFeeMixinTest is Test { address(vaultZeroFee), adminViewSelectors, address(adminModule), ROLE_PUBLIC ); - bytes4[] memory queueSelectors = new bytes4[](5); - queueSelectors[0] = QueueModule.requestClaim.selector; - queueSelectors[1] = QueueModule.cancelClaim.selector; - queueSelectors[2] = QueueModule.processQueuedRedemptions.selector; - queueSelectors[3] = QueueModule.settleFeesAndProcessQueue.selector; - queueSelectors[4] = QueueModule.endEpochCrystallize.selector; + bytes4[] memory queueSelectors = new bytes4[](8); + queueSelectors[0] = EpochedQueueModule.requestEpochWithdrawal.selector; + queueSelectors[1] = EpochedQueueModule.cancelEpochWithdrawal.selector; + queueSelectors[2] = EpochedQueueModule.closeCurrentEpoch.selector; + queueSelectors[3] = EpochedQueueModule.fundEpoch.selector; + queueSelectors[4] = EpochedQueueModule.claimEpochAssets.selector; + queueSelectors[5] = EpochedQueueModule.requestInstantWithdrawal.selector; + queueSelectors[6] = EpochedQueueModule.batchClaimEpochAssets.selector; + queueSelectors[7] = EpochedQueueModule.endEpochCrystallize.selector; ModuleSetter.setModulesSame( address(vaultZeroFee), queueSelectors, address(queueModule), ROLE_PUBLIC ); @@ -388,10 +394,10 @@ contract CoreVaultPerfFeeMixinTest is Test { (,, uint256 hwm2,) = admin().getPerfParams(); assertGt(hwm2, hwm1, "HWM should increase after profit"); - // Simulate loss (partial withdrawal via requestClaim) + // Simulate loss (partial withdrawal via requestInstantWithdrawal) uint256 sharesToClaim = vault.convertToShares(600e6); vm.prank(user); - IQueueModule(address(vault)).requestClaim(true, sharesToClaim); + IQueueModule(address(vault)).requestInstantWithdrawal(sharesToClaim); vm.warp(block.timestamp + MIN_INTERVAL + 1); queue().endEpochCrystallize(); (,, uint256 hwm3,) = admin().getPerfParams(); diff --git a/test/unit/core/CoreVault_ReentrancyGuards.t.sol b/test/unit/core/CoreVault_ReentrancyGuards.t.sol index e0e4152..6d0cbf2 100644 --- a/test/unit/core/CoreVault_ReentrancyGuards.t.sol +++ b/test/unit/core/CoreVault_ReentrancyGuards.t.sol @@ -6,7 +6,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { CoreVault } from "../../../src/core/CoreVault.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { IQueueModule } from "src/interfaces/IQueueModule.sol"; import { IAdminModule } from "src/interfaces/IAdminModule.sol"; @@ -44,7 +44,7 @@ import { MockBufferManagerForTests } from "test/helpers/MockBufferManagerForTest contract CoreVault_ReentrancyGuards is Test { CoreVault internal vault; ERC20Mock internal usdc; - QueueModule internal queueModule; + EpochedQueueModule internal queueModule; AdminModule internal adminModule; address internal owner = address(0xA11CE); @@ -74,7 +74,7 @@ contract CoreVault_ReentrancyGuards is Test { vault = _harness; // Deploy and configure modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); vm.startPrank(owner); @@ -121,14 +121,14 @@ contract CoreVault_ReentrancyGuards is Test { vm.prank(user1); vault.deposit(100_000e6, user1); - // Multiple requestClaim(true) should work (withdraw/redeem always revert AsyncWithdrawalRequired) + // Multiple requestInstantWithdrawal() should work (withdraw/redeem always revert AsyncWithdrawalRequired) vm.startPrank(user1); - IQueueModule(address(vault)).requestClaim(true, vault.previewWithdraw(10_000e6)); - IQueueModule(address(vault)).requestClaim(true, vault.previewWithdraw(20_000e6)); - IQueueModule(address(vault)).requestClaim(true, vault.previewWithdraw(30_000e6)); + IQueueModule(address(vault)).requestInstantWithdrawal(vault.previewWithdraw(10_000e6)); + IQueueModule(address(vault)).requestInstantWithdrawal(vault.previewWithdraw(20_000e6)); + IQueueModule(address(vault)).requestInstantWithdrawal(vault.previewWithdraw(30_000e6)); vm.stopPrank(); - assertTrue(true, "all requestClaims succeeded"); + assertTrue(true, "all requestInstantWithdrawals succeeded"); } function test_sequential_mints_work() public { @@ -151,23 +151,23 @@ contract CoreVault_ReentrancyGuards is Test { vm.prank(user1); vault.deposit(100_000e6, user1); - // Multiple requestClaim(true) should work (redeem always reverts AsyncWithdrawalRequired) + // Multiple requestInstantWithdrawal() should work (redeem always reverts AsyncWithdrawalRequired) vm.startPrank(user1); - IQueueModule(address(vault)).requestClaim(true, 10_000e6); - IQueueModule(address(vault)).requestClaim(true, 20_000e6); - IQueueModule(address(vault)).requestClaim(true, 30_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(10_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(20_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(30_000e6); vm.stopPrank(); - assertTrue(true, "all requestClaims succeeded"); + assertTrue(true, "all requestInstantWithdrawals succeeded"); } function test_mixed_operations_sequential() public { vm.startPrank(user1); vault.deposit(50_000e6, user1); - IQueueModule(address(vault)).requestClaim(true, vault.previewWithdraw(10_000e6)); + IQueueModule(address(vault)).requestInstantWithdrawal(vault.previewWithdraw(10_000e6)); vault.mint(5_000e6, user1); - IQueueModule(address(vault)).requestClaim(true, 3_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(3_000e6); vault.deposit(20_000e6, user1); vm.stopPrank(); @@ -203,7 +203,7 @@ contract CoreVault_ReentrancyGuards is Test { uint256 claimShares = vault.previewWithdraw(10_000e6); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, claimShares); + IQueueModule(address(vault)).requestInstantWithdrawal(claimShares); uint256 shares1After = vault.balanceOf(user1); @@ -265,11 +265,11 @@ contract CoreVault_ReentrancyGuards is Test { uint256 claimShares = vault.previewWithdraw(20_000e6); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, claimShares); + IQueueModule(address(vault)).requestInstantWithdrawal(claimShares); uint256 assetsAfterClaim = vault.totalAssets(); // Assets decrease by approximately the claimed amount (exact depends on fee) - assertLt(assetsAfterClaim, assetsAfterDeposit, "assets decreased by requestClaim"); + assertLt(assetsAfterClaim, assetsAfterDeposit, "assets decreased by requestInstantWithdrawal"); } function test_no_phantom_shares_created() public { @@ -284,7 +284,7 @@ contract CoreVault_ReentrancyGuards is Test { uint256 claimShares = vault.previewWithdraw(5_000e6); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, claimShares); + IQueueModule(address(vault)).requestInstantWithdrawal(claimShares); // Total supply should have increased from deposits and decreased from claim uint256 totalSupplyAfter = vault.totalSupply(); @@ -298,11 +298,11 @@ contract CoreVault_ReentrancyGuards is Test { function test_many_sequential_operations_no_corruption() public { vm.startPrank(user1); - // 20 operations: deposit + requestClaim(true) + // 20 operations: deposit + requestInstantWithdrawal() for (uint256 i = 0; i < 10; i++) { vault.deposit(1000e6, user1); uint256 claimShares = vault.previewWithdraw(500e6); - IQueueModule(address(vault)).requestClaim(true, claimShares); + IQueueModule(address(vault)).requestInstantWithdrawal(claimShares); } vm.stopPrank(); diff --git a/test/unit/core/CoreVault_Roles.t.sol b/test/unit/core/CoreVault_Roles.t.sol index b5f2608..c879303 100644 --- a/test/unit/core/CoreVault_Roles.t.sol +++ b/test/unit/core/CoreVault_Roles.t.sol @@ -6,7 +6,7 @@ import { CoreVault } from "../../../src/core/CoreVault.sol"; import { MockUSDC } from "../../helpers/MockUSDC.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { IQueueModule } from "src/interfaces/IQueueModule.sol"; import { IAdminModule } from "src/interfaces/IAdminModule.sol"; @@ -21,7 +21,7 @@ import { ModuleSetter } from "test/helpers/ModuleSetter.sol"; contract CoreVaultRolesTest is Test { CoreVault public vault; MockUSDC public usdc; - QueueModule public queueModule; + EpochedQueueModule public queueModule; AdminModule public adminModule; address public owner = address(0xA11CE); @@ -43,7 +43,7 @@ contract CoreVaultRolesTest is Test { ); // Deploy and configure modules - queueModule = new QueueModule(); + queueModule = new EpochedQueueModule(); adminModule = new AdminModule(); vm.startPrank(owner); diff --git a/test/unit/core/CoreVault_SizeGate.t.sol b/test/unit/core/CoreVault_SizeGate.t.sol index 25a85b9..640ff76 100644 --- a/test/unit/core/CoreVault_SizeGate.t.sol +++ b/test/unit/core/CoreVault_SizeGate.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.28; import { Test } from "forge-std/Test.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { CoreVault } from "src/core/CoreVault.sol"; -import { QueueModule } from "src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "src/core/modules/AdminModule.sol"; import { ERC4626Module } from "src/core/modules/ERC4626Module.sol"; import { BufferManager } from "src/core/modules/BufferManager.sol"; @@ -81,11 +81,11 @@ contract CoreVault_SizeGate_Test is Test { // ═══════════════════════════════════════════════════════════════════════════════ function test_sizeGate_queueModule_extcodesize_under_16KB() public { - QueueModule module = new QueueModule(); + EpochedQueueModule module = new EpochedQueueModule(); uint256 runtimeSize = _getExtcodesize(address(module)); - emit log_named_uint("QueueModule runtime bytecode", runtimeSize); - assertLt(runtimeSize, MODULE_TARGET_SIZE, "QueueModule exceeds 16KB target"); + emit log_named_uint("EpochedQueueModule runtime bytecode", runtimeSize); + assertLt(runtimeSize, MODULE_TARGET_SIZE, "EpochedQueueModule exceeds 16KB target"); } function test_sizeGate_adminModule_extcodesize_under_16KB() public { @@ -135,7 +135,7 @@ contract CoreVault_SizeGate_Test is Test { feeCollector, address(0x999) ); - QueueModule queueModule = new QueueModule(); + EpochedQueueModule queueModule = new EpochedQueueModule(); AdminModule adminModule = new AdminModule(); ERC4626Module erc4626Module = new ERC4626Module(); StrategyRouter router = new StrategyRouter(owner, address(0x100), address(0x200)); @@ -152,7 +152,7 @@ contract CoreVault_SizeGate_Test is Test { uint256 routerSize = _getExtcodesize(address(router)); emit log_named_uint("CoreVault ", coreVaultSize); - emit log_named_uint("QueueModule ", queueSize); + emit log_named_uint("EpochedQueueModule", queueSize); emit log_named_uint("AdminModule ", adminSize); emit log_named_uint("ERC4626Module ", erc4626Size); emit log_named_uint("StrategyRouter ", routerSize); @@ -166,7 +166,7 @@ contract CoreVault_SizeGate_Test is Test { // Assertions assertLt(coreVaultSize, EIP170_LIMIT, "CoreVault EXCEEDS EIP-170"); - assertLt(queueSize, MODULE_TARGET_SIZE, "QueueModule over 16KB"); + assertLt(queueSize, MODULE_TARGET_SIZE, "EpochedQueueModule over 16KB"); assertLt(adminSize, MODULE_TARGET_SIZE, "AdminModule over 16KB"); assertLt(erc4626Size, MODULE_TARGET_SIZE, "ERC4626Module over 16KB"); assertLt(routerSize, EIP170_LIMIT, "StrategyRouter EXCEEDS EIP-170"); diff --git a/test/unit/core/EpochedQueueModule.t.sol b/test/unit/core/EpochedQueueModule.t.sol new file mode 100644 index 0000000..5a1c9c8 --- /dev/null +++ b/test/unit/core/EpochedQueueModule.t.sol @@ -0,0 +1,705 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// ───────────────────────────────────────────────────────────────────────────── +// General correctness suite for EpochedQueueModule, complementing the historical +// regression suite in test/sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol +// (which stays scoped to its 4 originally-fixed bugs and is left untouched). +// +// Covers: the outstandingClaimCount dynamic-cap fix (the reason this suite +// exists), cancellation, EpochTooYoung, double-claim, multi-retry fundEpoch, +// and closing a zero-claim epoch. +// ───────────────────────────────────────────────────────────────────────────── + +import { Test } from "lib/forge-std/src/Test.sol"; +import { Vm } from "lib/forge-std/src/Vm.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { CoreHarness } from "../../helpers/CoreHarness.sol"; +import { MockUSDC } from "../../helpers/MockUSDC.sol"; +import { ERC4626Module } from "../../../src/core/modules/ERC4626Module.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; +import { EpochQueueStorage } from "../../../src/core/modules/EpochedQueueModule.sol"; +import { CoreStorage } from "../../../src/core/storage/CoreStorage.sol"; +import { MockQueueEpochParamsProvider } from "../../sprint-test/QueueEpochModule_WithdrawFlow_POC.t.sol"; + +contract EpochedQueueModule_Test is Test { + address constant USDC_UNDERLYING = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + + address internal user; + address internal userB; + + CoreHarness internal core; + MockUSDC internal mock; + MockQueueEpochParamsProvider internal params; + + function setUp() public { + user = makeAddr("user"); + userB = makeAddr("userB"); + + mock = new MockUSDC(); + vm.etch(USDC_UNDERLYING, address(mock).code); + + params = new MockQueueEpochParamsProvider(); + core = new CoreHarness( + IERC20Metadata(USDC_UNDERLYING), + "USDC Agg", + "agUSDC", + address(this), + address(this), + address(params) + ); + + core.setEpochDurationUnsafe(7 days); + + MockUSDC(USDC_UNDERLYING).mint(user, 10_000_000e6); + MockUSDC(USDC_UNDERLYING).mint(userB, 10_000_000e6); + vm.prank(user); + IERC20(USDC_UNDERLYING).approve(address(core), type(uint256).max); + vm.prank(userB); + IERC20(USDC_UNDERLYING).approve(address(core), type(uint256).max); + } + + function _deposit(address who, uint256 assets) internal returns (uint256 shares) { + vm.prank(who); + shares = ERC4626Module(address(core)).deposit(assets, who); + } + + // ═══════════════════════════════════════════════════════════════════════ + // BUG FIX: outstandingClaimCount persists across epoch close (the reason + // this suite exists) — dynamic cap stress must not reset just because a + // fresh epoch opened while a claim from the closed epoch is still + // unfunded/unclaimed. + // ═══════════════════════════════════════════════════════════════════════ + + function test_dynamicCap_staysTightened_afterEpochClose_withOutstandingClaim() public { + params.setDynamicCap(true, 100, 2000, 1); // enabled, min 1%, max 20%, threshold 1 + uint256 sharesA = _deposit(user, 1_000_000e6); + _deposit(userB, 10_000e6); + + // userB queues a small claim, then the epoch closes -- with the old + // per-open-epoch claimCount signal, this would reset queueDepth to 0 + // and the dynamic cap would wrongly relax back to max (20%). + vm.prank(userB); + EpochedQueueModule(address(core)).requestEpochWithdrawal(1_000e6); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + assertEq( + EpochedQueueModule(address(core)).outstandingClaimCount(), + 1, + "userB's claim is still outstanding (unfunded/unclaimed) after the epoch closed" + ); + + // userA now requests an instant withdrawal worth 5% of TVL -- exceeds + // the 1%-under-stress dynamic cap. Must still be rejected. + uint256 fivePctShares = sharesA / 20; + vm.prank(user); + (bool settledImmediately,,) = + EpochedQueueModule(address(core)).requestInstantWithdrawal(fivePctShares); + + assertFalse( + settledImmediately, + "dynamic cap must stay tightened: outstanding claim from the closed epoch still counts as queue depth" + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // cancelEpochWithdrawal + // ═══════════════════════════════════════════════════════════════════════ + + function test_cancelEpochWithdrawal_returnsShares_andDecrementsTotals() public { + uint256 shares = _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + assertEq(EpochedQueueModule(address(core)).outstandingClaimCount(), 1); + assertEq(EpochedQueueModule(address(core)).totalEscrowedShares(), shares); + + vm.prank(user); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + + assertEq(core.balanceOf(user), shares, "shares returned to user"); + assertEq(EpochedQueueModule(address(core)).outstandingClaimCount(), 0); + assertEq(EpochedQueueModule(address(core)).totalEscrowedShares(), 0); + + EpochQueueStorage.EpochData memory epoch = EpochedQueueModule(address(core)).epochData(epochId); + assertEq(epoch.claimCount, 0); + assertEq(epoch.totalGrossShares, 0); + } + + function test_cancelEpochWithdrawal_revertsForNonOwner() public { + uint256 shares = _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + vm.prank(userB); + vm.expectRevert(EpochedQueueModule.NotClaimOwner.selector); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + } + + function test_cancelEpochWithdrawal_revertsIfAlreadyCancelled() public { + uint256 shares = _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + vm.prank(user); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + + vm.prank(user); + vm.expectRevert(EpochedQueueModule.ClaimAlreadySettled.selector); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + } + + function test_cancelEpochWithdrawal_revertsOnceEpochClosed() public { + uint256 shares = _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + vm.prank(user); + vm.expectRevert(EpochedQueueModule.EpochNotOpen.selector); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + } + + // ═══════════════════════════════════════════════════════════════════════ + // EpochTooYoung + // ═══════════════════════════════════════════════════════════════════════ + + function test_closeCurrentEpoch_revertsBeforeMinDuration() public { + _deposit(user, 1_000_000e6); + vm.prank(user); + EpochedQueueModule(address(core)).requestEpochWithdrawal(500_000e6); + + // No warp -- epoch just opened. + vm.expectRevert(EpochedQueueModule.EpochTooYoung.selector); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Double-claim + // ═══════════════════════════════════════════════════════════════════════ + + function test_claimEpochAssets_revertsOnDoubleClaim() public { + uint256 shares = _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epochId); + + vm.prank(user); + EpochedQueueModule(address(core)).claimEpochAssets(epochId, claimId); + + vm.prank(user); + vm.expectRevert(EpochedQueueModule.ClaimAlreadySettled.selector); + EpochedQueueModule(address(core)).claimEpochAssets(epochId, claimId); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Multi-retry fundEpoch: partial funding across calls, no double-counting + // ═══════════════════════════════════════════════════════════════════════ + + function test_fundEpoch_staysClosedUntilFullyFunded_thenSucceedsOnRetry() public { + uint256 shares = _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(shares); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + // Simulate capital deployed elsewhere: drain the vault's hot balance + // so fundEpoch() sees an unfundable deficit on the first call. + vm.prank(address(core)); + IERC20(USDC_UNDERLYING).transfer(makeAddr("elsewhere"), 900_000e6); + + EpochedQueueModule(address(core)).fundEpoch(epochId); + EpochQueueStorage.EpochData memory epochAfterFirst = + EpochedQueueModule(address(core)).epochData(epochId); + assertTrue( + epochAfterFirst.state == EpochQueueStorage.EpochState.Closed, + "epoch stays CLOSED: hot balance insufficient, no BufferManager/router to cover the gap" + ); + + // Liquidity arrives (e.g. strategy harvest returns funds). + MockUSDC(USDC_UNDERLYING).mint(address(core), 900_000e6); + + EpochedQueueModule(address(core)).fundEpoch(epochId); + EpochQueueStorage.EpochData memory epochAfterSecond = + EpochedQueueModule(address(core)).epochData(epochId); + assertTrue( + epochAfterSecond.state == EpochQueueStorage.EpochState.Funded, + "retry succeeds once hot balance covers totalNetAssets" + ); + // No double counting: totalNetAssets is unchanged across retries. + assertEq(epochAfterFirst.totalNetAssets, epochAfterSecond.totalNetAssets); + + vm.prank(user); + uint256 assets = EpochedQueueModule(address(core)).claimEpochAssets(epochId, claimId); + assertGt(assets, 0); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Zero-claim epoch close + // ═══════════════════════════════════════════════════════════════════════ + + function test_closeCurrentEpoch_withNoClaims_computesSanely() public { + // No deposits, no withdrawal requests at all -- epoch 0 was never + // touched by _requestEpochWithdrawal, so openedAt defaults to 0. + vm.warp(block.timestamp + 7 days + 1); + + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + EpochQueueStorage.EpochData memory epoch0 = EpochedQueueModule(address(core)).epochData(0); + assertTrue(epoch0.state == EpochQueueStorage.EpochState.Closed); + assertEq(epoch0.totalNetShares, 0); + assertEq(epoch0.totalNetAssets, 0, "no division-by-zero weirdness: zero shares -> zero assets"); + assertEq(epoch0.ppsAtClose, 1e18, "empty-supply PPS defaults to WAD"); + + assertEq(EpochedQueueModule(address(core)).currentEpochId(), 1, "next epoch opened"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // oldestUnfundedEpochId cursor (keeper-facing "what needs fundEpoch() next") + // ═══════════════════════════════════════════════════════════════════════ + + function test_oldestUnfundedEpochId_advancesInOrder() public { + assertEq(EpochedQueueModule(address(core)).oldestUnfundedEpochId(), 0, "no backlog initially"); + + _deposit(user, 1_000_000e6); + vm.prank(user); + (uint256 epoch0Id, uint256 claim0Id) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(200_000e6); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); // opens epoch 1 + + assertEq( + EpochedQueueModule(address(core)).oldestUnfundedEpochId(), epoch0Id, + "epoch 0 is closed and unfunded -> cursor points at it" + ); + + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + + assertEq( + EpochedQueueModule(address(core)).oldestUnfundedEpochId(), + EpochedQueueModule(address(core)).currentEpochId(), + "epoch 0 funded, epoch 1 still open -> cursor catches up to currentEpochId (no backlog)" + ); + + vm.prank(user); + EpochedQueueModule(address(core)).claimEpochAssets(epoch0Id, claim0Id); + } + + function test_oldestUnfundedEpochId_doesNotSkipPast_stillUnfundedEarlierEpoch() public { + // Track time via a local counter -- block.timestamp is not reliably + // re-readable mid-test through the CoreHarness delegatecall stack here + // (same quirk worked around elsewhere in this suite/session). + uint256 t = block.timestamp; + + _deposit(user, 1_000_000e6); + + // Epoch 0: userA's claim. + vm.prank(user); + (uint256 epoch0Id,) = EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); // opens epoch 1 + + // Drain hot so epoch 0 CANNOT be funded yet. + vm.prank(address(core)); + IERC20(USDC_UNDERLYING).transfer(makeAddr("elsewhere"), 950_000e6); + + // Epoch 1: userB's claim, funded normally (ample remaining liquidity). + _deposit(userB, 500_000e6); + vm.prank(userB); + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); // opens epoch 2 + + uint256 epoch1Id = epoch0Id + 1; + EpochedQueueModule(address(core)).fundEpoch(epoch1Id); // funds OUT OF ORDER + + EpochQueueStorage.EpochData memory e1 = EpochedQueueModule(address(core)).epochData(epoch1Id); + assertTrue(e1.state == EpochQueueStorage.EpochState.Funded, "epoch 1 funded despite epoch 0 still pending"); + + assertEq( + EpochedQueueModule(address(core)).oldestUnfundedEpochId(), + epoch0Id, + "cursor must NOT skip past epoch 0 just because the later epoch 1 got funded first" + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // BUG FIX (PR #13 review, critical): a FUNDED epoch is a real claim on + // assets, not a snapshot. Funding a LATER epoch must never spend cash + // already reserved for an EARLIER funded-but-unclaimed epoch, and an + // instant exit must never dip into it either. Reproduces the reviewer's + // PoC: Alice's epoch funds at pps 1.0 for 1M against 2M hot; NAV drops + // 50%; epoch N+1 closes at pps 0.5 and must NOT be fundable out of + // Alice's reserved cash. + // ═══════════════════════════════════════════════════════════════════════ + + function test_fundEpoch_cannotFundLaterEpoch_outOfEarlierFundedEpochsReservedCash() public { + // Track time via a local counter -- block.timestamp is not reliably + // re-readable mid-test through the CoreHarness delegatecall stack here + // (same quirk worked around elsewhere in this suite). + uint256 t = block.timestamp; + + uint256 aliceShares = _deposit(user, 1_000_000e6); + _deposit(userB, 1_000_000e6); // 2M hot, 2M supply, pps 1.0 + + vm.prank(user); + (uint256 epoch0Id, uint256 aliceClaimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(aliceShares); + + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); // opens epoch 1 + + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + EpochQueueStorage.EpochData memory e0 = EpochedQueueModule(address(core)).epochData(epoch0Id); + assertTrue(e0.state == EpochQueueStorage.EpochState.Funded, "epoch 0 funded out of the 2M hot"); + assertEq( + EpochedQueueModule(address(core)).reservedForClaims(), 1_000_000e6, + "Alice's payout is now reserved" + ); + + // NAV drops 50% (e.g. a strategy loss) -- drain hot directly by + // exactly what's reserved for Alice, leaving hot == reservedForClaims. + vm.prank(address(core)); + IERC20(USDC_UNDERLYING).transfer(makeAddr("elsewhere"), 1_000_000e6); + + vm.prank(userB); + EpochedQueueModule(address(core)).requestEpochWithdrawal(500_000e6); + + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); // opens epoch 2 + uint256 epoch1Id = epoch0Id + 1; + EpochQueueStorage.EpochData memory e1Closed = EpochedQueueModule(address(core)).epochData(epoch1Id); + assertEq(e1Closed.ppsAtClose, 0.5e18, "NAV halved relative to unchanged supply"); + + // Pre-fix, fundEpoch compared hot(1,000,000) >= totalNetAssets(250,000) + // directly and would have wrongly marked epoch 1 Funded out of + // Alice's reserved cash. Post-fix it must stay CLOSED. + EpochedQueueModule(address(core)).fundEpoch(epoch1Id); + EpochQueueStorage.EpochData memory e1After = EpochedQueueModule(address(core)).epochData(epoch1Id); + assertTrue( + e1After.state == EpochQueueStorage.EpochState.Closed, + "epoch 1 must stay CLOSED: hot is fully reserved for epoch 0, no spare liquidity" + ); + + // Alice's original Funded claim is still fully payable. + vm.prank(user); + uint256 assets = EpochedQueueModule(address(core)).claimEpochAssets(epoch0Id, aliceClaimId); + assertEq(assets, 1_000_000e6, "Alice paid in full despite the later NAV drop and funding attempt"); + } + + function test_canInstant_rejectsExit_thatWouldDipIntoReservedForClaims() public { + uint256 aliceShares = _deposit(user, 1_000_000e6); + _deposit(userB, 1_000_000e6); + + vm.prank(user); + (uint256 epoch0Id, uint256 aliceClaimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(aliceShares); + + vm.warp(block.timestamp + 7 days + 1); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + assertEq(EpochedQueueModule(address(core)).reservedForClaims(), 1_000_000e6); + + // Drain hot down to exactly reservedForClaims -- zero free liquidity. + vm.prank(address(core)); + IERC20(USDC_UNDERLYING).transfer(makeAddr("elsewhere"), 1_000_000e6); + + // Pre-fix, _canInstant compared raw hot(1,000,000) >= gross and would + // have let this through, spending into Alice's reserved payout. + vm.prank(userB); + (bool settledImmediately,,) = + EpochedQueueModule(address(core)).requestInstantWithdrawal(1_000e6); + + assertFalse(settledImmediately, "instant exit must not dip into cash reserved for a funded epoch"); + + // Alice's reservation is untouched, still fully payable at her locked + // ppsAtClose (unaffected by the live-pps drop from the drain above). + vm.prank(user); + uint256 assets = EpochedQueueModule(address(core)).claimEpochAssets(epoch0Id, aliceClaimId); + assertEq(assets, 1_000_000e6); + } + + // ═══════════════════════════════════════════════════════════════════════ + // BUG FIX (PR #13 review): the oldestUnfundedEpochId cursor could wedge. + // The bounded advance scan can leave the cursor parked on an epoch that is + // already FUNDED; fundEpoch() used to revert EpochAlreadyFunded on it, + // every cycle, with no administrative reset. It now syncs the cursor and + // returns, so the state is always recoverable permissionlessly. + // ═══════════════════════════════════════════════════════════════════════ + + event EpochFundSkipped(uint256 indexed epochId, uint256 cursorBefore, uint256 cursorAfter); + + /// @notice The no-op must stay observable: a moved cursor means the + /// self-heal fired, an unmoved one means the caller picked the + /// wrong epoch. Both are silent without this event. + function test_fundEpoch_onAlreadyFundedEpoch_emitsSkippedWithCursorDelta() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epoch0Id,) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + + uint256 healthy = EpochedQueueModule(address(core)).oldestUnfundedEpochId(); + + // Caller picked the wrong epoch: cursor does not move. + vm.expectEmit(true, false, false, true, address(core)); + emit EpochFundSkipped(epoch0Id, healthy, healthy); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + + // Cursor stale on a funded epoch: the same call repairs it, and says so. + _forceCursor(epoch0Id); + vm.expectEmit(true, false, false, true, address(core)); + emit EpochFundSkipped(epoch0Id, epoch0Id, healthy); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + } + + /// @notice And it must NOT fire on the keeper's normal path, or it is noise. + function test_fundEpoch_normalKeeperCycle_emitsNoSkip() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId,) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + // Exactly what the keeper does: read the cursor, fund what it points at. + uint256 cursor = EpochedQueueModule(address(core)).oldestUnfundedEpochId(); + assertEq(cursor, epochId, "cursor points at the closed epoch"); + + vm.recordLogs(); + EpochedQueueModule(address(core)).fundEpoch(cursor); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = keccak256("EpochFundSkipped(uint256,uint256,uint256)"); + uint256 seen; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) seen++; + } + assertEq(seen, 0, "no skip event on the normal keeper cycle"); + } + + function test_fundEpoch_onAlreadyFundedEpoch_syncsCursorInsteadOfReverting() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epoch0Id, uint256 claim0Id) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + + // Force the cursor back onto the now-FUNDED epoch, reproducing the + // state the bounded scan can leave behind. + _forceCursor(epoch0Id); + assertEq(EpochedQueueModule(address(core)).oldestUnfundedEpochId(), epoch0Id); + + // Must not revert, and must leave the cursor past the funded epoch. + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + assertEq( + EpochedQueueModule(address(core)).oldestUnfundedEpochId(), + EpochedQueueModule(address(core)).currentEpochId(), + "cursor self-healed past the already-funded epoch" + ); + + // And the claim behind it is still payable. + vm.prank(user); + uint256 assets = EpochedQueueModule(address(core)).claimEpochAssets(epoch0Id, claim0Id); + assertGt(assets, 0, "claimant unaffected by the cursor repair"); + } + + function test_syncOldestUnfundedEpoch_isPermissionlessAndIdempotent() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epoch0Id,) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epoch0Id); + + _forceCursor(epoch0Id); + + vm.prank(makeAddr("anyone")); + EpochedQueueModule(address(core)).syncOldestUnfundedEpoch(); + uint256 healed = EpochedQueueModule(address(core)).oldestUnfundedEpochId(); + assertEq(healed, EpochedQueueModule(address(core)).currentEpochId(), "cursor advanced"); + + vm.prank(makeAddr("anyone")); + EpochedQueueModule(address(core)).syncOldestUnfundedEpoch(); + assertEq( + EpochedQueueModule(address(core)).oldestUnfundedEpochId(), healed, + "second call is a no-op" + ); + } + + /// @dev Write oldestUnfundedEpochId directly. The field sits at offset 6 of + /// EpochQueueStorage.Layout (currentEpochId, three mappings, + /// escrowedShares, outstandingClaimCount, then this one). + function _forceCursor(uint256 value) internal { + vm.store(address(core), bytes32(uint256(EpochQueueStorage.SLOT) + 6), bytes32(value)); + } + + // ═══════════════════════════════════════════════════════════════════════ + // FUNDING OBSERVABILITY: a failed or partial fundEpoch() used to emit + // nothing, so a stalled epoch was invisible until a user complained. + // ═══════════════════════════════════════════════════════════════════════ + + event EpochFundingShortfall( + uint256 indexed epochId, + uint256 needed, + uint256 freeLiquidity, + uint256 shortfall + ); + event EpochFundAttempt( + uint256 indexed epochId, + uint256 needed, + uint256 hotBefore, + uint256 hotAfter + ); + + function test_fundEpoch_emitsShortfallWhenItCannotFund() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId,) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(1_000_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + uint256 owed = EpochedQueueModule(address(core)).epochData(epochId).totalNetAssets; + + // Drain most of the hot balance so the epoch cannot be funded. + vm.prank(address(core)); + IERC20(USDC_UNDERLYING).transfer(makeAddr("elsewhere"), 900_000e6); + uint256 hotLeft = IERC20(USDC_UNDERLYING).balanceOf(address(core)); + + vm.expectEmit(true, false, false, true, address(core)); + emit EpochFundingShortfall(epochId, owed, hotLeft, owed - hotLeft); + EpochedQueueModule(address(core)).fundEpoch(epochId); + + assertTrue( + EpochedQueueModule(address(core)).epochData(epochId).state + == EpochQueueStorage.EpochState.Closed, + "epoch stayed closed, and said so" + ); + } + + function test_fundEpoch_emitsOneAttemptEventWithBothBalances() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId,) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + uint256 owed = EpochedQueueModule(address(core)).epochData(epochId).totalNetAssets; + uint256 hot = IERC20(USDC_UNDERLYING).balanceOf(address(core)); + + vm.recordLogs(); + EpochedQueueModule(address(core)).fundEpoch(epochId); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = keccak256("EpochFundAttempt(uint256,uint256,uint256,uint256)"); + uint256 seen; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { + seen++; + (uint256 needed, uint256 hotBefore, uint256 hotAfter) = + abi.decode(logs[i].data, (uint256, uint256, uint256)); + assertEq(needed, owed, "needed reported"); + assertEq(hotBefore, hot, "hotBefore populated, not zeroed"); + assertEq(hotAfter, hot, "hotAfter populated, not zeroed"); + } + } + assertEq(seen, 1, "exactly one attempt event per call"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // REENTRANCY: every state-changing entry point takes the same guard. + // fundEpoch in particular calls out to the buffer manager and the router + // and then re-reads the hot balance to decide whether to mark the epoch + // FUNDED, so a reentrant call landing in between is the shape that matters. + // ═══════════════════════════════════════════════════════════════════════ + + function test_stateChangingEntryPoints_areAllGuarded() public { + uint256 t = block.timestamp; + _deposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId, uint256 claimId) = + EpochedQueueModule(address(core)).requestEpochWithdrawal(100_000e6); + + // Force the guard flag on, as a reentrant caller would find it. + _setReentrancyLock(true); + + vm.prank(user); + vm.expectRevert(EpochedQueueModule.ReentrancyGuardLocked.selector); + EpochedQueueModule(address(core)).cancelEpochWithdrawal(epochId, claimId); + + vm.expectRevert(EpochedQueueModule.ReentrancyGuardLocked.selector); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + + vm.expectRevert(EpochedQueueModule.ReentrancyGuardLocked.selector); + EpochedQueueModule(address(core)).fundEpoch(epochId); + + // Released again, the same calls go through. + _setReentrancyLock(false); + t += 7 days + 1; + vm.warp(t); + EpochedQueueModule(address(core)).closeCurrentEpoch(); + EpochedQueueModule(address(core)).fundEpoch(epochId); + + vm.prank(user); + uint256 assets = EpochedQueueModule(address(core)).claimEpochAssets(epochId, claimId); + assertGt(assets, 0, "guard releases cleanly, the claim still pays out"); + } + + /// @dev packedFlags sits at offset 10 of CoreStorage.Layout, after the ten + /// one-slot address fields. If that ever shifts, the guard assertions + /// below fail rather than passing silently. + function _setReentrancyLock(bool locked) internal { + bytes32 slot = bytes32(uint256(CoreStorage.SLOT) + 10); + uint256 flags = uint256(vm.load(address(core), slot)); + uint256 bit = CoreStorage.FLAG_REENTRANCY_LOCKED; + vm.store(address(core), slot, bytes32(locked ? flags | bit : flags & ~bit)); + } +} diff --git a/test/unit/core/ExitEngine_AuditEdgeCases.t.sol b/test/unit/core/ExitEngine_AuditEdgeCases.t.sol index d4709a2..a869535 100644 --- a/test/unit/core/ExitEngine_AuditEdgeCases.t.sol +++ b/test/unit/core/ExitEngine_AuditEdgeCases.t.sol @@ -15,13 +15,21 @@ import { FeeStorage } from "../../../src/core/storage/FeeStorage.sol"; import { Percentage } from "../../../src/libs/Percentage.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function cancelClaim(uint256 claimId) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function nextClaimId() external view returns (uint256); - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function currentEpochId() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); } interface IForceWithdrawAll { @@ -101,7 +109,7 @@ contract ExitEngine_AuditEdgeCases is Test { // Instant claim with stale NAV - should still work (W2) vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 usdcAfter = usdc.balanceOf(users[0]); uint256 sharesAfter = vault.balanceOf(users[0]); @@ -121,16 +129,20 @@ contract ExitEngine_AuditEdgeCases is Test { function test_A1_navDrift_queueSettle() public { // Queue claim with fresh NAV vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(1_000_000e6); - // Make NAV very stale before settlement - vm.warp(block.timestamp + 1 hours); + // Make NAV very stale, then close + fund past the min epoch duration + vm.warp(block.timestamp + 7 days + 1); uint256 usdcBefore = usdc.balanceOf(users[0]); uint256 supplyBefore = vault.totalSupply(); // Settle with stale NAV - should work (W2) - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(users[0]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 usdcAfter = usdc.balanceOf(users[0]); uint256 supplyAfter = vault.totalSupply(); @@ -158,7 +170,7 @@ contract ExitEngine_AuditEdgeCases is Test { uint256 usdcBefore0 = usdc.balanceOf(users[0]); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 received0 = usdc.balanceOf(users[0]) - usdcBefore0; // Stale NAV claim (same PPS - no actual drift, just staleness) @@ -166,7 +178,7 @@ contract ExitEngine_AuditEdgeCases is Test { uint256 usdcBefore1 = usdc.balanceOf(users[1]); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 received1 = usdc.balanceOf(users[1]) - usdcBefore1; // Both should receive similar amounts (PPS unchanged, only staleness differs) @@ -185,37 +197,46 @@ contract ExitEngine_AuditEdgeCases is Test { // Queue a claim vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, 2_000_000e6); - uint256 claimId = IQueueModule(address(vault)).nextClaimId(); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(2_000_000e6); uint256 sharesAfterQueue = vault.balanceOf(users[0]); assertEq(sharesBefore - sharesAfterQueue, 2_000_000e6, "A2: shares moved to escrow"); // Cancel vm.prank(users[0]); - IQueueModule(address(vault)).cancelClaim(claimId); + IQueueModule(address(vault)).cancelEpochWithdrawal(epochId, claimId); uint256 sharesAfterCancel = vault.balanceOf(users[0]); assertEq(sharesAfterCancel, sharesBefore, "A2: shares returned on cancel"); - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "A2: pending cleared"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "A2: pending cleared"); } function test_A2_multiUserQueueAndSettle_noZombie() public { - // 5 users queue claims + // 5 users queue claims into the same epoch + uint256[5] memory claimIds; + uint256 epochId; for (uint256 i = 0; i < 5; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(false, 500_000e6); + (epochId, claimIds[i]) = + IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); } - assertEq(IQueueModule(address(vault)).queueLength(), 5, "A2: 5 claims queued"); - assertEq(IQueueModule(address(vault)).pendingShares(), 2_500_000e6, "A2: 2.5M pending"); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 5, "A2: 5 claims queued"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 2_500_000e6, "A2: 2.5M pending"); - // Settle all - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); + // Settle all: close + fund the epoch, then each user self-claims + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + for (uint256 i = 0; i < 5; i++) { + vm.prank(users[i]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimIds[i]); + } // Verify no zombies - uint256 remaining = IQueueModule(address(vault)).queueLength(); - uint256 pending = IQueueModule(address(vault)).pendingShares(); + uint256 remaining = IQueueModule(address(vault)).outstandingClaimCount(); + uint256 pending = IQueueModule(address(vault)).totalEscrowedShares(); console2.log("A2: remaining queue:", remaining, "pending:", pending); assertEq(pending, 0, "A2: no pending shares after full settle"); @@ -228,24 +249,32 @@ contract ExitEngine_AuditEdgeCases is Test { function test_A2_cancelMidQueue_noStarvation() public { // User0 queues, user1 queues, user0 cancels, user2 queues vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); - uint256 claimId0 = IQueueModule(address(vault)).nextClaimId(); + (uint256 epochId0, uint256 claimId0) = + IQueueModule(address(vault)).requestEpochWithdrawal(1_000_000e6); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); + (uint256 epochId1, uint256 claimId1) = + IQueueModule(address(vault)).requestEpochWithdrawal(1_000_000e6); // User0 cancels mid-queue vm.prank(users[0]); - IQueueModule(address(vault)).cancelClaim(claimId0); + IQueueModule(address(vault)).cancelEpochWithdrawal(epochId0, claimId0); vm.prank(users[2]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); + (uint256 epochId2, uint256 claimId2) = + IQueueModule(address(vault)).requestEpochWithdrawal(1_000_000e6); // Settle — user1 and user2 should get settled, user0's cancel should not block uint256 user1Before = usdc.balanceOf(users[1]); uint256 user2Before = usdc.balanceOf(users[2]); - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId1); + vm.prank(users[1]); + IQueueModule(address(vault)).claimEpochAssets(epochId1, claimId1); + vm.prank(users[2]); + IQueueModule(address(vault)).claimEpochAssets(epochId2, claimId2); assertGt(usdc.balanceOf(users[1]), user1Before, "A2: user1 settled after cancel"); assertGt(usdc.balanceOf(users[2]), user2Before, "A2: user2 settled after cancel"); @@ -258,17 +287,17 @@ contract ExitEngine_AuditEdgeCases is Test { // Queue and cancel 10 times for (uint256 i = 0; i < 10; i++) { vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); - uint256 claimId = IQueueModule(address(vault)).nextClaimId(); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); vm.prank(users[0]); - IQueueModule(address(vault)).cancelClaim(claimId); + IQueueModule(address(vault)).cancelEpochWithdrawal(epochId, claimId); } // Shares should be exactly the same (no leak) assertEq(vault.balanceOf(users[0]), initialShares, "A2: no share leak on queue/cancel"); assertEq(vault.totalSupply(), initialSupply, "A2: no supply leak"); - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "A2: no pending leak"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "A2: no pending leak"); } // ===================================================================== @@ -285,7 +314,7 @@ contract ExitEngine_AuditEdgeCases is Test { vault.deposit(50_000_000e6, users[0]); uint256 usdcBefore = usdc.balanceOf(users[0]); - IQueueModule(address(vault)).requestClaim(true, 8_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(8_000_000e6); uint256 usdcAfter = usdc.balanceOf(users[0]); vm.stopPrank(); @@ -297,17 +326,17 @@ contract ExitEngine_AuditEdgeCases is Test { // TVL = 50M, cap = 5M // Claim 4M instant (leaves 1M cap) vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 4_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(4_000_000e6); // TVL decreased (~46M), cap = 10% of 46M = ~4.6M // Already used 4M, remaining = ~0.6M // Try 2M instant — should queue (exceeds remaining) - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, 2_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(2_000_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // The cap decreased because totalAssets decreased // This may or may not queue depending on exact math @@ -333,7 +362,7 @@ contract ExitEngine_AuditEdgeCases is Test { // TVL = 250M, cap = 25M // Instant claim 20M uint256 usdcBefore = usdc.balanceOf(attacker); - IQueueModule(address(vault)).requestClaim(true, 20_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(20_000_000e6); uint256 received = usdc.balanceOf(attacker) - usdcBefore; // Verify: attacker lost shares (fee applied) @@ -366,7 +395,7 @@ contract ExitEngine_AuditEdgeCases is Test { // Instant claim: fee = witBps(25) + immPenBps(50) = 75 bps uint256 usdcBefore0 = usdc.balanceOf(users[0]); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 instantNet = usdc.balanceOf(users[0]) - usdcBefore0; // Force claim: fee = witBps(25) + forcePenBps(150) = 175 bps @@ -390,13 +419,13 @@ contract ExitEngine_AuditEdgeCases is Test { function test_A4_forceDoesNotConsumeEpochCap() public { // Exhaust cap with instant claims vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 4_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(4_000_000e6); // Next instant queues (cap ~exhausted) - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, 3_000_000e6); - uint256 pendingAfterInstant = IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).requestInstantWithdrawal(3_000_000e6); + uint256 pendingAfterInstant = IQueueModule(address(vault)).totalEscrowedShares(); bool instantQueued = pendingAfterInstant > pendingBefore; @@ -422,17 +451,22 @@ contract ExitEngine_AuditEdgeCases is Test { // Queue claim vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, shares); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(shares); - // Settle it + // Settle it: close + fund + self-claim uint256 usdcBefore0 = usdc.balanceOf(users[0]); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(users[0]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 queuedNet = usdc.balanceOf(users[0]) - usdcBefore0; // Instant claim uint256 usdcBefore1 = usdc.balanceOf(users[1]); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 instantNet = usdc.balanceOf(users[1]) - usdcBefore1; console2.log("A4: queued net:", queuedNet); @@ -488,7 +522,7 @@ contract ExitEngine_AuditEdgeCases is Test { uint256 feeCollectorBefore = vault.balanceOf(feeCollector); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 actualNet = usdc.balanceOf(users[0]) - usdcBefore; uint256 actualSharesConsumed = sharesBefore - vault.balanceOf(users[0]); @@ -546,10 +580,15 @@ contract ExitEngine_AuditEdgeCases is Test { // INVARIANT 3: feeShares are exact (same formula used at queue and settle) // Verify by queueing and checking fee at settlement vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(false, shares); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(shares); uint256 feeCollectorBefore = vault.balanceOf(feeCollector); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(users[0]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 actualFeeShares = vault.balanceOf(feeCollector) - feeCollectorBefore; // feeShares must match exactly (allow 1 unit rounding) diff --git a/test/unit/core/ExitEngine_DepositRouter.t.sol b/test/unit/core/ExitEngine_DepositRouter.t.sol index 864150b..6184f7a 100644 --- a/test/unit/core/ExitEngine_DepositRouter.t.sol +++ b/test/unit/core/ExitEngine_DepositRouter.t.sol @@ -14,10 +14,17 @@ import { MockReferralBinding } from "../../mocks/MockReferralBinding.sol"; import { ExitEngineLib } from "../../../src/core/libraries/ExitEngineLib.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function pendingShares() external view returns (uint256); - function queueLength() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function totalEscrowedShares() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); } interface IForceWithdrawAll { @@ -181,19 +188,24 @@ contract ExitEngine_DepositRouter is Test { // INSTANT claim — user0 uint256 user0UsdcBefore = usdc.balanceOf(users[0]); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 5_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(5_000_000e6); uint256 user0Received = usdc.balanceOf(users[0]) - user0UsdcBefore; assertGt(user0Received, 0, "instant claim: user0 received USDC"); console2.log("Instant claim net:", user0Received / 1e6, "USDC"); // QUEUED claim — user1 vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(false, 5_000_000e6); - assertEq(IQueueModule(address(vault)).queueLength(), 1, "1 queued claim"); + (uint256 epochId1, uint256 claimId1) = + IQueueModule(address(vault)).requestEpochWithdrawal(5_000_000e6); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 1, "1 queued claim"); - // Keeper settle + // Keeper settle: close + fund the epoch, then user1 self-claims uint256 user1UsdcBefore = usdc.balanceOf(users[1]); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId1); + vm.prank(users[1]); + IQueueModule(address(vault)).claimEpochAssets(epochId1, claimId1); uint256 user1Received = usdc.balanceOf(users[1]) - user1UsdcBefore; assertGt(user1Received, 0, "queued settle: user1 received USDC"); console2.log("Queued settle net:", user1Received / 1e6, "USDC"); @@ -239,19 +251,19 @@ contract ExitEngine_DepositRouter is Test { // Phase 2: Exhaust cap (10% of 100M = 10M) vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 9_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(9_000_000e6); - // Next instant should queue - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + // Next instant should queue (cap exhausted -> falls back to the epoch queue) vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, 5_000_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + (bool settledImmediately, uint256 epochId1, uint256 claimId1) = + IQueueModule(address(vault)).requestInstantWithdrawal(5_000_000e6); - if (pendingAfter > pendingBefore) { + if (!settledImmediately) { console2.log("Cap exhausted - claim queued"); } - // Phase 3: Epoch rollover + // Phase 3: Epoch rollover (both the cap epoch and the settlement queue + // epoch default to a 7-day duration) vm.warp(block.timestamp + 7 days + 1); // New deposit via router after epoch @@ -264,14 +276,20 @@ contract ExitEngine_DepositRouter is Test { // Fresh cap — instant claim works uint256 usdcBefore = usdc.balanceOf(users[3]); vm.prank(users[3]); - IQueueModule(address(vault)).requestClaim(true, 3_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(3_000_000e6); assertGt(usdc.balanceOf(users[3]), usdcBefore, "instant claim after epoch + router deposit"); - // Phase 4: Settle any remaining queue - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); + // Phase 4: Settle any remaining queue -- close + fund the epoch the + // fallback claim (if any) landed in, then the user self-claims. + if (!settledImmediately) { + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId1); + vm.prank(users[1]); + IQueueModule(address(vault)).claimEpochAssets(epochId1, claimId1); + } console2.log("Final TVL:", vault.totalAssets() / 1e6); - console2.log("Final queue:", IQueueModule(address(vault)).queueLength()); + console2.log("Outstanding claims:", IQueueModule(address(vault)).outstandingClaimCount()); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/test/unit/core/ExitEngine_ForkSuite.t.sol b/test/unit/core/ExitEngine_ForkSuite.t.sol index 89d5eac..7482a89 100644 --- a/test/unit/core/ExitEngine_ForkSuite.t.sol +++ b/test/unit/core/ExitEngine_ForkSuite.t.sol @@ -11,13 +11,21 @@ import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTes import { ExitEngineLib } from "../../../src/core/libraries/ExitEngineLib.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function cancelClaim(uint256 claimId) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function nextClaimId() external view returns (uint256); - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function currentEpochId() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); } interface IForceWithdrawAll { @@ -30,7 +38,7 @@ interface IForceWithdrawAll { /// /// INVARIANTS UNDER TEST: /// 1. withdraw()/redeem() CANNOT ever transfer assets - always revert -/// 2. requestClaim(true) CANNOT exceed epoch cap +/// 2. requestInstantWithdrawal() CANNOT exceed epoch cap /// 3. Epoch rollover auto-rolls on any interaction, no keeper needed /// 4. totalSupply NEVER increases on exit - no _mint in exit paths /// 5. feeShares NEVER minted - always transferred from owner/escrow @@ -106,7 +114,7 @@ contract ExitEngine_ForkSuite is Test { } // ═══════════════════════════════════════════════════════════════════════════════ - // TEST 1: withdraw() always reverts, requestClaim(true) settles instantly + // TEST 1: withdraw() always reverts, requestInstantWithdrawal() settles instantly // ═══════════════════════════════════════════════════════════════════════════════ function test_fork1_withdrawReverts_requestClaimInstant() public { @@ -120,12 +128,12 @@ contract ExitEngine_ForkSuite is Test { vm.expectRevert(ExitEngineLib.AsyncWithdrawalRequired.selector); vault.redeem(100e6, user1, user1); - // requestClaim(true) settles instantly + // requestInstantWithdrawal settles instantly uint256 usdcBefore = usdc.balanceOf(user1); uint256 sharesBefore = vault.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); uint256 sharesAfter = vault.balanceOf(user1); uint256 usdcAfter = usdc.balanceOf(user1); @@ -139,7 +147,7 @@ contract ExitEngine_ForkSuite is Test { } // ═══════════════════════════════════════════════════════════════════════════════ - // TEST 2: requestClaim(false) → keeper settles → fee via transfer + // TEST 2: requestEpochWithdrawal() → keeper settles → fee via transfer // ═══════════════════════════════════════════════════════════════════════════════ function test_fork2_queuedClaim_keeperSettles_feeTransfer() public { @@ -147,19 +155,24 @@ contract ExitEngine_ForkSuite is Test { // User queues a claim (not immediate) vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 200_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(200_000e6); // Shares moved to escrow - assertEq(IQueueModule(address(vault)).pendingShares(), 200_000e6, "pending shares"); - assertEq(IQueueModule(address(vault)).queueLength(), 1, "queue has 1 claim"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 200_000e6, "pending shares"); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 1, "queue has 1 claim"); // Supply unchanged (shares in escrow, not burned yet) assertEq(vault.totalSupply(), supplyBefore, "supply unchanged during queue"); uint256 feeCollectorSharesBefore = vault.balanceOf(feeCollector); - // Keeper settles - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + // Keeper settles: close + fund the epoch, then the user self-claims + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 supplyAfter = vault.totalSupply(); uint256 feeCollectorSharesAfter = vault.balanceOf(feeCollector); @@ -175,7 +188,7 @@ contract ExitEngine_ForkSuite is Test { assertLe(supplyDrop, 200_000e6, "supply drop <= claimed shares"); // Queue cleared - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "queue empty"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "queue empty"); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -186,13 +199,13 @@ contract ExitEngine_ForkSuite is Test { // Cap = 10% of 2M = 200K per epoch // Claim 150K (under cap) - succeeds vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 150_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(150_000e6); // Claim another 100K - should queue (total 250K > 200K cap) - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // Should have been queued (cap exhausted) assertGt(pendingAfter, pendingBefore, "second claim queued due to cap"); @@ -203,30 +216,30 @@ contract ExitEngine_ForkSuite is Test { // New claim should succeed (epoch rolled, cap reset) uint256 usdcBefore = usdc.balanceOf(user2); vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); uint256 usdcAfter = usdc.balanceOf(user2); assertGt(usdcAfter, usdcBefore, "claim succeeded after epoch rollover"); } // ═══════════════════════════════════════════════════════════════════════════════ - // TEST 4: cap exhaustion → requestClaim(true) queues instead of settling + // TEST 4: cap exhaustion → requestInstantWithdrawal() queues instead of settling // ═══════════════════════════════════════════════════════════════════════════════ function test_fork4_capExhaustion_instantQueues() public { // Cap = 10% of 2M = 200K per epoch // Use up the cap vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 199_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(199_000e6); // Next instant claim should queue (cap nearly exhausted) uint256 sharesBefore = vault.balanceOf(user2); - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // Shares moved to escrow (queued, not settled) assertGt(pendingAfter, pendingBefore, "claim queued when cap exhausted"); @@ -243,13 +256,13 @@ contract ExitEngine_ForkSuite is Test { function test_fork5_forceExitBypassesCap() public { // Exhaust cap completely vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 199_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(199_000e6); // Verify cap is nearly exhausted by trying another instant claim vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); // If this queued, cap is exhausted - verify: - assertGt(IQueueModule(address(vault)).pendingShares(), 0, "cap exhausted, claims queuing"); + assertGt(IQueueModule(address(vault)).totalEscrowedShares(), 0, "cap exhausted, claims queuing"); // forceWithdrawAll should still work (bypasses cap) uint256 user3SharesBefore = vault.balanceOf(user3); @@ -275,17 +288,17 @@ contract ExitEngine_ForkSuite is Test { // Cap = 10% of 2M = 200K // User1 claims 80K (instant, under cap) vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 80_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(80_000e6); // User2 claims 80K (instant, under cap - cumulative 160K < 200K) vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 80_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(80_000e6); // User3 claims 80K - should queue (cumulative 240K > 200K cap) - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(user3); - IQueueModule(address(vault)).requestClaim(true, 80_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).requestInstantWithdrawal(80_000e6); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); assertGt(pendingAfter, pendingBefore, "user3 claim queued - cap exhausted by multi-user"); } @@ -299,21 +312,26 @@ contract ExitEngine_ForkSuite is Test { // Instant claim - supply must decrease vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); uint256 supplyAfterInstant = vault.totalSupply(); assertLt(supplyAfterInstant, supplyStart, "supply decreased after instant claim"); // Queued claim + settle - supply must decrease further vm.prank(user2); - IQueueModule(address(vault)).requestClaim(false, 30_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(30_000e6); uint256 supplyAfterQueue = vault.totalSupply(); // During queue, shares are in escrow (still counted in supply) assertEq(supplyAfterQueue, supplyAfterInstant, "supply unchanged during queue escrow"); - // Settle - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + // Settle: close + fund + self-claim + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user2); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 supplyAfterSettle = vault.totalSupply(); assertLt(supplyAfterSettle, supplyAfterQueue, "supply decreased after settlement"); @@ -340,14 +358,14 @@ contract ExitEngine_ForkSuite is Test { // Claim near cap vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 150_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(150_000e6); // After 1 day, epoch should roll and cap reset vm.warp(block.timestamp + 1 days + 1); uint256 usdcBefore = usdc.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); uint256 usdcAfter = usdc.balanceOf(user1); assertGt(usdcAfter, usdcBefore, "claim succeeded after 1-day epoch"); @@ -358,20 +376,20 @@ contract ExitEngine_ForkSuite is Test { vm.warp(block.timestamp + 30 days + 1); // Trigger epoch roll with a small claim vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 1_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(1_000e6); // Now exhaust the fresh cap in this 30-day epoch vm.prank(user2); - IQueueModule(address(vault)).requestClaim(true, 150_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(150_000e6); // After 7 days - epoch should NOT roll (30-day epoch, only 7 days passed) vm.warp(block.timestamp + 7 days); // Large claim should exceed remaining cap and queue - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(user3); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); assertGt(pendingAfter, pendingBefore, "claim queued - 30-day epoch not yet rolled"); // After remaining 23+ days - epoch rolls @@ -379,7 +397,7 @@ contract ExitEngine_ForkSuite is Test { usdcBefore = usdc.balanceOf(user3); vm.prank(user3); - IQueueModule(address(vault)).requestClaim(true, 10_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(10_000e6); usdcAfter = usdc.balanceOf(user3); assertGt(usdcAfter, usdcBefore, "claim succeeded after 30-day epoch roll"); } @@ -394,13 +412,14 @@ contract ExitEngine_ForkSuite is Test { // Mode 1: INSTANT claim uint256 user1UsdcBefore = usdc.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); assertGt(usdc.balanceOf(user1), user1UsdcBefore, "instant: received USDC"); // Mode 2: QUEUED claim (same epoch) vm.prank(user2); - IQueueModule(address(vault)).requestClaim(false, 30_000e6); - assertEq(IQueueModule(address(vault)).queueLength(), 1, "queued: 1 in queue"); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(30_000e6); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 1, "queued: 1 in queue"); // Mode 3: FORCE withdrawal (same epoch) uint256 user3UsdcBefore = usdc.balanceOf(user3); @@ -409,9 +428,13 @@ contract ExitEngine_ForkSuite is Test { assertGt(usdc.balanceOf(user3), user3UsdcBefore, "force: received USDC"); assertEq(vault.balanceOf(user3), 0, "force: user3 fully exited"); - // Settle the queued claim - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); - assertEq(IQueueModule(address(vault)).queueLength(), 0, "queue settled"); + // Settle the queued claim: close + fund + self-claim + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user2); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 0, "queue settled"); // INVARIANT: supply only decreased assertLt(vault.totalSupply(), supplyStart, "supply decreased from mixed exits"); @@ -425,12 +448,12 @@ contract ExitEngine_ForkSuite is Test { // Set NAV to stale (warp past 15min) vm.warp(block.timestamp + 20 minutes); - // requestClaim(true) should still work (soft refresh, W2 = never block) + // requestInstantWithdrawal should still work (soft refresh, W2 = never block) uint256 usdcBefore = usdc.balanceOf(user1); uint256 sharesBefore = vault.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 50_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(50_000e6); uint256 usdcAfter = usdc.balanceOf(user1); uint256 sharesAfter = vault.balanceOf(user1); @@ -442,12 +465,17 @@ contract ExitEngine_ForkSuite is Test { vm.warp(block.timestamp + 20 minutes); vm.prank(user2); - IQueueModule(address(vault)).requestClaim(false, 30_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(30_000e6); - vm.warp(block.timestamp + 20 minutes); + // Warp well past the min epoch duration (also keeps NAV stale) + vm.warp(block.timestamp + 7 days + 1); uint256 user2UsdcBefore = usdc.balanceOf(user2); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user2); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 user2UsdcAfter = usdc.balanceOf(user2); assertGt(user2UsdcAfter, user2UsdcBefore, "settlement works with stale NAV"); diff --git a/test/unit/core/ExitEngine_StressTest.t.sol b/test/unit/core/ExitEngine_StressTest.t.sol index 209dd1d..d456020 100644 --- a/test/unit/core/ExitEngine_StressTest.t.sol +++ b/test/unit/core/ExitEngine_StressTest.t.sol @@ -12,13 +12,21 @@ import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTes import { ExitEngineLib } from "../../../src/core/libraries/ExitEngineLib.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function cancelClaim(uint256 claimId) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function nextClaimId() external view returns (uint256); - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function currentEpochId() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); function endEpochCrystallize() external; } @@ -33,8 +41,8 @@ interface IDepositFor { /// @title ExitEngine Stress Test - Multi-User 300M TVL /// @notice Tests ALL protocol paths under high TVL with multiple users: /// - Direct deposit + DepositRouter-style depositFor -/// - requestClaim(true) instant exit -/// - requestClaim(false) queued exit + keeper settlement +/// - requestInstantWithdrawal() instant exit +/// - requestEpochWithdrawal() queued exit + keeper settlement /// - forceWithdrawAll /// - Queue cleanup /// - Cap exhaustion + epoch rollover @@ -172,7 +180,7 @@ contract ExitEngine_StressTest is Test { vm.stopPrank(); // ═══════════════════════════════════════════════════════════════════════ - // PHASE 3: Instant claims (requestClaim(true)) + cap tracking + // PHASE 3: Instant claims (requestInstantWithdrawal()) + cap tracking // ═══════════════════════════════════════════════════════════════════════ console2.log("=== PHASE 3: Instant claims + cap ==="); @@ -183,18 +191,18 @@ contract ExitEngine_StressTest is Test { // User0 claims 10M instant uint256 gasStart = gasleft(); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 10_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(10_000_000e6); uint256 gasUsed = gasStart - gasleft(); - console2.log("requestClaim(true, 10M) gas:", gasUsed); + console2.log("requestInstantWithdrawal(10M) gas:", gasUsed); assertLt(gasUsed, GAS_LIMIT, "instant claim gas < 5M"); // User1 claims 10M instant vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(true, 10_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(10_000_000e6); // User2 claims 10M instant — should be near cap vm.prank(users[2]); - IQueueModule(address(vault)).requestClaim(true, 10_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(10_000_000e6); uint256 supplyAfterInstant = vault.totalSupply(); assertLt(supplyAfterInstant, supplyBefore, "supply decreased from instant claims"); @@ -206,13 +214,13 @@ contract ExitEngine_StressTest is Test { console2.log("=== PHASE 4: Cap exhaustion ==="); - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); // User3 tries instant 5M — should queue (cap nearly exhausted) vm.prank(users[3]); - IQueueModule(address(vault)).requestClaim(true, 5_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(5_000_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // If cap was exhausted, claim was queued if (pendingAfter > pendingBefore) { console2.log("Cap exhausted - claim queued. Pending:", pendingAfter / 1e6); @@ -221,19 +229,22 @@ contract ExitEngine_StressTest is Test { } // ═══════════════════════════════════════════════════════════════════════ - // PHASE 5: Queued claims (requestClaim(false)) — multiple users + // PHASE 5: Queued claims (requestEpochWithdrawal()) — multiple users // ═══════════════════════════════════════════════════════════════════════ console2.log("=== PHASE 5: Queued claims ==="); - // Users 4-7 queue 5M each + // Users 4-7 queue 5M each into the same epoch + uint256[4] memory queuedClaimIds; + uint256 queuedEpochId; for (uint256 i = 4; i <= 7; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(false, 5_000_000e6); + (queuedEpochId, queuedClaimIds[i - 4]) = + IQueueModule(address(vault)).requestEpochWithdrawal(5_000_000e6); } - uint256 queueLen = IQueueModule(address(vault)).queueLength(); - uint256 pendingTotal = IQueueModule(address(vault)).pendingShares(); + uint256 queueLen = IQueueModule(address(vault)).outstandingClaimCount(); + uint256 pendingTotal = IQueueModule(address(vault)).totalEscrowedShares(); console2.log("Queue length:", queueLen); console2.log("Pending shares:", pendingTotal / 1e6, "M"); assertGt(queueLen, 0, "queue has claims"); @@ -247,12 +258,20 @@ contract ExitEngine_StressTest is Test { uint256 feeCollectorBefore = vault.balanceOf(feeCollector); uint256 supplyBeforeSettle = vault.totalSupply(); + vm.warp(block.timestamp + 7 days + 1); gasStart = gasleft(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(queuedEpochId); gasUsed = gasStart - gasleft(); - console2.log("settleFeesAndProcessQueue(25) gas:", gasUsed); + console2.log("closeCurrentEpoch + fundEpoch gas:", gasUsed); assertLt(gasUsed, GAS_LIMIT, "settle gas < 5M"); + // Each user self-claims (pull-based) + for (uint256 i = 4; i <= 7; i++) { + vm.prank(users[i]); + IQueueModule(address(vault)).claimEpochAssets(queuedEpochId, queuedClaimIds[i - 4]); + } + uint256 feeCollectorAfter = vault.balanceOf(feeCollector); uint256 supplyAfterSettle = vault.totalSupply(); @@ -263,14 +282,9 @@ contract ExitEngine_StressTest is Test { assertGe(feeCollectorAfter, feeCollectorBefore, "feeCollector got fee shares"); console2.log("FeeCollector shares:", feeCollectorAfter / 1e6); - // Settle remaining if any - uint256 remaining = IQueueModule(address(vault)).queueLength(); - if (remaining > 0) { - console2.log("Remaining in queue:", remaining); - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); - remaining = IQueueModule(address(vault)).queueLength(); - console2.log("After second settle:", remaining); - } + // All 4 queued claims settled -- verify no zombies + uint256 remaining = IQueueModule(address(vault)).outstandingClaimCount(); + console2.log("Remaining in queue:", remaining); // ═══════════════════════════════════════════════════════════════════════ // PHASE 7: Epoch rollover + fresh claims @@ -283,7 +297,7 @@ contract ExitEngine_StressTest is Test { // After epoch roll, fresh cap available uint256 user8UsdcBefore = usdc.balanceOf(users[8]); vm.prank(users[8]); - IQueueModule(address(vault)).requestClaim(true, 5_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(5_000_000e6); uint256 user8UsdcAfter = usdc.balanceOf(users[8]); assertGt(user8UsdcAfter, user8UsdcBefore, "instant claim succeeded after epoch roll"); console2.log("User8 received:", (user8UsdcAfter - user8UsdcBefore) / 1e6, "USDC"); @@ -339,8 +353,8 @@ contract ExitEngine_StressTest is Test { console2.log("Final assets:", finalAssets / 1e6, "USDC"); console2.log("Final supply:", finalSupply / 1e6, "shares"); console2.log("FeeCollector shares:", finalFeeShares / 1e6); - console2.log("Queue length:", IQueueModule(address(vault)).queueLength()); - console2.log("Pending shares:", IQueueModule(address(vault)).pendingShares() / 1e6); + console2.log("Queue length:", IQueueModule(address(vault)).outstandingClaimCount()); + console2.log("Pending shares:", IQueueModule(address(vault)).totalEscrowedShares() / 1e6); // INVARIANT: supply < initial (exits happened) assertLt(finalSupply, 300_001_000e6, "supply decreased from exits"); @@ -368,57 +382,69 @@ contract ExitEngine_StressTest is Test { console2.log("=== Keeper Gas at 300M TVL ==="); console2.log("Total assets:", vault.totalAssets() / 1e6, "USDC"); - // --- requestClaim(true) gas --- + // --- requestInstantWithdrawal gas --- uint256 g; g = gasleft(); vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 1_000_000e6); - console2.log("requestClaim(true, 1M):", g - gasleft()); + IQueueModule(address(vault)).requestInstantWithdrawal(1_000_000e6); + console2.log("requestInstantWithdrawal(1M):", g - gasleft()); assertLt(g - gasleft(), GAS_LIMIT, "instant claim < 5M"); - // --- requestClaim(false) gas --- + // --- requestEpochWithdrawal gas --- g = gasleft(); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); - console2.log("requestClaim(false, 1M):", g - gasleft()); + (uint256 epochId1, uint256 claimId1) = + IQueueModule(address(vault)).requestEpochWithdrawal(1_000_000e6); + console2.log("requestEpochWithdrawal(1M):", g - gasleft()); assertLt(g - gasleft(), GAS_LIMIT, "queued claim < 5M"); - // --- settleFeesAndProcessQueue gas (1 claim) --- + // --- closeCurrentEpoch + fundEpoch gas (1 claim) --- + // NOTE: track elapsed time via a local `t` instead of repeated + // `block.timestamp + X` re-reads -- this codebase's test suite has a + // known Foundry quirk where a second vm.warp() computed from + // block.timestamp mid-test can behave as if time had reset. + uint256 t = block.timestamp; + t += 7 days + 1; + vm.warp(t); g = gasleft(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); - console2.log("settleFeesAndProcessQueue(10):", g - gasleft()); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId1); + console2.log("closeCurrentEpoch+fundEpoch (1 claim):", g - gasleft()); assertLt(g - gasleft(), GAS_LIMIT, "settle < 5M"); - // --- Queue 20 claims then settle batch --- + // --- claimEpochAssets gas (per-user self-claim, pull-based) --- + g = gasleft(); + vm.prank(users[1]); + IQueueModule(address(vault)).claimEpochAssets(epochId1, claimId1); + console2.log("claimEpochAssets:", g - gasleft()); + assertLt(g - gasleft(), GAS_LIMIT, "claim < 5M"); + + // --- Queue 9 claims into a new epoch then close+fund (O(1) regardless of depth) --- for (uint256 i = 2; i < 8; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(false, 500_000e6); + IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); } // 3 more from same users + uint256 epochId2; for (uint256 i = 2; i < 5; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(false, 500_000e6); + (epochId2,) = IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); } + t += 7 days + 1; + vm.warp(t); g = gasleft(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId2); uint256 settleGas = g - gasleft(); - console2.log("settleFeesAndProcessQueue(25) batch:", settleGas); + console2.log("closeCurrentEpoch+fundEpoch (9 claims):", settleGas); assertLt(settleGas, GAS_LIMIT, "batch settle < 5M"); - // --- processQueuedRedemptions gas (no cap) --- - vm.prank(users[8]); - IQueueModule(address(vault)).requestClaim(false, 1_000_000e6); - - g = gasleft(); - IQueueModule(address(vault)).processQueuedRedemptions(10); - console2.log("processQueuedRedemptions(10):", g - gasleft()); - assertLt(g - gasleft(), GAS_LIMIT, "processQueued < 5M"); - // --- endEpochCrystallize gas --- vault.setPerfParamsUnsafe(10e16, 3600); usdc._mint(address(vault), 1_000_000e6); // simulate profit - vm.warp(block.timestamp + 1 days); + t += 1 days; + vm.warp(t); g = gasleft(); IQueueModule(address(vault)).endEpochCrystallize(); @@ -465,9 +491,30 @@ contract ExitEngine_StressTest is Test { // TEST: Multi-day simulation with deposits/claims/settlements // ═══════════════════════════════════════════════════════════════════════════ + /// @dev Tracks a queued claim across the multi-day lifecycle so it can be + /// self-claimed once its epoch is closed + funded. In the epoch model, + /// settlement is gated on the epoch's minimum duration (7 days by + /// default), not on-demand per keeper call like QueueModule -- so + /// claims queued across "Day 1" through "Day 4" all land in the SAME + /// open epoch and are settled together once, on "Day 8" (7+ cumulative + /// days later), rather than after each day. + struct DayClaim { + address user; + uint256 epochId; + uint256 claimId; + } + function test_stress_multiDay_lifecycle() public { console2.log("=== Multi-Day Lifecycle ==="); + DayClaim[] memory dayClaims = new DayClaim[](5); + uint256 nClaims; + // NOTE: track elapsed time via a local `t` instead of repeated + // `block.timestamp + X` re-reads -- this codebase's test suite has a + // known Foundry quirk where a second vm.warp() computed from + // block.timestamp mid-test can behave as if time had reset. + uint256 t = block.timestamp; + // DAY 1: Initial deposits to 100M for (uint256 i = 0; i < 10; i++) { _deposit(users[i], 10_000_000e6); @@ -476,17 +523,19 @@ contract ExitEngine_StressTest is Test { // DAY 1: Mix of instant + queued claims vm.prank(users[0]); - IQueueModule(address(vault)).requestClaim(true, 2_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(2_000_000e6); vm.prank(users[1]); - IQueueModule(address(vault)).requestClaim(false, 3_000_000e6); + { + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(3_000_000e6); + dayClaims[nClaims++] = DayClaim(users[1], epochId, claimId); + } vm.prank(users[2]); - IQueueModule(address(vault)).requestClaim(true, 1_000_000e6); - - // Keeper settles queue - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + IQueueModule(address(vault)).requestInstantWithdrawal(1_000_000e6); // DAY 2: More deposits + withdrawals - vm.warp(block.timestamp + 1 days); + t += 1 days; + vm.warp(t); // New deposits via depositFor (router pattern) _depositFor(router, users[0], 5_000_000e6); @@ -494,40 +543,43 @@ contract ExitEngine_StressTest is Test { // More claims vm.prank(users[3]); - IQueueModule(address(vault)).requestClaim(true, 500_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(500_000e6); vm.prank(users[4]); - IQueueModule(address(vault)).requestClaim(false, 2_000_000e6); - - // Keeper settles - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + { + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(2_000_000e6); + dayClaims[nClaims++] = DayClaim(users[4], epochId, claimId); + } // DAY 3: Ramp up to 200M - vm.warp(block.timestamp + 1 days); + t += 1 days; + vm.warp(t); for (uint256 i = 0; i < 10; i++) { _deposit(users[i], 10_000_000e6); } console2.log("Day 3 TVL:", vault.totalAssets() / 1e6); // DAY 4: Heavy exit pressure - vm.warp(block.timestamp + 1 days); + t += 1 days; + vm.warp(t); // 5 users instant claim 5M each for (uint256 i = 0; i < 5; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(true, 5_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(5_000_000e6); } // 3 users queue 3M each for (uint256 i = 5; i < 8; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(false, 3_000_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(3_000_000e6); + dayClaims[nClaims++] = DayClaim(users[i], epochId, claimId); } - // Keeper settles - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); - // DAY 7: Epoch rollover + crystallize - vm.warp(block.timestamp + 3 days); + t += 3 days; + vm.warp(t); // Simulate profit usdc._mint(address(vault), 200_000e6); @@ -542,20 +594,30 @@ contract ExitEngine_StressTest is Test { console2.log("Day 7 TVL:", vault.totalAssets() / 1e6); // DAY 8: Force withdraw + instant + queued - vm.warp(block.timestamp + 1 days); + t += 1 days; + vm.warp(t); vm.prank(users[9]); IForceWithdrawAll(address(vault)).forceWithdrawAll(users[9], 0); assertEq(vault.balanceOf(users[9]), 0, "user9 fully exited"); vm.prank(users[8]); - IQueueModule(address(vault)).requestClaim(true, 1_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(1_000_000e6); + + // Cumulative elapsed since Day 1 is now 7 days -- the epoch all the + // above queued claims landed in (never closed until now) is eligible. + IQueueModule(address(vault)).closeCurrentEpoch(); + uint256 settledEpochId = dayClaims[0].epochId; + IQueueModule(address(vault)).fundEpoch(settledEpochId); + for (uint256 i = 0; i < nClaims; i++) { + vm.prank(dayClaims[i].user); + IQueueModule(address(vault)).claimEpochAssets(dayClaims[i].epochId, dayClaims[i].claimId); + } + // Day 8's own queued claim lands in the epoch that just opened after + // the close above -- leave it outstanding (not yet eligible to close). vm.prank(users[7]); - IQueueModule(address(vault)).requestClaim(false, 2_000_000e6); - - // Final settle - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); + IQueueModule(address(vault)).requestEpochWithdrawal(2_000_000e6); // FINAL CHECKS uint256 finalAssets = vault.totalAssets(); @@ -565,7 +627,7 @@ contract ExitEngine_StressTest is Test { console2.log("Final TVL:", finalAssets / 1e6); console2.log("Final supply:", finalSupply / 1e6); console2.log("Fee shares:", finalFeeShares / 1e6); - console2.log("Queue:", IQueueModule(address(vault)).queueLength()); + console2.log("Queue:", IQueueModule(address(vault)).outstandingClaimCount()); assertGt(finalFeeShares, 0, "fees collected"); assertEq(vault.balanceOf(users[9]), 0, "user9 exited"); diff --git a/test/unit/core/FeeCollectorHarvestQueue.t.sol b/test/unit/core/FeeCollectorHarvestQueue.t.sol new file mode 100644 index 0000000..13a4477 --- /dev/null +++ b/test/unit/core/FeeCollectorHarvestQueue.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// ───────────────────────────────────────────────────────────────────────────── +// AUTO_HARVEST fallback bookkeeping. +// +// Two defects, both introduced by the epoch cutover and both of which brick fee +// distribution for a share token with no recovery short of a governance mode +// change: +// +// 1. distribute() refused a second queued harvest for the same token, so one +// epoch that never funded blocked the token outright. +// 2. An instant harvest that rounded down to zero underlying was routed down +// the fallback path, where it recorded the (0, 0) sentinel that +// requestInstantWithdrawal returns for inline settlements as if it were a +// real claim handle -- permanently unclaimable. +// ───────────────────────────────────────────────────────────────────────────── + +import { Test } from "forge-std/Test.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { CoreHarness } from "../../helpers/CoreHarness.sol"; +import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; +import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; +import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; +import { FeeCollector } from "../../../src/core/modules/FeeCollector.sol"; +import { EpochQueueStorage } from "../../../src/core/modules/EpochedQueueModule.sol"; + +interface IHarvestQueue { + function requestEpochWithdrawal(uint256 shares) external returns (uint256, uint256); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory); + function currentEpochId() external view returns (uint256); +} + +contract FeeCollectorHarvestQueue is Test { + CoreHarness public vault; + ERC20Mock public usdc; + MockParamsProvider public params; + FeeCollector public collector; + + address public gov = address(0x600D); + address public treasury = address(0x7EA5); + address public ops = address(0x0B5); + address public reserve = address(0xEE5E); + address public alice = address(0xA001); + + uint256 internal t; + + function setUp() public { + usdc = new ERC20Mock("USDC", "USDC", 6); + params = new MockParamsProvider(); + params.setLockPeriod(0); + params.setCapPerEpochBps(10000); + + collector = new FeeCollector(gov, treasury, ops, reserve, 5000, 300, 5000); + + vault = new CoreHarness( + IERC20Metadata(address(usdc)), "Vault", "vUSDC", + address(this), address(collector), address(params) + ); + vault.setBufferManagerUnsafe(address(new MockBufferManagerForTests(address(vault)))); + // Exit fee only: deposits stay clean so share maths is easy to follow. + vault.setFeeParamsUnsafe(0, 500, address(collector)); + vault.setExitFeesUnsafe(500, 500, 150); + vault.setEpochDurationUnsafe(7 days); + vault.unpause(); + + vm.startPrank(gov); + collector.setShareConfig(address(vault), FeeCollector.ShareMode.AUTO_HARVEST); + collector.setMinDistribution(address(usdc), 0); + vm.stopPrank(); + + usdc._mint(alice, 100_000_000e6); + vm.prank(alice); + usdc.approve(address(vault), type(uint256).max); + + t = block.timestamp; + } + + function _q() internal view returns (IHarvestQueue) { + return IHarvestQueue(address(vault)); + } + + function _warp(uint256 d) internal { + t += d; + vm.warp(t); + } + + /// @dev Exit fee shares reach the collector in a batch at closeCurrentEpoch, + /// not at request time, so a full close is needed to accrue them. + function _accrueFeeShares(uint256 shares) internal { + vm.prank(alice); + _q().requestEpochWithdrawal(shares); + _warp(7 days + 1); + _q().closeCurrentEpoch(); + } + + function test_secondQueuedHarvest_doesNotRevertDistribute() public { + vm.prank(alice); + vault.deposit(1_000_000e6, alice); + + _accrueFeeShares(200_000e6); + assertGt(vault.balanceOf(address(collector)), 0, "collector holds fee shares"); + + // Starve the instant path so the harvest must queue. + params.setCapPerEpochBps(1); + + collector.distribute(address(vault)); + assertEq(collector.pendingHarvestClaimCount(address(vault)), 1, "first harvest queued"); + + // More fee shares, and a second distribute for the same token. This is + // the call that used to revert "harvest already queued". + _accrueFeeShares(200_000e6); + + collector.distribute(address(vault)); + assertEq( + collector.pendingHarvestClaimCount(address(vault)), 2, + "second harvest queues alongside the first instead of reverting" + ); + + // Both claim handles are distinct and preserved -- the old single-slot + // bookkeeping would have overwritten the first with the second. + (uint256 e0, uint256 c0) = collector.pendingHarvestClaimAt(address(vault), 0); + (uint256 e1, uint256 c1) = collector.pendingHarvestClaimAt(address(vault), 1); + assertTrue(e0 != e1 || c0 != c1, "the two claims are tracked separately"); + } + + function test_harvestQueued_settlesReadyClaimsAndLeavesTheRest() public { + vm.prank(alice); + vault.deposit(1_000_000e6, alice); + + _accrueFeeShares(200_000e6); + params.setCapPerEpochBps(1); + + // First harvest queues into the currently open epoch. + uint256 epochToFund = _q().currentEpochId(); + collector.distribute(address(vault)); + + // Close and fund that epoch, so the first claim becomes claimable. + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(epochToFund); + + // A second harvest queues into the NEXT epoch, which is still open and + // therefore not claimable. + _accrueFeeShares(200_000e6); + collector.distribute(address(vault)); + assertEq(collector.pendingHarvestClaimCount(address(vault)), 2, "two queued"); + + uint256 treasuryBefore = usdc.balanceOf(treasury); + collector.harvestQueued(address(vault)); + + assertEq( + collector.pendingHarvestClaimCount(address(vault)), 1, + "the ready claim settled, the unfunded one stayed queued" + ); + assertGt( + usdc.balanceOf(treasury), treasuryBefore, + "underlying actually reached the treasury" + ); + } + + /// @notice An instant harvest that rounds down to zero must not record a + /// pending claim against the (0, 0) inline-settlement sentinel. + function test_dustInstantHarvest_doesNotQueueAPhantomClaim() public { + vm.prank(alice); + vault.deposit(1_000_000e6, alice); + + // One wei of shares to the collector: the instant path settles it and + // convertToAssets rounds the payout down to zero. + vm.prank(alice); + vault.transfer(address(collector), 1); + assertEq(vault.balanceOf(address(collector)), 1, "collector holds dust"); + + collector.distribute(address(vault)); + + assertEq( + collector.pendingHarvestClaimCount(address(vault)), 0, + "no phantom claim recorded for a dust settlement" + ); + assertEq( + collector.pendingHarvestShares(address(vault)), 0, + "pendingHarvestShares stays clear, so distribute() is not bricked" + ); + + // And the token is still distributable afterwards. + vm.prank(alice); + _q().requestEpochWithdrawal(200_000e6); + collector.distribute(address(vault)); + } + // ═════════════════════════════════════════════════════════════════════════ + // DEGRADATION: distribute() must never be the thing that breaks. + // ═════════════════════════════════════════════════════════════════════════ + + /// @notice Fee accruals below the queue's minClaimAmount used to be handled + /// by exempting feeCollector inside the floor check. The exemption + /// is gone; the collector absorbs the refusal instead, leaving the + /// shares in place to accumulate. + function test_subFloorHarvest_defersInsteadOfReverting() public { + params.setMinClaimAmount(100_000e6); // far above anything accrued here + params.setCapPerEpochBps(1); // and no instant route either + + vm.prank(alice); + vault.deposit(1_000_000e6, alice); + _accrueFeeShares(200_000e6); + + uint256 collectorShares = vault.balanceOf(address(collector)); + assertGt(collectorShares, 0, "collector holds fee shares"); + + // Does not revert. + collector.distribute(address(vault)); + + assertEq( + vault.balanceOf(address(collector)), collectorShares, + "shares stay put, ready to be retried with a larger balance" + ); + assertEq( + collector.pendingHarvestClaimCount(address(vault)), 0, + "nothing queued, so nothing to strand" + ); + assertEq( + collector.pendingHarvestShares(address(vault)), 0, + "and the collector is not left in a half-queued state" + ); + + // Once the floor is clearable, the same call goes through. + params.setMinClaimAmount(0); + collector.distribute(address(vault)); + assertEq( + collector.pendingHarvestClaimCount(address(vault)), 1, + "the deferred harvest queues on the next attempt" + ); + } + + /// @notice A full pending list defers too, and the shares are never handed + /// to the vault -- an untracked claim in escrow is the failure the + /// single-slot bookkeeping was guarding against in the first place. + function test_fullPendingList_defersAndNeverStrandsShares() public { + vm.prank(alice); + vault.deposit(50_000_000e6, alice); + params.setCapPerEpochBps(1); + + uint256 cap = collector.MAX_PENDING_HARVEST_CLAIMS(); + for (uint256 i = 0; i < cap; i++) { + _accrueFeeShares(200_000e6); + collector.distribute(address(vault)); + } + assertEq(collector.pendingHarvestClaimCount(address(vault)), cap, "list is full"); + + _accrueFeeShares(200_000e6); + uint256 sharesBefore = vault.balanceOf(address(collector)); + uint256 escrowBefore = vault.balanceOf(address(vault)); + + // Does not revert. + collector.distribute(address(vault)); + + assertEq( + collector.pendingHarvestClaimCount(address(vault)), cap, + "nothing appended past the cap" + ); + assertEq( + vault.balanceOf(address(collector)), sharesBefore, + "shares stayed with the collector" + ); + assertEq( + vault.balanceOf(address(vault)), escrowBefore, + "and never entered vault escrow untracked" + ); + } +} diff --git a/test/unit/core/FuzzWithdraw_NetExact.t.sol b/test/unit/core/FuzzWithdraw_NetExact.t.sol index ee3256f..f4ab087 100644 --- a/test/unit/core/FuzzWithdraw_NetExact.t.sol +++ b/test/unit/core/FuzzWithdraw_NetExact.t.sol @@ -8,20 +8,22 @@ import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; import { ModuleSetter } from "../../helpers/ModuleSetter.sol"; import { ExitEngineLib } from "../../../src/core/libraries/ExitEngineLib.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); } /** * @title FuzzWithdraw_NetExact - * @notice Audit-grade fuzz: requestClaim(true) transfers correct USDC to user + * @notice Audit-grade fuzz: requestInstantWithdrawal() transfers correct USDC to user * @dev withdraw() always reverts AsyncWithdrawalRequired in queued protocol. - * This test validates instant claim via requestClaim(true). + * This test validates instant claim via requestInstantWithdrawal(). * * Invariants: * 1. shares consumed == requested shares @@ -32,7 +34,7 @@ interface IQueueModule { contract FuzzWithdraw_NetExact is Test { CoreHarness internal vault; ERC20Mock internal usdc; - QueueModule internal queueModule; + EpochedQueueModule internal queueModule; address internal user = address(0xBEEF); address internal treasury = address(0xFEE); @@ -52,8 +54,8 @@ contract FuzzWithdraw_NetExact is Test { MockBufferManagerForTests mockBM = new MockBufferManagerForTests(address(vault)); vault.setBufferManagerUnsafe(address(mockBM)); - // Wire QueueModule - queueModule = new QueueModule(); + // Wire EpochedQueueModule + queueModule = new EpochedQueueModule(); bytes4[] memory queueSels = SelectorLib.getQueueModuleSelectors(); ModuleSetter.setModulesSame( address(vault), queueSels, address(queueModule), SelectorLib.ROLE_PUBLIC @@ -80,7 +82,7 @@ contract FuzzWithdraw_NetExact is Test { vm.stopPrank(); } - /// @notice requestClaim(true) instant claim invariants + /// @notice requestInstantWithdrawal() instant claim invariants function testFuzz_requestClaimInstant_invariants(uint256 shares, uint16 witBps) public { witBps = uint16(bound(uint256(witBps), 0, 500)); // 0-5% shares = bound(shares, 1e6, 1_000_000e6); // 1 .. 1M shares @@ -101,7 +103,7 @@ contract FuzzWithdraw_NetExact is Test { uint256 usdcBefore = usdc.balanceOf(user); // Instant claim - IQueueModule(address(vault)).requestClaim(true, shares); + IQueueModule(address(vault)).requestInstantWithdrawal(shares); uint256 sharesAfter = vault.balanceOf(user); uint256 supplyAfter = vault.totalSupply(); diff --git a/test/unit/core/Hardening_Consistency.t.sol b/test/unit/core/Hardening_Consistency.t.sol index 0ddc933..f35d959 100644 --- a/test/unit/core/Hardening_Consistency.t.sol +++ b/test/unit/core/Hardening_Consistency.t.sol @@ -11,17 +11,21 @@ import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; import { SelectorLib } from "../../../src/core/libraries/SelectorLib.sol"; -import { QueueModule } from "../../../src/core/modules/QueueModule.sol"; +import { EpochedQueueModule } from "../../../src/core/modules/EpochedQueueModule.sol"; import { AdminModule } from "../../../src/core/modules/AdminModule.sol"; import { ERC4626Module } from "../../../src/core/modules/ERC4626Module.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function processQueuedRedemptions(uint256 maxClaims) external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) external returns (uint256 epochId, uint256 claimId); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); function endEpochCrystallize() external; - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); } /// @title Hardening: canX/performX Consistency + Event Correctness + Wiring @@ -66,13 +70,18 @@ contract Hardening_Consistency is Test { function test_canSettle_vs_settle() public { // Create a claim to make canSettle true vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); + IQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); + + // canSettle() reflects the epoch queue: an epoch is only settle-ready + // once it has run for at least the minimum epoch duration + // (MockParamsProvider.getQueueParams sets epochDuration = 7 days). + vm.warp(block.timestamp + 7 days + 1); bool canSettle = vault.canSettle(); assertTrue(canSettle, "canSettle should be true with pending claims"); // performX should succeed - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + IQueueModule(address(vault)).closeCurrentEpoch(); // No revert = success } @@ -92,14 +101,17 @@ contract Hardening_Consistency is Test { function test_settle_noRevert_whenQueueEmpty() public { // No claims in queue - assertEq(IQueueModule(address(vault)).queueLength(), 0, "queue empty"); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 0, "queue empty"); // Should not revert even if nothing to settle - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); } function test_processQueuedRedemptions_noRevert_whenEmpty() public { - IQueueModule(address(vault)).processQueuedRedemptions(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(0); } // ═════════════════════════════════════════════════════���═════════════════════ @@ -110,7 +122,7 @@ contract Hardening_Consistency is Test { vm.recordLogs(); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 500_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(500_000e6); Vm.Log[] memory logs = vm.getRecordedLogs(); @@ -141,53 +153,49 @@ contract Hardening_Consistency is Test { } } assertTrue(foundFeePaid, "FeePaid event emitted"); - - // Check for ClaimSettled event - bytes32 claimSettledSig = keccak256("ClaimSettled(uint256,address,uint256)"); - bool foundSettled = false; - for (uint256 i; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == claimSettledSig) { - foundSettled = true; - break; - } - } - assertTrue(foundSettled, "ClaimSettled event emitted"); } function test_queuedClaim_emitsClaimQueued() public { vm.recordLogs(); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 500_000e6); + IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 claimQueuedSig = keccak256("ClaimQueued(uint256)"); + bytes32 requestedSig = + keccak256("EpochWithdrawalRequested(uint256,uint256,address,uint256,uint256,uint256)"); bool found = false; uint256 count = 0; for (uint256 i; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == claimQueuedSig) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == requestedSig) { found = true; count++; } } - assertTrue(found, "ClaimQueued emitted"); - assertEq(count, 1, "ClaimQueued exactly once"); + assertTrue(found, "EpochWithdrawalRequested emitted"); + assertEq(count, 1, "EpochWithdrawalRequested exactly once"); } function test_settlement_emitsFeePaidAndSettled() public { // Queue claim vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 200_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(200_000e6); + + vm.warp(block.timestamp + 7 days + 1); vm.recordLogs(); - // Settle - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + // Close: fee shares (if any) transfer here + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); Vm.Log[] memory logs = vm.getRecordedLogs(); - // FeePaid should be emitted during settlement + // FeePaid should be emitted during close (fee shares leave escrow) bytes32 feePaidSig = keccak256("FeePaid(address,address,uint256)"); bool foundFeePaid = false; for (uint256 i; i < logs.length; i++) { @@ -198,16 +206,16 @@ contract Hardening_Consistency is Test { } assertTrue(foundFeePaid, "FeePaid emitted on settlement"); - // ClaimSettled - bytes32 claimSettledSig = keccak256("ClaimSettled(uint256,address,uint256)"); + // EpochAssetsClaimed emitted when the user pulls their claim + bytes32 claimedSig = keccak256("EpochAssetsClaimed(uint256,uint256,address,uint256,uint256)"); bool foundSettled = false; for (uint256 i; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == claimSettledSig) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == claimedSig) { foundSettled = true; break; } } - assertTrue(foundSettled, "ClaimSettled emitted on settlement"); + assertTrue(foundSettled, "EpochAssetsClaimed emitted on settlement"); } // ════════════════════════════��═════════════════════════════════════════���════ @@ -217,23 +225,27 @@ contract Hardening_Consistency is Test { function test_criticalSelectors_wired() public view { // Queue selectors assertTrue( - vault.moduleOf(QueueModule.requestClaim.selector) != address(0), - "requestClaim wired" + vault.moduleOf(EpochedQueueModule.requestEpochWithdrawal.selector) != address(0), + "requestEpochWithdrawal wired" + ); + assertTrue( + vault.moduleOf(EpochedQueueModule.cancelEpochWithdrawal.selector) != address(0), + "cancelEpochWithdrawal wired" ); assertTrue( - vault.moduleOf(QueueModule.cancelClaim.selector) != address(0), - "cancelClaim wired" + vault.moduleOf(EpochedQueueModule.closeCurrentEpoch.selector) != address(0), + "closeCurrentEpoch wired" ); assertTrue( - vault.moduleOf(QueueModule.settleFeesAndProcessQueue.selector) != address(0), - "settleFeesAndProcessQueue wired" + vault.moduleOf(EpochedQueueModule.fundEpoch.selector) != address(0), + "fundEpoch wired" ); assertTrue( - vault.moduleOf(QueueModule.processQueuedRedemptions.selector) != address(0), - "processQueuedRedemptions wired" + vault.moduleOf(EpochedQueueModule.requestInstantWithdrawal.selector) != address(0), + "requestInstantWithdrawal wired" ); assertTrue( - vault.moduleOf(QueueModule.endEpochCrystallize.selector) != address(0), + vault.moduleOf(EpochedQueueModule.endEpochCrystallize.selector) != address(0), "endEpochCrystallize wired" ); @@ -275,7 +287,7 @@ contract Hardening_Consistency is Test { vault.previewMint(1e6); // Module-routed views should still work - IQueueModule(address(vault)).queueLength(); - IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).outstandingClaimCount(); + IQueueModule(address(vault)).totalEscrowedShares(); } } diff --git a/test/unit/core/Hardening_GasAndChaos.t.sol b/test/unit/core/Hardening_GasAndChaos.t.sol index 33c0461..e712dfb 100644 --- a/test/unit/core/Hardening_GasAndChaos.t.sol +++ b/test/unit/core/Hardening_GasAndChaos.t.sol @@ -8,15 +8,31 @@ import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; +import { StrategyMock } from "../../helpers/StrategyMock.sol"; +import { MockPriceOracleMiddleware } from "../../helpers/MockPriceOracleMiddleware.sol"; + +interface IDeploy { + function deployToStrategies(uint256 maxAmount) external; +} +import { EpochQueueStorage } from "../../../src/core/modules/EpochedQueueModule.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function cancelClaim(uint256 claimId) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function nextClaimId() external view returns (uint256); - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory); + function currentEpochId() external view returns (uint256); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); function endEpochCrystallize() external; } @@ -80,30 +96,102 @@ contract Hardening_GasAndChaos is Test { // Seed vault with enough TVL _fundAndDeposit(owner, 100_000_000e6); - // Create N users and queue claims + // Create N users and queue claims into the same epoch + uint256 epochId; for (uint256 i = 0; i < queueSize; i++) { address user = address(uint160(0xC000 + i)); _fundAndDeposit(user, 10_000e6); vm.prank(user); - IQueueModule(address(vault)).requestClaim(false, 5_000e6); + (epochId,) = IQueueModule(address(vault)).requestEpochWithdrawal(5_000e6); } - uint256 ql = IQueueModule(address(vault)).queueLength(); + uint256 ql = IQueueModule(address(vault)).outstandingClaimCount(); console2.log("Queue size:", ql); - // Measure settle gas with batch=25 + // Measure fundEpoch() gas: unlike QueueModule's per-batch keeper scan + // (cost scales with min(batchSize, queueDepth)), a single fundEpoch() + // call pulls liquidity for the ENTIRE epoch regardless of how many + // claims it contains — gas here should stay roughly flat as queueSize grows. + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + uint256 g = gasleft(); + IQueueModule(address(vault)).fundEpoch(epochId); + uint256 gasUsed = g - gasleft(); + console2.log("fundEpoch() gas:", gasUsed); + + // fundEpoch() never touches per-claim storage, so its cost must not + // move with queue depth. Asserted, not just logged: the flat-gas claim + // is one of the load-bearing reasons the epoch model replaced the + // per-claim keeper scan, and a characterization that only prints a + // number cannot catch a regression. + assertLt(gasUsed, FUND_EPOCH_GAS_CEILING, "fundEpoch cost must stay flat in queue depth"); + } + + /// @dev Comfortably above the measured ~39k for the pre-funded path and the + /// strategy-redeem path below, far below anything that would scale + /// with claim count. + uint256 internal constant FUND_EPOCH_GAS_CEILING = 400_000; + + /// @notice The characterization above pre-funds the vault, so fundEpoch + /// short-circuits before its liquidity waterfall ever runs. This + /// exercises the branch that actually pulls: hot is short, the + /// router has to redeem from a strategy, and the epoch only reaches + /// FUNDED because of that pull. + function test_fundEpoch_executesStrategyRedeemWaterfall() public { + StrategyMock strat = new StrategyMock(address(usdc)); + vault.addStrategyUnsafe(address(strat)); + + // StrategyRouter.executeRedeemBatch values the asset through + // OracleValuationLib and reverts OracleNotConfigured without a fresh + // oracle -- for 6dp USDC too, not just 18dp assets. fundEpoch swallows + // that revert, so without this the waterfall silently no-ops. + MockPriceOracleMiddleware oracle = new MockPriceOracleMiddleware(); + oracle.setPrice(address(usdc), 1e18); + params.setOracle(address(oracle)); + + address user = address(0xC0FFEE); + _fundAndDeposit(user, 1_000_000e6); + + vm.prank(user); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); + + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + uint256 owed = IQueueModule(address(vault)).epochData(epochId).totalNetAssets; + + // Refresh the quote after the warp: the staleness window is an hour and + // epochs are days long, so at fund time the oracle must have been + // updated since the epoch closed or the redeem reverts and the epoch + // silently stays CLOSED. + oracle.setPrice(address(usdc), 1e18); + + // Push hot into the strategy through the real deploy path, so the + // router's own accounting matches and a redeem can actually pull it + // back. A raw transfer would leave the router thinking the strategy + // holds nothing. + IDeploy(address(vault)).deployToStrategies(type(uint256).max); + assertGt(usdc.balanceOf(address(strat)), 0, "strategy is funded"); + assertLt(usdc.balanceOf(address(vault)), owed, "hot is genuinely short before funding"); + uint256 g = gasleft(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + IQueueModule(address(vault)).fundEpoch(epochId); uint256 gasUsed = g - gasleft(); - console2.log("settleFeesAndProcessQueue(25) gas:", gasUsed); + console2.log("fundEpoch() gas with strategy redeem:", gasUsed); - uint256 remaining = IQueueModule(address(vault)).queueLength(); - console2.log("Remaining after batch:", remaining); + assertTrue( + IQueueModule(address(vault)).epochData(epochId).state + == EpochQueueStorage.EpochState.Funded, + "the waterfall pulled enough to fund the epoch" + ); + assertLt(gasUsed, FUND_EPOCH_GAS_CEILING, "redeem path stays within the same ceiling"); - // Gas report — characterization, not hard assertion - // Queue 100: ~3.6M, Queue 500: ~13.7M - // Safe operating range: queue <= 100 for batch=25 within 5M gas - console2.log("Gas limit check: gasUsed=", gasUsed, "limit=5000000"); + // And the claimant is actually paid out of the redeemed liquidity. + uint256 before = usdc.balanceOf(user); + vm.prank(user); + uint256 paid = IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); + assertEq(usdc.balanceOf(user) - before, paid, "claim paid from redeemed assets"); + assertGt(paid, 0, "and it was a real payout"); } // ═══════════════════════════════════════════════════════════════════════════ @@ -125,17 +213,22 @@ contract Hardening_GasAndChaos is Test { // Instant claim — small amount uint256 usdcBefore = usdc.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 100e6); + IQueueModule(address(vault)).requestInstantWithdrawal(100e6); uint256 received = usdc.balanceOf(user1) - usdcBefore; assertGt(received, 0, "received USDC on tiny TVL"); console2.log("Instant claim 100 shares, received:", received); - // Queued claim + settle + // Queued claim + settle (close + fund the epoch, then user2 self-claims) vm.prank(user2); - IQueueModule(address(vault)).requestClaim(false, 100e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(100e6); uint256 usdcBefore2 = usdc.balanceOf(user2); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user2); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); uint256 received2 = usdc.balanceOf(user2) - usdcBefore2; assertGt(received2, 0, "settled on tiny TVL"); console2.log("Queued settle 100 shares, received:", received2); @@ -172,26 +265,31 @@ contract Hardening_GasAndChaos is Test { } console2.log("TVL after deposits:", vault.totalAssets() / 1e6, "M"); - // Wave 1: mix of instant + queued claims + // Wave 1: mix of instant + queued claims (even i = instant, odd i = queued) + uint256 epochId; + bool hasQueuedClaims; for (uint256 i = 0; i < 10; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(i % 2 == 0, 200_000e6); + if (i % 2 == 0) { + IQueueModule(address(vault)).requestInstantWithdrawal(200_000e6); + } else { + (epochId,) = IQueueModule(address(vault)).requestEpochWithdrawal(200_000e6); + hasQueuedClaims = true; + } } - // Wave 2: some cancels + new claims - for (uint256 i = 0; i < 5; i++) { - uint256 claimId = i * 2 + 2; // even claims (queued ones) - // Only cancel if it was queued (i%2==1 → queued) + // Settle batch: close + fund the epoch the queued (odd-i) claims landed in + if (hasQueuedClaims) { + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); } - // Settle batch - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); - // Wave 3: epoch rollover + fresh claims vm.warp(block.timestamp + 7 days + 1); for (uint256 i = 10; i < 15; i++) { vm.prank(users[i]); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); } // Wave 4: force exits @@ -201,8 +299,16 @@ contract Hardening_GasAndChaos is Test { assertEq(vault.balanceOf(users[i]), 0, "force exit complete"); } - // Final settle - IQueueModule(address(vault)).settleFeesAndProcessQueue(50); + // Final settle: close + fund whatever landed in the queue during Wave 3 + // (instant claims that fell back to the queue due to cap exhaustion) + if ( + IQueueModule(address(vault)).canCloseCurrentEpoch() + && IQueueModule(address(vault)).currentEpochClaimCount() > 0 + ) { + uint256 curEpochId = IQueueModule(address(vault)).currentEpochId(); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(curEpochId); + } // Crystallize usdc._mint(address(vault), 100_000e6); @@ -218,7 +324,7 @@ contract Hardening_GasAndChaos is Test { console2.log("Final TVL:", finalAssets / 1e6, "M"); console2.log("Final supply:", finalSupply / 1e6, "M"); console2.log("Fee shares:", feeShares); - console2.log("Queue:", IQueueModule(address(vault)).queueLength()); + console2.log("Outstanding claims:", IQueueModule(address(vault)).outstandingClaimCount()); assertGt(feeShares, 0, "fees collected"); assertLt(finalSupply, 20_000_000e6, "supply < initial"); @@ -240,17 +346,17 @@ contract Hardening_GasAndChaos is Test { // 50 cycles of queue → cancel → re-queue for (uint256 i = 0; i < 50; i++) { vm.prank(user); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); - uint256 claimId = IQueueModule(address(vault)).nextClaimId(); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); vm.prank(user); - IQueueModule(address(vault)).cancelClaim(claimId); + IQueueModule(address(vault)).cancelEpochWithdrawal(epochId, claimId); } // No leak assertEq(vault.balanceOf(user), initialShares, "no share leak after 50 cancel cycles"); assertEq(vault.totalSupply(), initialSupply, "no supply leak"); - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "no pending leak"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "no pending leak"); } // ═══════════════════════════════════════════════════════════════════════════ @@ -267,13 +373,13 @@ contract Hardening_GasAndChaos is Test { // This should settle (within cap) vm.prank(user); - IQueueModule(address(vault)).requestClaim(true, 999_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(999_000e6); // This should queue (over cap ~1.5M) - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); vm.prank(user); - IQueueModule(address(vault)).requestClaim(true, 600_000e6); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + IQueueModule(address(vault)).requestInstantWithdrawal(600_000e6); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); assertGt(pendingAfter, pendingBefore, "second claim queued at cap boundary"); } diff --git a/test/unit/core/Hardening_MissingTests.t.sol b/test/unit/core/Hardening_MissingTests.t.sol index efb63bc..5555492 100644 --- a/test/unit/core/Hardening_MissingTests.t.sol +++ b/test/unit/core/Hardening_MissingTests.t.sol @@ -11,13 +11,20 @@ import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; import { VaultUpkeep, Op } from "../../../src/automation/VaultUpkeep.sol"; import { IStrategyRouter } from "../../../src/interfaces/IStrategyRouter.sol"; +import { EpochedQueueModule, EpochQueueStorage } from "../../../src/core/modules/EpochedQueueModule.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function processQueuedRedemptions(uint256 maxClaims) external; - function queueLength() external view returns (uint256); - function pendingShares() external view returns (uint256); + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) external returns (uint256 epochId, uint256 claimId); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function canCloseCurrentEpoch() external view returns (bool); + function currentEpochClaimCount() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -87,32 +94,41 @@ contract Hardening_MissingTests is Test { function test_C3_degradedPlan_noInfiniteRetry() public { // Drain hot by depositing then claiming most vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 9_500_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(9_500_000e6); // Now hot is very low. Queue a large claim that exceeds remaining hot. vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 400_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(400_000e6); - uint256 pendingBefore = IQueueModule(address(vault)).pendingShares(); + uint256 pendingBefore = IQueueModule(address(vault)).totalEscrowedShares(); assertGt(pendingBefore, 0, "claim queued"); // Settle — no router configured, hot likely < gross for this claim. - // Claim skipped with QueueClaimSkippedInsufficientHot. - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + // fundEpoch() falls short (stays CLOSED, not FUNDED); claim stays pending. + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + try IQueueModule(address(vault)).claimEpochAssets(epochId, claimId) { } catch { } - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // If claim was skipped (insufficient hot), it stays pending // If claim was settled (hot was enough), pending = 0 — also fine // The key: no revert, no infinite loop, no crash console2.log("Pending before:", pendingBefore, "after:", pendingAfter); - // Second settle — same result, no crash - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + // Second attempt — same result, no crash (fundEpoch reverts + // EpochAlreadyFunded if a prior attempt already fully funded it -- + // that's expected, not a failure of this test). + try IQueueModule(address(vault)).fundEpoch(epochId) { } catch { } + vm.prank(user1); + try IQueueModule(address(vault)).claimEpochAssets(epochId, claimId) { } catch { } // Gas is bounded uint256 g = gasleft(); - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + try IQueueModule(address(vault)).fundEpoch(epochId) { } catch { } uint256 gasUsed = g - gasleft(); console2.log("Degraded settle gas:", gasUsed); assertLt(gasUsed, 5_000_000, "degraded settle gas bounded"); @@ -121,14 +137,21 @@ contract Hardening_MissingTests is Test { /// @notice Claims that are skippable today become processable when liquidity returns function test_C3_degradedRecovery() public { vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); // First settle — might skip if hot insufficient for this claim size // (hot should be sufficient since we have 10M deposited) - IQueueModule(address(vault)).settleFeesAndProcessQueue(25); + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); // Verify claim was processed (we have enough hot) - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "claim settled with available hot"); + assertEq( + IQueueModule(address(vault)).totalEscrowedShares(), 0, "claim settled with available hot" + ); } // ═══════════════════════════════════════════════════════════════════════════ @@ -151,8 +174,6 @@ contract Hardening_MissingTests is Test { address(0), // no buffer manager address(stubRouter), address(stubConfig), - 25, // maxClaims - 100, // hardMaxClaims type(uint256).max, // maxRealize type(uint256).max, // maxDeploy 10, // minRealizeGapBps @@ -160,7 +181,7 @@ contract Hardening_MissingTests is Test { ); // Verify initial state - assertEq(upkeep.failureCountByOp(Op.SETTLE), 0, "initial failure count = 0"); + assertEq(upkeep.failureCountByOp(Op.EPOCH_CLOSE), 0, "initial failure count = 0"); assertEq(upkeep.failureCountByOp(Op.DEPLOY), 0, "initial deploy count = 0"); assertEq(upkeep.failureCountByOp(Op.REALIZE), 0, "initial realize count = 0"); @@ -170,11 +191,12 @@ contract Hardening_MissingTests is Test { assertEq(upkeep.lastAction(), 0, "initial lastAction = 0"); } - /// @notice VaultUpkeep settleFeesAndProcessQueue succeeds via performUpkeep + /// @notice VaultUpkeep closeCurrentEpoch succeeds via performUpkeep function test_M3_upkeepSettleSucceeds() public { - // Queue a claim so canSettle returns true + // Queue a claim, then wait out the epoch duration so canCloseCurrentEpoch() is true. vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); + EpochedQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); + vm.warp(block.timestamp + 7 days + 1); StubRouterReader stubRouter = new StubRouterReader(); StubGlobalConfigReader stubConfig = new StubGlobalConfigReader(); @@ -184,7 +206,6 @@ contract Hardening_MissingTests is Test { address(0), address(stubRouter), address(stubConfig), - 25, 100, type(uint256).max, type(uint256).max, 10, 10000 ); @@ -196,8 +217,8 @@ contract Hardening_MissingTests is Test { // Perform upkeep.performUpkeep(data); - // After successful settle, failure count should be 0 - assertEq(upkeep.failureCountByOp(Op.SETTLE), 0, "reset after success"); + // After successful epoch close, failure count should be 0 + assertEq(upkeep.failureCountByOp(Op.EPOCH_CLOSE), 0, "reset after success"); } } @@ -252,7 +273,7 @@ contract Hardening_MissingTests is Test { uint256 usdcBefore = usdc.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); assertGt(usdc.balanceOf(user1), usdcBefore, "instant claim at 1h stale NAV"); @@ -261,23 +282,192 @@ contract Hardening_MissingTests is Test { usdcBefore = usdc.balanceOf(user1); vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 100_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(100_000e6); assertGt(usdc.balanceOf(user1), usdcBefore, "instant claim at 24h stale NAV"); } - /// @notice settleFeesAndProcessQueue works at ANY NAV staleness + /// @notice Epoch close/fund/claim works at ANY NAV staleness function test_H5_settle_anyNAVAge() public { vm.prank(user1); - IQueueModule(address(vault)).requestClaim(false, 100_000e6); + (uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestEpochWithdrawal(100_000e6); - // 2 hours stale - vm.warp(block.timestamp + 2 hours); + // Well past both NAV staleness AND the min epoch duration + vm.warp(block.timestamp + 7 days + 1); uint256 usdcBefore = usdc.balanceOf(user1); - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); + + assertGt(usdc.balanceOf(user1), usdcBefore, "settle at stale NAV"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PR #13 review fix: minClaimAmount re-enforced (closes the dust-claim + // outstandingClaimCount griefing vector -- was silently unenforced after + // the QueueModule -> EpochedQueueModule cutover, even though GlobalConfig + // still ships/documents it as an anti-spam floor). + // ═══════════════════════════════════════════════════════════════════════════ + + function test_minClaimAmount_blocksQueuedDustClaim() public { + params.setMinClaimAmount(50e6); + + vm.prank(user1); + vm.expectRevert(EpochedQueueModule.ClaimTooSmall.selector); + IQueueModule(address(vault)).requestEpochWithdrawal(10e6); + } + + function test_minClaimAmount_allowsClaimAtFloor() public { + params.setMinClaimAmount(50e6); + + vm.prank(user1); + (, uint256 claimId) = IQueueModule(address(vault)).requestEpochWithdrawal(50e6); + assertGt(claimId, 0, "exactly-at-floor claim is accepted"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PR #13 review fix: EPOCH_FUND livelock. checkUpkeep() used to return + // EPOCH_FUND unconditionally whenever a closed-but-unfunded epoch existed, + // with no fall-through -- so one persistently-underfunded epoch (no + // router/warm liquidity to cover the gap) starved + // CRYSTALLIZE/REBALANCE/DEPLOY/REALIZE/RECONCILE forever. + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice The floor is enforced on BOTH legs, so a sub-floor instant + /// request reverts on the caller's input rather than on whatever + /// the cap happens to allow at that moment. + function test_minClaimAmount_instantPath_revertsDeterministically() public { + params.setMinClaimAmount(100e6); + params.setCapPerEpochBps(10); // 0.1% of TVL == 1_000 USDC of allowance + + // Cap wide open: still rejected on the input alone. + vm.prank(user1); + vm.expectRevert(EpochedQueueModule.ClaimTooSmall.selector); + IQueueModule(address(vault)).requestInstantWithdrawal(50e6); + + // Consume the cap allowance with an above-floor exit. + vm.prank(user1); + (bool settled,,) = IQueueModule(address(vault)).requestInstantWithdrawal(900e6); + assertTrue(settled, "above-floor instant exit settles"); + + // Cap exhausted: same rejection, same reason. Previously this leg + // reverted while the first one succeeded. + vm.prank(user1); + vm.expectRevert(EpochedQueueModule.ClaimTooSmall.selector); + IQueueModule(address(vault)).requestInstantWithdrawal(50e6); + } + + /// @notice The floor applies to every caller, with no address carve-out. + /// An exemption inside a security check is an invitation to widen + /// it; callers that cannot tolerate the revert -- FeeCollector's + /// AUTO_HARVEST is the one in-protocol case -- absorb it on their + /// own side instead. See FeeCollectorHarvestQueue. + function test_minClaimAmount_appliesToEveryCallerIncludingFeeCollector() public { + params.setMinClaimAmount(100e6); + + usdc._mint(feeCollector, 1_000e6); + vm.startPrank(feeCollector); + usdc.approve(address(vault), type(uint256).max); + vault.deposit(1_000e6, feeCollector); - assertGt(usdc.balanceOf(user1), usdcBefore, "settle at 2h stale NAV"); + vm.expectRevert(EpochedQueueModule.ClaimTooSmall.selector); + IQueueModule(address(vault)).requestEpochWithdrawal(50e6); + vm.stopPrank(); + + assertEq( + IQueueModule(address(vault)).outstandingClaimCount(), 0, + "no claim reaches the queue below the floor, whoever asks" + ); + assertEq( + IQueueModule(address(vault)).totalEscrowedShares(), 0, + "and nothing was escrowed on the way to the revert" + ); + } + + /// @notice A fundEpoch that REVERTS must still register as a stall. The + /// accounting used to sit inside the success branch, so a target + /// that reverts every cycle left the counters untouched, the + /// stalled-check unreachable, and EPOCH_FUND holding unconditional + /// priority forever. Driven against a stub core so the revert is + /// deterministic and independent of queue-module internals. + function test_epochFund_revertingAttempt_stillRecordsAStall() public { + RevertingFundCore stubCore = new RevertingFundCore(7); + VaultUpkeep upkeep = new VaultUpkeep( + address(stubCore), address(0), + address(new StubRouterReader()), address(new StubGlobalConfigReader()), + type(uint256).max, type(uint256).max, 10, 10000 + ); + + upkeep.performUpkeep(abi.encode(Op.EPOCH_FUND, uint256(7))); + + assertEq(upkeep.epochFundStallCount(), 1, "a reverting attempt counts as a stall"); + assertEq( + upkeep.lastEpochFundTargetId(), 7, + "the stalled target is recorded, so checkUpkeep can yield priority" + ); + + upkeep.performUpkeep(abi.encode(Op.EPOCH_FUND, uint256(7))); + assertEq(upkeep.epochFundStallCount(), 2, "repeat reverts keep accumulating"); + } + + function test_epochFund_yieldsPriorityAfterStall_thenReclaimsAfterBackoff() public { + // Queue a large claim, then drain hot so it can never be fully funded + // (no router configured; MockBufferManagerForTests' warm refill is a + // permanent no-op -- see MockBufferManagerForTests.refill()). + vm.prank(user1); + (uint256 epochId,) = IQueueModule(address(vault)).requestEpochWithdrawal(9_000_000e6); + + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + + vm.prank(address(vault)); + usdc.transfer(makeAddr("elsewhere"), 8_000_000e6); + + // Give the vault a genuinely pending CRYSTALLIZE, so cycle 2 has real + // lower-priority work to fall through to. + usdc._mint(address(vault), 3_000_000e6); + + StubRouterReader stubRouter = new StubRouterReader(); + StubGlobalConfigReader stubConfig = new StubGlobalConfigReader(); + VaultUpkeep upkeep = new VaultUpkeep( + address(vault), address(0), address(stubRouter), address(stubConfig), + type(uint256).max, type(uint256).max, 10, 10000 + ); + + // Cycle 1: EPOCH_FUND takes priority. + (bool needed1, bytes memory data1) = upkeep.checkUpkeep(""); + assertTrue(needed1, "EPOCH_FUND needed on the first cycle"); + (Op op1,) = abi.decode(data1, (Op, uint256)); + assertTrue(op1 == Op.EPOCH_FUND); + upkeep.performUpkeep(data1); // attempts fundEpoch(epochId); stays underfunded -> stall count = 1 + + EpochQueueStorage.EpochData memory epochAfterAttempt = + EpochedQueueModule(address(vault)).epochData(epochId); + assertTrue( + epochAfterAttempt.state == EpochQueueStorage.EpochState.Closed, + "still underfunded -- no progress made" + ); + + // Cycle 2: same epoch still unfundable -- must yield priority instead + // of retrying EPOCH_FUND forever, AND must schedule real work in its + // place. Asserted unconditionally: guarding this behind `if (needed2)` + // would let the test pass green on an idle keeper, which is precisely + // the failure it is meant to catch. + (bool needed2, bytes memory data2) = upkeep.checkUpkeep(""); + assertTrue(needed2, "keeper must still have work once EPOCH_FUND yields"); + (Op op2,) = abi.decode(data2, (Op, uint256)); + assertTrue(op2 != Op.EPOCH_FUND, "EPOCH_FUND must yield priority once stalled"); + assertTrue(op2 == Op.CRYSTALLIZE, "the pending CRYSTALLIZE becomes schedulable"); + + // After the backoff window elapses, EPOCH_FUND reclaims priority. + vm.warp(block.timestamp + upkeep.epochFundStallBackoffSeconds() + 1); + (bool needed3, bytes memory data3) = upkeep.checkUpkeep(""); + assertTrue(needed3, "EPOCH_FUND retried after the backoff window"); + (Op op3,) = abi.decode(data3, (Op, uint256)); + assertTrue(op3 == Op.EPOCH_FUND); } } @@ -292,3 +482,28 @@ contract StubRouterReader { contract StubGlobalConfigReader { uint256 public minRebalanceCooldown = 300; } + +/// @notice Minimal core stub whose fundEpoch always reverts while the cursor +/// stays parked on the same target — the shape VaultUpkeep must handle +/// without losing its stall accounting. +contract RevertingFundCore { + uint256 private immutable _oldest; + + constructor(uint256 oldest_) { _oldest = oldest_; } + + error AlwaysReverts(); + + function fundEpoch(uint256) external pure { revert AlwaysReverts(); } + function oldestUnfundedEpochId() external view returns (uint256) { return _oldest; } + function currentEpochId() external view returns (uint256) { return _oldest + 1; } + + function canSettle() external pure returns (bool) { return false; } + function canCrystallize() external pure returns (bool) { return false; } + function canRealizeWithGap() external pure returns (bool, uint256) { return (false, 0); } + function canDeploy() external pure returns (bool) { return false; } + function canCloseCurrentEpoch() external pure returns (bool) { return false; } + function currentEpochClaimCount() external pure returns (uint256) { return 0; } + function canRebalanceStrategies() external pure returns (bool) { return false; } + function pendingExitCount() external pure returns (uint256) { return 0; } + function totalAssets() external pure returns (uint256) { return 0; } +} diff --git a/test/unit/core/Hardening_RemainingItems.t.sol b/test/unit/core/Hardening_RemainingItems.t.sol index 717d993..a0b8eea 100644 --- a/test/unit/core/Hardening_RemainingItems.t.sol +++ b/test/unit/core/Hardening_RemainingItems.t.sol @@ -11,11 +11,17 @@ import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTes import { VaultUpkeep } from "../../../src/automation/VaultUpkeep.sol"; interface IQueueModule { - function requestClaim(bool immediate, uint256 shares) external; - function settleFeesAndProcessQueue(uint256 maxClaims) external; - function pendingShares() external view returns (uint256); - function queueLength() external view returns (uint256); - function compactQueue() external; + function requestInstantWithdrawal(uint256 shares) + external + returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) + external + returns (uint256 epochId, uint256 claimId); + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function totalEscrowedShares() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); } /// @title Hardening: C1 no-arbitrage invariant, M3 failure counter reset, queue compaction @@ -108,44 +114,55 @@ contract Hardening_RemainingItems is Test { } // ═══════════════════════════════════════════════════════════════════════════ - // QUEUE: compaction off-path, FIFO preserved, no zombie + // QUEUE: epoch close/fund/claim cycle preserves invariants + // (No compaction in the epoch model -- epochs are immutable once closed, + // there is no live FIFO array to compact.) // ═══════════════════════════════════════════════════════════════════════════ - /// @notice Queue settle + compaction cycle preserves invariants + /// @notice Queue close + fund + claim cycle preserves invariants function test_queue_settleAndCompact() public { - // 10 users queue claims + // 10 users queue claims into the same open epoch + address[10] memory claimants; + uint256[10] memory claimIds; + uint256 epochId; for (uint256 i = 0; i < 10; i++) { address u = address(uint160(0xB000 + i)); + claimants[i] = u; usdc._mint(u, 1_000_000e6); vm.startPrank(u); usdc.approve(address(vault), type(uint256).max); vault.deposit(1_000_000e6, u); - IQueueModule(address(vault)).requestClaim(false, 500_000e6); + (epochId, claimIds[i]) = IQueueModule(address(vault)).requestEpochWithdrawal(500_000e6); vm.stopPrank(); } - assertEq(IQueueModule(address(vault)).queueLength(), 10, "10 claims queued"); + assertEq(IQueueModule(address(vault)).outstandingClaimCount(), 10, "10 claims queued"); - // Settle 5 - IQueueModule(address(vault)).settleFeesAndProcessQueue(5); + // Close + fund the epoch (single liquidity pull covers all 10 claims) + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); - // Queue length should reflect settled claims removed from active count - uint256 ql = IQueueModule(address(vault)).queueLength(); - console2.log("Queue after settle 5:", ql); - - // Compact (off-path, separate call) - IQueueModule(address(vault)).compactQueue(); + // Settle 5 (each user pulls their own claim -- pull-based, not keeper-push) + for (uint256 i = 0; i < 5; i++) { + vm.prank(claimants[i]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimIds[i]); + } - uint256 qlAfterCompact = IQueueModule(address(vault)).queueLength(); - console2.log("Queue after compact:", qlAfterCompact); + uint256 ql = IQueueModule(address(vault)).outstandingClaimCount(); + console2.log("Outstanding claims after settling 5:", ql); + assertEq(ql, 5, "5 claims remain outstanding"); // Settle remaining - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); + for (uint256 i = 5; i < 10; i++) { + vm.prank(claimants[i]); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimIds[i]); + } - assertEq(IQueueModule(address(vault)).pendingShares(), 0, "all claims settled"); + assertEq(IQueueModule(address(vault)).totalEscrowedShares(), 0, "all claims settled"); } - /// @notice Skipped claims are retried in next scan (not permanently lost) + /// @notice Fallback-to-queue claims are retrievable once the epoch is funded function test_queue_instantFallbackBecomesStandard() public { // User deposits and queues immediate claim that exceeds cap vm.prank(user1); @@ -153,22 +170,28 @@ contract Hardening_RemainingItems is Test { // Exhaust cap with first instant claim vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 4_000_000e6); + IQueueModule(address(vault)).requestInstantWithdrawal(4_000_000e6); - // Second instant claim falls back to queue — becomes STANDARD (immediate=false) + // Second instant claim falls back to queue — becomes a standard epoch claim vm.prank(user1); - IQueueModule(address(vault)).requestClaim(true, 3_000_000e6); + (bool settledImmediately, uint256 epochId, uint256 claimId) = + IQueueModule(address(vault)).requestInstantWithdrawal(3_000_000e6); - uint256 pending = IQueueModule(address(vault)).pendingShares(); + assertFalse(settledImmediately, "claim queued due to cap"); + uint256 pending = IQueueModule(address(vault)).totalEscrowedShares(); assertGt(pending, 0, "claim queued due to cap"); - // Settle: since claim is now STANDARD (no cap check), it settles immediately - // (if hot liquidity is sufficient) - IQueueModule(address(vault)).settleFeesAndProcessQueue(10); - uint256 pendingAfter = IQueueModule(address(vault)).pendingShares(); + // Settle: close + fund the epoch, then user1 self-claims (standard claims + // have no cap check, only the epoch's liquidity/maturity gates) + vm.warp(block.timestamp + 7 days + 1); + IQueueModule(address(vault)).closeCurrentEpoch(); + IQueueModule(address(vault)).fundEpoch(epochId); + vm.prank(user1); + IQueueModule(address(vault)).claimEpochAssets(epochId, claimId); + uint256 pendingAfter = IQueueModule(address(vault)).totalEscrowedShares(); // Claim should be settled (standard claims have no cap, only lock period) - assertEq(pendingAfter, 0, "standard claim settled immediately (no cap)"); + assertEq(pendingAfter, 0, "standard claim settled after epoch funded"); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/test/unit/core/ReservationConsumers.t.sol b/test/unit/core/ReservationConsumers.t.sol new file mode 100644 index 0000000..cb3468a --- /dev/null +++ b/test/unit/core/ReservationConsumers.t.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// ───────────────────────────────────────────────────────────────────────────── +// Every consumer of the vault's hot balance must respect reservedForClaims. +// +// The first reservation pass guarded three consumers (fundEpoch, _canInstant, +// the strategy deploy path) and missed two more: the force-exit pair in +// ERC4626Module, and the warm-buffer deploy, which pulls straight out of the +// vault under the standing allowance from CoreVault.approveWarmAdapters() and +// therefore never touches a module-level check at all. +// +// The force-exit cases below are the reviewer's proof-of-concept with the +// assertions inverted: what used to demonstrate an unpayable claimant now +// asserts that the claimant is paid in full. +// +// Also covers the release half of the reservation lifecycle, which the first +// pass never asserted: reserve without release is the mirror-image failure +// (capital locked up forever) and no test written against the original bug +// would catch it. +// ───────────────────────────────────────────────────────────────────────────── + +import { Test } from "forge-std/Test.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { CoreHarness } from "../../helpers/CoreHarness.sol"; +import { ERC20Mock } from "../../../src/mocks/ERC20Mock.sol"; +import { MockParamsProvider } from "../../helpers/MockParamsProvider.sol"; +import { MockBufferManagerForTests } from "../../helpers/MockBufferManagerForTests.sol"; +import { StrategyMock } from "../../helpers/StrategyMock.sol"; +import { ERC4626Module } from "../../../src/core/modules/ERC4626Module.sol"; +import { EpochedQueueModule, EpochQueueStorage } from "../../../src/core/modules/EpochedQueueModule.sol"; +import { IStrategyRouter } from "../../../src/interfaces/IStrategyRouter.sol"; +import { BufferManager } from "../../../src/core/modules/BufferManager.sol"; +import { IBufferManager } from "../../../src/interfaces/IBufferManager.sol"; +import { CoreVault } from "../../../src/core/CoreVault.sol"; + +interface IReservationQueue { + function requestInstantWithdrawal(uint256 shares) + external returns (bool settledImmediately, uint256 epochId, uint256 claimId); + function requestEpochWithdrawal(uint256 shares) external returns (uint256 epochId, uint256 claimId); + function cancelEpochWithdrawal(uint256 epochId, uint256 claimId) external; + function closeCurrentEpoch() external; + function fundEpoch(uint256 epochId) external; + function claimEpochAssets(uint256 epochId, uint256 claimId) external returns (uint256 assets); + function batchClaimEpochAssets(uint256 epochId, uint256[] calldata claimIds) + external returns (uint256 totalAssets); + function currentEpochId() external view returns (uint256); + function outstandingClaimCount() external view returns (uint256); + function totalEscrowedShares() external view returns (uint256); + function reservedForClaims() external view returns (uint256); + function closedPendingAssets() external view returns (uint256); + function epochData(uint256 epochId) external view returns (EpochQueueStorage.EpochData memory); +} + +interface IForceExit { + function forceWithdrawAll(address receiver, uint256 minAssetsOut) + external returns (uint256 assetsReceived); +} + +interface ICanDeployView { + function canDeploy() external view returns (bool); +} + +contract ReservationConsumers is Test { + CoreHarness public vault; + ERC20Mock public usdc; + MockParamsProvider public params; + MockBufferManagerForTests public bufferManager; + + address public feeCollector = address(0xFEE); + address public alice = address(0xA001); + address public bob = address(0xB002); + + uint256 internal t; + + function setUp() public { + usdc = new ERC20Mock("USDC", "USDC", 6); + params = new MockParamsProvider(); + params.setLockPeriod(0); + params.setCapPerEpochBps(10000); + + vault = new CoreHarness( + IERC20Metadata(address(usdc)), "Vault", "vUSDC", + address(this), feeCollector, address(params) + ); + bufferManager = new MockBufferManagerForTests(address(vault)); + vault.setBufferManagerUnsafe(address(bufferManager)); + vault.setFeeParamsUnsafe(0, 25, feeCollector); + vault.setExitFeesUnsafe(25, 50, 150); + vault.setEpochDurationUnsafe(7 days); + vault.unpause(); + + usdc._mint(alice, 100_000_000e6); + usdc._mint(bob, 100_000_000e6); + vm.prank(alice); + usdc.approve(address(vault), type(uint256).max); + vm.prank(bob); + usdc.approve(address(vault), type(uint256).max); + + t = block.timestamp; + } + + function _q() internal view returns (IReservationQueue) { + return IReservationQueue(address(vault)); + } + + function _dep(address who, uint256 a) internal returns (uint256) { + vm.prank(who); + return vault.deposit(a, who); + } + + function _hot() internal view returns (uint256) { + return usdc.balanceOf(address(vault)); + } + + function _warp(uint256 d) internal { + t += d; + vm.warp(t); + } + + /// @dev Queue alice's whole position, close, fund. Returns her claim handle. + function _fundAliceEpoch(uint256 shares) internal returns (uint256 epochId, uint256 claimId) { + vm.prank(alice); + (epochId, claimId) = _q().requestEpochWithdrawal(shares); + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(epochId); + assertTrue( + _q().epochData(epochId).state == EpochQueueStorage.EpochState.Funded, + "setup: epoch must be funded" + ); + } + + // ═════════════════════════════════════════════════════════════════════════ + // FORCE EXIT — the two consumers the first reservation pass missed + // ═════════════════════════════════════════════════════════════════════════ + + /// @notice Regression: forceWithdrawAll after a NAV drop used to spend past + /// the reservation and leave the funded claimant unpayable. + function test_forceWithdrawAll_cannotSpendPastTheReservation() public { + uint256 aliceShares = _dep(alice, 1_000_000e6); + _dep(bob, 1_000_000e6); + + (uint256 e0, uint256 c0) = _fundAliceEpoch(aliceShares); + uint256 reserved = _q().reservedForClaims(); + assertGt(reserved, 0, "alice's payout is reserved"); + + // 50% NAV loss AFTER epoch 0 was funded: alice's ppsAtClose is already + // locked at the pre-loss price, so her liability now exceeds her + // proportional share of what is left. + uint256 loss = _hot() / 2; + vm.prank(address(vault)); + usdc.transfer(makeAddr("blackhole"), loss); + + vm.prank(bob); + IForceExit(address(vault)).forceWithdrawAll(bob, 0); + + assertGe( + _hot(), _q().reservedForClaims(), + "hot must still cover the reservation after a force exit" + ); + + // The whole point: alice is paid, in full, at her locked price. + vm.prank(alice); + uint256 paid = _q().claimEpochAssets(e0, c0); + assertEq(paid, reserved, "funded claimant paid in full at ppsAtClose"); + assertEq(_q().reservedForClaims(), 0, "reservation released on payout"); + } + + /// @notice A force exit with zero free liquidity is a no-op fill, not a + /// raid on the reservation. + function test_forceWithdrawAll_withZeroFreeLiquidity_deliversNothing() public { + uint256 aliceShares = _dep(alice, 1_000_000e6); + _dep(bob, 1_000_000e6); + + (uint256 e0, uint256 c0) = _fundAliceEpoch(aliceShares); + + // Drain hot down to exactly the reservation. + uint256 drain = _hot() - _q().reservedForClaims(); + vm.prank(address(vault)); + usdc.transfer(makeAddr("elsewhere"), drain); + + uint256 bobBefore = usdc.balanceOf(bob); + vm.prank(bob); + uint256 got = IForceExit(address(vault)).forceWithdrawAll(bob, 0); + + assertEq(got, 0, "nothing free to hand out"); + assertEq(usdc.balanceOf(bob), bobBefore, "bob received nothing"); + assertEq(vault.balanceOf(bob), 1_000_000e6, "bob keeps his shares for later"); + + vm.prank(alice); + _q().claimEpochAssets(e0, c0); + } + + /// @notice forceWithdraw asks for an exact amount, so a shortfall against + /// free liquidity is an explicit revert rather than a silent raid. + function test_forceWithdraw_revertsWhenItWouldDipIntoTheReservation() public { + uint256 aliceShares = _dep(alice, 1_000_000e6); + _dep(bob, 1_000_000e6); + + (uint256 e0, uint256 c0) = _fundAliceEpoch(aliceShares); + + uint256 drain = _hot() - _q().reservedForClaims(); + vm.prank(address(vault)); + usdc.transfer(makeAddr("elsewhere"), drain); + + // forceWithdraw is not wired by CoreHarness, so route it explicitly. + vault.setModule( + ERC4626Module.forceWithdraw.selector, address(vault.erc4626Module()), vault.ROLE_PUBLIC() + ); + + IStrategyRouter.Pull[] memory emptyPlan = new IStrategyRouter.Pull[](0); + vm.prank(bob); + vm.expectRevert(ERC4626Module.InsufficientFreeLiquidity.selector); + ERC4626Module(address(vault)).forceWithdraw( + 100e6, bob, bob, emptyPlan, type(uint256).max + ); + + vm.prank(alice); + _q().claimEpochAssets(e0, c0); + } + + // ═════════════════════════════════════════════════════════════════════════ + // WARM BUFFER — reserved cash is not deployable warm either + // ═════════════════════════════════════════════════════════════════════════ + + /// @notice BufferManager.plan() sizes needDeploy off FREE liquidity. Warm + /// adapters pull directly from the vault under a standing + /// allowance, so a raw-hot plan would move reserved cash out with + /// no module-level check able to see it. + function test_bufferPlan_excludesReservedCashFromNeedDeploy() public { + // The real BufferManager, not the test mock: plan() is the function + // under test and the mock stubs it out. + BufferManager real = new BufferManager( + address(this), + address(vault), + IBufferManager.BufferConfig({ + targetHotBps: 300, + minHotBps: 200, + targetWarmBps: 700, + maxWarmBps: 1000, + opsReserveTargetBps: 100, + maxWarmSlippageBps: 50, + asset: address(usdc), + warmAdapter: address(0), + twapWindowSec: 0, + paused: false + }) + ); + + uint256 aliceShares = _dep(alice, 1_000_000e6); + _dep(bob, 1_000_000e6); + + (, uint256 deployBefore) = real.plan(); + assertGt(deployBefore, 0, "baseline: surplus is deployable to warm"); + + _fundAliceEpoch(aliceShares); + + // Drain hot to exactly the reservation: zero free liquidity remains. + uint256 drain = _hot() - _q().reservedForClaims(); + vm.prank(address(vault)); + usdc.transfer(makeAddr("elsewhere"), drain); + + (, uint256 deployAfter) = real.plan(); + assertEq(deployAfter, 0, "nothing deployable once all hot cash is reserved"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // FIXED MATURITY — the mode switch may not strand a funded claimant + // ═════════════════════════════════════════════════════════════════════════ + + function test_setVaultModeFixedMaturity_blockedWhileClaimsOutstanding() public { + _dep(alice, 1_000_000e6); + vm.prank(alice); + _q().requestEpochWithdrawal(100_000e6); + + vm.expectRevert(); + IFixedMaturityMode(address(vault)).setVaultModeFixedMaturity(); + } + + // ═════════════════════════════════════════════════════════════════════════ + // RELEASE — the half the first pass never asserted + // ═════════════════════════════════════════════════════════════════════════ + + /// @notice Ten full request/close/fund/claim cycles with a moving price. + /// Everything must drain back, bar the documented truncation dust. + function test_reservationReleases_overTenFullCycles() public { + _dep(alice, 10_000_000e6); + _dep(bob, 10_000_000e6); + + for (uint256 i = 0; i < 10; i++) { + // Push pps off an exact 1.0 so the reserve/release rounding is + // genuinely exercised (mulWadDown is exact at pps == 1e18). + usdc._mint(address(vault), 137_777e6 + i * 911e6); + + vm.prank(alice); + (uint256 e, uint256 cA) = _q().requestEpochWithdrawal(100_000e6); + vm.prank(bob); + (, uint256 cB) = _q().requestEpochWithdrawal(70_000e6); + + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(e); + + uint256 aliceBefore = usdc.balanceOf(alice); + vm.prank(alice); + uint256 paidA = _q().claimEpochAssets(e, cA); + assertEq(usdc.balanceOf(alice) - aliceBefore, paidA, "alice received what she was owed"); + + uint256 bobBefore = usdc.balanceOf(bob); + vm.prank(bob); + uint256 paidB = _q().claimEpochAssets(e, cB); + assertEq(usdc.balanceOf(bob) - bobBefore, paidB, "bob received what he was owed"); + } + + assertEq(_q().outstandingClaimCount(), 0, "no claims outstanding"); + assertEq(_q().totalEscrowedShares(), 0, "escrow drained"); + assertEq(_q().closedPendingAssets(), 0, "closed-pending liability cleared"); + assertLe( + _q().reservedForClaims(), 10, + "reservation drains back to at most the documented per-epoch truncation dust" + ); + } + + /// @notice Same, with cancellations interleaved and an empty epoch closed + /// and funded in the middle. + function test_reservationReleases_withCancellationsAndEmptyEpochs() public { + _dep(alice, 10_000_000e6); + _dep(bob, 10_000_000e6); + + for (uint256 i = 0; i < 5; i++) { + usdc._mint(address(vault), 91_313e6 + i * 707e6); + + vm.prank(alice); + (uint256 e, uint256 cA) = _q().requestEpochWithdrawal(100_000e6); + vm.prank(bob); + (, uint256 cB) = _q().requestEpochWithdrawal(60_000e6); + vm.prank(bob); + _q().cancelEpochWithdrawal(e, cB); + + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(e); + + uint256 before = usdc.balanceOf(alice); + vm.prank(alice); + uint256 paid = _q().claimEpochAssets(e, cA); + assertEq(usdc.balanceOf(alice) - before, paid, "alice paid the claimed amount"); + + uint256 empty = _q().currentEpochId(); + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(empty); + } + + assertEq(_q().outstandingClaimCount(), 0, "no claims outstanding"); + assertEq(_q().totalEscrowedShares(), 0, "escrow drained"); + assertEq(_q().reservedForClaims(), 0, "single-claim epochs release exactly"); + assertEq(_q().closedPendingAssets(), 0, "empty epochs add no liability"); + } + + /// @notice The batch path must release exactly what the single path does. + function test_reservationReleases_viaBatchClaim() public { + _dep(alice, 10_000_000e6); + usdc._mint(address(vault), 333_333e6); + + vm.prank(alice); + (uint256 e, uint256 c1) = _q().requestEpochWithdrawal(100_000e6); + vm.prank(alice); + (, uint256 c2) = _q().requestEpochWithdrawal(70_000e6); + vm.prank(alice); + (, uint256 c3) = _q().requestEpochWithdrawal(33_333e6); + + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(e); + + uint256[] memory ids = new uint256[](3); + ids[0] = c1; + ids[1] = c2; + ids[2] = c3; + + uint256 before = usdc.balanceOf(alice); + vm.prank(alice); + uint256 total = _q().batchClaimEpochAssets(e, ids); + + assertEq(usdc.balanceOf(alice) - before, total, "alice received the batch total"); + assertEq(_q().outstandingClaimCount(), 0, "all three claims settled"); + assertLe(_q().reservedForClaims(), 10, "batch release matches the single path"); + } + + /// @notice After a clean run the vault must still be operable: deployable + /// surplus and instant exits must not be permanently suppressed. + function test_vaultRemainsOperableAfterCycles() public { + StrategyMock strat = new StrategyMock(address(usdc)); + vault.addStrategyUnsafe(address(strat)); + t = block.timestamp; // addStrategyUnsafe warps and restores; resync + + _dep(alice, 10_000_000e6); + _dep(bob, 10_000_000e6); + + for (uint256 i = 0; i < 10; i++) { + usdc._mint(address(vault), 137_777e6 + i * 911e6); + vm.prank(alice); + (uint256 e, uint256 cA) = _q().requestEpochWithdrawal(100_000e6); + _warp(7 days + 1); + _q().closeCurrentEpoch(); + _q().fundEpoch(e); + vm.prank(alice); + _q().claimEpochAssets(e, cA); + } + + assertTrue(ICanDeployView(address(vault)).canDeploy(), "surplus still deployable"); + + uint256 before = usdc.balanceOf(bob); + vm.prank(bob); + (bool settled,,) = _q().requestInstantWithdrawal(1_000e6); + assertTrue(settled, "instant exit still available"); + assertGt(usdc.balanceOf(bob), before, "and it actually delivered assets"); + } +} + +interface IFixedMaturityMode { + function setVaultModeFixedMaturity() external; +} + +/// @notice The warm-adapter allowance is the one channel that moves the +/// underlying without any module-level check: adapters pull with +/// transferFrom under a standing grant. It must be bounded. +contract WarmAdapterAllowance is Test { + CoreHarness public vault; + ERC20Mock public usdc; + MockParamsProvider public params; + + address public feeCollector = address(0xFEE); + address public adapterA = address(0xADA1); + address public adapterB = address(0xADA2); + + function setUp() public { + usdc = new ERC20Mock("USDC", "USDC", 6); + params = new MockParamsProvider(); + vault = new CoreHarness( + IERC20Metadata(address(usdc)), "Vault", "vUSDC", + address(this), feeCollector, address(params) + ); + vault.unpause(); + usdc._mint(address(vault), 10_000_000e6); + } + + function test_allowanceIsCappedNotUnlimited() public { + address[] memory adapters = new address[](2); + adapters[0] = adapterA; + adapters[1] = adapterB; + + vault.approveWarmAdapters(adapters, 1_000e6); + + assertEq(usdc.allowance(address(vault), adapterA), 1_000e6, "adapter A capped"); + assertEq(usdc.allowance(address(vault), adapterB), 1_000e6, "adapter B capped"); + assertLt( + usdc.allowance(address(vault), adapterA), type(uint256).max, + "no adapter holds an unbounded allowance" + ); + } + + /// @notice A rogue adapter can take at most the cap, not the vault. + function test_cappedAdapterCannotDrainTheVault() public { + address[] memory adapters = new address[](1); + adapters[0] = adapterA; + vault.approveWarmAdapters(adapters, 1_000e6); + + uint256 vaultBefore = usdc.balanceOf(address(vault)); + + vm.prank(adapterA); + usdc.transferFrom(address(vault), adapterA, 1_000e6); + assertEq(usdc.balanceOf(adapterA), 1_000e6, "pulled up to the cap"); + + // Anything beyond the cap fails: the allowance is spent, not renewed. + vm.prank(adapterA); + vm.expectRevert(); + usdc.transferFrom(address(vault), adapterA, 1); + + assertEq( + usdc.balanceOf(address(vault)), vaultBefore - 1_000e6, + "vault lost exactly the budgeted amount and no more" + ); + } + + function test_zeroCapIsRejected() public { + address[] memory adapters = new address[](1); + adapters[0] = adapterA; + vm.expectRevert(CoreVault.ZeroAmount.selector); + vault.approveWarmAdapters(adapters, 0); + } + + function test_revokeStillZeroesTheAllowance() public { + address[] memory adapters = new address[](1); + adapters[0] = adapterA; + vault.approveWarmAdapters(adapters, 1_000e6); + vault.revokeWarmAdapters(adapters); + assertEq(usdc.allowance(address(vault), adapterA), 0, "revoked to zero"); + } +} diff --git a/test/unit/fixed-maturity/FixedMaturityHarness.sol b/test/unit/fixed-maturity/FixedMaturityHarness.sol index 7cb7274..7127546 100644 --- a/test/unit/fixed-maturity/FixedMaturityHarness.sol +++ b/test/unit/fixed-maturity/FixedMaturityHarness.sol @@ -8,7 +8,6 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { CoreHarness } from "../../helpers/CoreHarness.sol"; import { CoreStorage } from "../../../src/core/storage/CoreStorage.sol"; import { FixedMaturityStorage, VaultMode, VaultState } from "../../../src/core/storage/FixedMaturityStorage.sol"; -import { QueueStorage } from "../../../src/core/storage/QueueStorage.sol"; import { FixedMaturityModule } from "../../../src/core/modules/FixedMaturityModule.sol"; import { LiquidityOpsModule } from "../../../src/core/modules/LiquidityOpsModule.sol"; @@ -118,10 +117,6 @@ contract FixedMaturityHarness is CoreHarness { fm.startingTs = uint64(block.timestamp); } - function setPendingSharesUnsafe(uint256 pendingShares_) external { - QueueStorage.layout().pendingShares = pendingShares_; - } - // ── Storage getters (for test assertions) ──────────────────────────────── function getFundingFailedPPS() external view returns (uint256) {