Skip to content

Latest commit

 

History

History
867 lines (646 loc) · 51.3 KB

File metadata and controls

867 lines (646 loc) · 51.3 KB

Multyr Core — Architecture

Status: draft | Audit-scope: multyr-core@pierdev Last reviewed by code: commit 1595a279 on branch pierdev (date: 2026-05-15) Version: 1.0.0-draft


Table of Contents

  1. Overview
  2. Diamond-Lite Architecture
  3. Module Routing and Access Control
  4. Storage Model
  5. NAV Accounting and totalAssets()
  6. Exit Semantics (v9 Async Protocol)
  7. Fee Mechanics
  8. Buffer Management (Hot/Warm)
  9. Fixed Maturity Lifecycle States
  10. System Sealing
  11. Ownership and Pause
  12. Strategy Boundary
  13. Public Interface Summary
  14. Invariants
  15. External Calls
  16. Edge Cases
  17. Glossary and Cross-Links

1. Overview

Multyr Core is a non-custodial ERC-4626 vault built on Arbitrum. It accepts USDC deposits and allocates capital to a configurable set of lending strategies (Aave V3, Compound III, Morpho Blue, Euler V2, Fluid, Dolomite) through a strategy router. Yield accrues to share holders via a rising price-per-share (PPS) model.

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 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.


2. Diamond-Lite Architecture

2.1 Design

CoreVault is a Diamond-lite proxy: a thin ERC-4626 contract that delegates all economic operations to external module contracts via delegatecall. Unlike EIP-2535 (full Diamond), the selector registry is immutable after initial deployment, and modules are not stored in a shared diamond storage contract — each module uses its own EIP-7201 namespaced storage slot.

graph TD
    User["User / Keeper"]
    CV["CoreVault (ERC4626 entry)"]
    SR["SelectorRegistry\n(immutable, no storage)"]
    EM["ERC4626Module\n(deposit/mint/forceWithdraw)"]
    QM["EpochedQueueModule\n(requestEpochWithdrawal/close/fund/claim/crystallize)"]
    AM["AdminModule\n(timelock governance)"]
    LOM["LiquidityOpsModule\n(deploy/realize/rebalance)"]
    FM["FixedMaturityModule\n(FM lifecycle)"]
    BM["BufferManager\n(hot/warm buffer — standalone)"]
    SR_Strat["StrategyRouter\n(strategy allocator)"]

    User -->|"any call"| CV
    CV -->|"fallback: moduleOf[msg.sig]"| SR
    SR -.->|"role validation"| CV
    CV -->|"delegatecall"| EM
    CV -->|"delegatecall"| QM
    CV -->|"delegatecall"| AM
    CV -->|"delegatecall"| LOM
    CV -->|"delegatecall"| FM
    CV -->|"external call"| BM
    CV -->|"external call"| SR_Strat
Loading

All shared state (flags, module routing table, owner, params, etc.) lives in CoreStorage.Layout accessed via CoreStorage.layout() in every module. Because modules execute in the vault's context via delegatecall, address(this) inside a module equals CoreVault's address.

2.2 Dispatch Flow

Every call to CoreVault that is not an explicitly-implemented function (see §2.3) is intercepted by the fallback() function:

fallback() [CoreVault.sol:144-178]:
  1. core = CoreStorage.layout()
  2. module = core.moduleOf[msg.sig]
     → revert ModuleNotSet() if address(0)
  3. role = core.roleOf[msg.sig]
     → access control check (see §3)
  4. (success, result) = module.delegatecall(msg.data)
     → propagate revert on failure
     → inline assembly return on success

ERC-4626 public functions (deposit, mint, withdraw, redeem) are explicitly implemented on CoreVault and delegate internally via _delegateToModule():

// CoreVault.sol:236-250
function _delegateToModule(bytes4 sel, bytes calldata data) internal returns (bytes memory) {
    address module = CoreStorage.layout().moduleOf[sel];
    if (module == address(0)) revert ModuleNotSet();
    (bool success, bytes memory result) = module.delegatecall(data);
    ...
}

After delegation, CoreVault calls _refreshOpsNavCache() to keep the operational NAV cache fresh. See §5 for the distinction between live NAV and ops cache.

2.3 Functions Implemented Directly on CoreVault

The following functions are NOT routed via the module dispatch — they are implemented directly on CoreVault:

Function Purpose
totalAssets() Live NAV (ERC-4626 canonical) — see §5
maxDeposit(address) Returns 0 if paused/cap/warm-NAV invalid
maxWithdraw(address) Always returns 0 (queued protocol)
maxRedeem(address) Always returns 0 (queued protocol)
previewDeposit(uint256) Fee-aware shares preview
previewMint(uint256) Fee-aware assets-in preview
owner(), guardian(), recoveryGate() Role reads
paused(), pausedDeposits(), pausedWithdrawals(), pausedInstantWithdrawal(), pausedQueuedRequest(), pausedEpochCloseFund(), pausedFundedClaim(), pausedForceExit() State reads — see §11.3
moduleOf(bytes4), roleOf(bytes4) Routing table reads
setModule(), setModulesBatch() Routing table writes (onlyOwner, blocked once FLAG_ROUTING_FROZEN is set)
freezeRouting() Freeze routing table (irreversible)
setSelectorRegistry() One-shot registry binding (onlyOwner, blocked post-seal)
setGuardian() Update guardian address (onlyOwner, blocked post-seal — matches AdminModule.setVetoer()'s existing _requireNotSealed() guard; not the AdminModule.setEcosystem()-era table entry that used to appear under §4 AdminModule in modules.md — this is a direct CoreVault function)
setRecoveryGate() One-shot Emergency Module Recovery gate binding (onlyOwner, blocked post-seal — see recovery.md)
recoverModuleGroup() Emergency Module Recovery entry point (onlyRecoveryGate only — see recovery.md)
pauseAll(), unpauseAll(), pauseDepositsOnly(), pauseWithdrawalsOnly(), pauseInstantWithdrawalOnly(), pauseEpochCloseFundOnly(), pauseQueuedRequestOnly(), pauseFundedClaimOnly(), pauseForceExitOnly(), guardianPause() Pause control — full breaker table in §11.3
beginOwnerTransfer(), acceptOwnerTransfer() Ownership transfer
processorMint(), processorBurn(), processorTransfer(), processorSpendAllowance() Module callbacks for share accounting
authorizeModule(), isModuleAuthorized() Module authorization for processor functions (onlyOwner, blocked post-seal)
setAuthorizedSealer(), sealBySealer() System sealing (onlyOwner, blocked post-seal)
payRewardShares() Dedicated reward payout (via RewardsPayoutManager) — transfers from a pre-funded rewards treasury, never mints; non-dilutive by construction

3. Module Routing and Access Control

3.1 Role Constants

CoreVault defines five role constants that govern which addresses may call which selectors via the fallback() router:

// CoreVault.sol:71-75
uint8 public constant ROLE_PUBLIC = 0;           // no restriction
uint8 public constant ROLE_OWNER = 1;            // only owner
uint8 public constant ROLE_GUARDIAN = 2;         // only guardian
uint8 public constant ROLE_OWNER_OR_GUARDIAN = 3; // owner or guardian
uint8 public constant ROLE_MODULE = 4;            // only address(this)

The role check is performed in fallback() before the delegatecall to the module. ROLE_PUBLIC (0) has no restriction — any address may call.

3.2 SelectorRegistry

SelectorRegistry is a stateless, immutable contract that serves as the single source of truth for selector-to-role mappings. It encodes the security model at deploy time and cannot be modified:

// SelectorRegistry.sol:27-28
// No storage, no admin, no upgrades — fully immutable
contract SelectorRegistry {

Key properties (src/core/libraries/SelectorRegistry.sol:55-208):

  • getRequiredRole(bytes4 selector) → uint8: O(1) switch-case lookup, pure function.
  • validateRoleAssignment(bytes4, uint8) → bool: reverts with InvalidRoleForSelector if a registered selector is assigned the wrong role.
  • requireKnownSelector(bytes4): reverts with UnknownSelector if the selector is not in the registry (allowlist mode).
  • Total registered selectors: 92 (src/core/libraries/SelectorRegistry.sol:388-390).
  • Owner-critical selectors: 35 (all AdminModule governance functions + FM governance).

CoreVault.setModule() calls SelectorRegistry.validateRoleAssignment() before every routing table write, preventing misrouting attacks where an owner-only function could be mistakenly set as public (src/core/CoreVault.sol:292-302).

3.3 Routing Table

The routing table is stored in CoreStorage.Layout:

// CoreStorage.sol:79-80
mapping(bytes4 => address) moduleOf;
mapping(bytes4 => uint8)   roleOf;

Once freezeRouting() is called (src/core/CoreVault.sol:304-309), the routing table is permanently frozen — no further setModule() calls are accepted. This is a precondition for sealBySealer() (see §10).

The routing table is populated during deployment by setModulesBatch(). Typical mappings:

Selector Group Module Role
deposit, mint, redeem, withdraw, forceWithdraw ERC4626Module ROLE_PUBLIC
requestEpochWithdrawal, keeperSettleClaims, 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
deployToStrategies, realizeForQueue, canDeploy LiquidityOpsModule ROLE_PUBLIC

3.4 Processor Functions

Modules executing via delegatecall cannot directly call ERC20-inherited functions on CoreVault (_mint, _burn, _transfer) because those are internal. Instead, CoreVault exposes four processor callbacks:

// CoreVault.sol:681-699
function processorMint(address to, uint256 amount) external { _requireModuleAccess(); _mint(to, amount); }
function processorBurn(address from, uint256 amount) external { _requireModuleAccess(); _burn(from, amount); }
function processorTransfer(address from, address to, uint256 amount) external { _requireModuleAccess(); _transfer(from, to, amount); }
function processorSpendAllowance(address owner_, address spender, uint256 amount) external { _requireModuleAccess(); _spendAllowance(owner_, spender, amount); }

_requireModuleAccess() (src/core/CoreVault.sol:673-679) authorizes two patterns:

  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().

4. Storage Model

All vault state is stored using EIP-7201 namespaced storage to prevent collisions between modules sharing the vault's storage context via delegatecall. Each namespace is a separate library with a fixed SLOT constant.

4.1 Storage Namespaces

Library Namespace string SLOT (truncated)
CoreStorage dsf.core.main.storage.v1 0x5f3e8c9a...2d00
FeeStorage dsf.core.fee.storage.v1 0x2b4d6f8a...2b00
QueueStorage dsf.core.queue.storage.v1 0x8a3c5e7b...1a00
FixedMaturityStorage dsf.core.fixedmaturity.storage.v1 0xa3a75559...9300

Each library exposes a layout() function that returns a storage pointer at the fixed slot via inline assembly. For detailed field-by-field layout see storage-layout.md.

4.2 Packed Flags

CoreStorage.Layout.packedFlags is a uint256 bitmap used for gas-efficient state flags. Each bit corresponds to a specific boolean state:

Bit Constant Meaning
0 FLAG_PAUSED All operations paused
1 FLAG_PAUSED_DEPOSITS Deposits only paused
2 FLAG_PAUSED_WITHDRAWALS Withdrawals only paused
3 FLAG_PARAMS_FROZEN paramMinDelay permanently frozen
4 FLAG_LIQUIDITY_LOCKED Liquidity reallocation locked
5 FLAG_NAV_SMOOTH_INIT NAV smoothing initialized
6 FLAG_ROUTING_FROZEN Module routing table frozen (irreversible)
7 FLAG_REENTRANCY_LOCKED Reentrancy guard
8 FLAG_COMPONENTS_TIMELOCKED Component changes require timelock
9 FLAG_SYSTEM_SEALED System permanently sealed
10 FLAG_DEAD_DEPOSIT_DONE Dead deposit seeded (inflation attack hardening)
11 FLAG_FEES_INITIALIZED Fees initialized via setInitialFees()
12 FLAG_PERF_INITIALIZED Performance fee initialized via setInitialPerfParams()
13 FLAG_INSTANT_WITHDRAWAL_PAUSED Instant settlement only paused (queued exits unaffected)
14 FLAG_QUEUED_REQUEST_PAUSED New queued-exit requests only paused (cancelling existing ones unaffected)
15 FLAG_EPOCH_CLOSE_FUND_PAUSED Epoch close/fund/crystallize only paused
16 FLAG_FUNDED_CLAIM_PAUSED Funded-claim settlement only paused
17 FLAG_FORCE_EXIT_PAUSED Force exit only paused — the only flag that can gate it; see §4.3

Source: src/core/storage/CoreStorage.sol:24-41.

Bits 13-17 (review §20/§21, "Recommended Withdrawal Circuit Breakers" / "Required Pause Matrix") replace the previous all-or-nothing behavior of FLAG_PAUSED_WITHDRAWALS on EpochedQueueModule — before their introduction, that flag was not read anywhere in EpochedQueueModule.sol at all, so guardianPause()/pauseWithdrawalsOnly() had zero effect on the queue. See §11.3.


5. NAV Accounting and totalAssets()

5.1 Live NAV (Canonical)

CoreVault.grossAssets() sums hot asset balance, cached warm NAV and enabled strategy assets. totalOwed() is unfunded nominal liabilities plus funded cohorts' remaining reserves. The ERC-4626 shell computes totalAssets() = max(0, grossAssets() - totalOwed()). Share conversions, deposit/mint pricing and performance fees use this shareholder NAV. Liquidity planning uses gross assets and must preserve reservedForClaims.

totalAssetsBreakdown() returns gross portfolio NAV, hot and warm components. The live liabilityIndex() is diagnostic; funded claims use their immutable cohort recovery index. navStatus() validates the warm cache (including its 15-minute age limit), enabled strategy readability/health and configured oracle inputs. Ordinary requests require valid NAV. Funding refreshes stale warm NAV when a haircut is indicated and refuses to crystallize an invalid valuation. See the insolvency runbook.

5.2 Ops NAV Cache (Non-Canonical)

A separate cache exists for gas-sensitive operational decisions:

// CoreVault.sol:80-82
uint256 internal _opsNavCache;
uint64  internal _opsNavCacheTs;
uint32  public   opsNavCacheTtl = 60; // seconds

Allowed uses: checkUpkeep, keeper scheduling, liquidity routing pre-checks. Forbidden uses: convertToAssets, convertToShares, fee math, share mint/burn, payout.

The cache is refreshed after every deposit, mint, withdraw, redeem call via _refreshOpsNavCache() (src/core/CoreVault.sol:655-658). Off-path callers read it via cachedNavForOps() (src/core/CoreVault.sol:645-647).

5.3 Deposit Admission Gate

Deposits are gated by _depositsAreCurrentlyAllowed() (src/core/CoreVault.sol:616-627). A deposit is rejected if:

  • FLAG_PAUSED or FLAG_PAUSED_DEPOSITS is set.
  • bufferManager is not set (address zero).
  • warmNavState() is not valid.
  • warmNavState() timestamp is older than 15 minutes (MAX_WARM_NAV_AGE = 15 minutes, src/core/modules/ERC4626Module.sol:73).

This ensures that no shares are minted when the warm NAV is stale or unavailable, preventing dilution attacks.


6. Exit Semantics (v9 Async Protocol)

6.1 No Sync Withdraw

This is a queued protocol. Standard ERC-4626 withdraw() and redeem() always revert with AsyncWithdrawalRequired:

// ERC4626Module.sol:140-157
function withdraw(uint256, address, address) external pure returns (uint256) {
    revert ExitEngineLib.AsyncWithdrawalRequired();
}
function redeem(uint256, address, address) external pure returns (uint256) {
    revert ExitEngineLib.AsyncWithdrawalRequired();
}

ERC-4626 compliance is maintained: maxWithdraw(address) and maxRedeem(address) both return 0, signaling that these functions will revert. Source: src/core/CoreVault.sol:544-553.

6.2 Exit Modes

The protocol defines three exit modes (src/core/libraries/ExitEngineLib.sol:28-33):

enum ExitMode {
    STANDARD, // requestEpochWithdrawal() — witBps only
    INSTANT,  // requestInstantWithdrawal() — witBps + immediateExitPenaltyBps
    FORCE     // forceWithdraw()     — witBps + forceExitPenaltyBps
}
Mode Path Epoch Cap Lock Period Fee
STANDARD requestEpochWithdrawal() Not consumed Enforced witBps
INSTANT requestInstantWithdrawal() Consumed Enforced witBps + immediateExitPenaltyBps
FORCE forceWithdraw() Not consumed Bypassed witBps + forceExitPenaltyBps

6.3 requestInstantWithdrawal() / requestEpochWithdrawal() — INSTANT vs QUEUED

Both ordinary request paths enforce deposit locks, refresh warm NAV and require valid NAV. They compute fee shares rounded up, price net shares before burning them, transfer the fee shares to feeCollector, and record a fixed assetsOwed liability. Zero-value requests revert; there is no configured withdrawal minimum and requests cannot be cancelled.

Instant eligibility uses refreshed gross share value against the fixed cap-epoch allowance and free hot/warm liquidity. Success pays the fixed net liability immediately and consumes that payout from the cap. Insufficient cap/liquidity or an instant pause selects the standard fee tier and records a queued claim. Invalid NAV and active deposit locks revert; they do not select a queued fallback.

6.4 Queue Processing — Epoch Close, Fund, Claim

  1. closeCurrentEpoch() closes a settlement bucket and opens the next one. Prices, fee transfers and share burns are already fixed by each request.
  2. fundEpoch(epochId) sizes the cohort using free assets and unfunded liabilities, excluding funded reserves from both. It pulls warm liquidity, then strategies, and recomputes requirements. A haircut requires valid NAV. Funding reserves cash, fixes recoveryIndex, writes the haircut out of liabilities and marks the epoch Funded.
  3. claimEpochAssets and batchClaimEpochAssets let owners receive their fixed recovery payout. Permissionless keeperSettleClaims pays the same recorded owners automatically. Neither path burns shares or charges another exit fee. Settlement of each claim occurs once.

Automatic settlement is a required deployment service. ClaimSettlementUpkeep uses a bounded circular scan and batch-to-individual fallback. Idle or unfunded-only queues do not request upkeep. fundedOutstandingClaimCount() provides this constant-time check without scanning the vault's history; fundedEpochCount() wakes an idle scan on every new funding. A zero-recovery claim still needs settlement. Manual claims remain available if automation is unfunded, delayed or excludes a claim. Recipient transfer failures cannot be bypassed by either caller; claims remain retryable.

See queue mechanics and the settlement operations runbook.

6.5 forceWithdraw — Guaranteed Exit

forceWithdraw(uint256 assets, address receiver, address owner_, IStrategyRouter.Pull[] calldata plan, uint256 maxShares) (src/core/modules/ERC4626Module.sol:163-250) provides a guaranteed exit path:

  • Bypasses lock period.
  • Does NOT consume epoch cap.
  • Requires a user-supplied liquidity plan specifying which strategies to redeem from.
  • Applies witBps + forceExitPenaltyBps fee (and preMaturityForceExitPenaltyBps in FM/Active state).
  • Maximum 10 strategy legs (MAX_FORCE_LEGS = 10, src/core/modules/ERC4626Module.sol:74).

forceWithdrawAll(address receiver, uint256 minAssetsOut) (src/core/modules/ERC4626Module.sol:272-367) is a no-plan variant: attempts hot → warm → strategy liquidity waterfall in sequence, then burns shares/fees proportional to the fill ratio actually raised (unfilled shares are left live and retriable). Reverts with SlippageExceeded if the fill is below the caller's minAssetsOut (F-03), with no state changed.


7. Fee Mechanics

7.1 Fee Types

Four fee parameters are stored in FeeStorage.InternalFeeParams (src/core/storage/FeeStorage.sol:12-18):

Field Type Applied at
depBps uint16 Deposit — deducted from deposited assets
witBps uint16 All exits — base withdrawal fee
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).

7.2 Fee Computation

ExitFeeLib is the single source of truth for exit fee computation (src/core/libraries/ExitFeeLib.sol):

STANDARD:  feeBps = witBps
INSTANT:   feeBps = witBps + immediateExitPenaltyBps
FORCE:     feeBps = witBps + forceExitPenaltyBps
           [+ preMaturityForceExitPenaltyBps if FM mode + Active state]

Fee shares are computed by ExitEngineLib.computeFeeShares() (src/core/libraries/ExitEngineLib.sol:151-174), rounded UP (directive #5). Fee shares are transferred to feeCollector via processorTransfer — no new shares are minted, so the operation is non-dilutive.

7.3 Deposit Fee

The deposit fee (depBps) is applied as:

feeAssets = mulBpsDown(assets, depBps)
net = assets - feeAssets
shares = convertToShares(net)
sharesFee = convertToShares(feeAssets)

Gross shares (shares + sharesFee) are minted to the receiver, then sharesFee is transferred to feeCollector. This is non-dilutive: totalSupply increases by convertToShares(assets), proportional to the totalAssets increase. Source: src/core/modules/ERC4626Module.sol:475-493.

7.4 Performance Fee (Crystallization)

A high-water mark (HWM) performance fee is crystallized at epoch end via endEpochCrystallize(). The fee is minted as new shares to feeCollector:

profit = totalAssets - (HWM * totalSupply)
feeAssets = profit * perfRateX
feeShares = convertToShares(feeAssets)

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.

Performance fee minting is dilutive by design — it represents protocol revenue proportional to vault growth.

7.5 Fee Changes (Timelock)

Fee parameters are changed via a submit/accept/revoke timelock pattern in AdminModule. The timelock duration is paramMinDelay (set at deployment, adjustable with its own timelock). The maximum ETA window is 7 days (src/core/modules/AdminModule.sol:45).

During bootstrap, paramMinDelay = 0 (immediate acceptance). Owners MUST increase this post-deployment. Source: src/core/CoreVault.sol:104.


8. Buffer Management (Hot/Warm)

8.1 Three-Tier Liquidity Architecture

User deposits/exits
       │
       ▼
┌──────────────┐
│ HOT BUFFER   │  = USDC held in CoreVault
│ (CoreVault)  │  ← immediate exits paid from here
└──────┬───────┘
       │  BufferManager deploys/refills
       ▼
┌──────────────┐
│ WARM BUFFER  │  = WarmAdapters (Aave, Morpho for short-term buffer)
│ (Adapters)   │  ← BufferManager.refill() pulls from here
└──────┬───────┘
       │  StrategyRouter deploys/redeems
       ▼
┌──────────────┐
│ STRATEGY     │  = Long-term yield strategies
│ (Router)     │  ← LiquidityOpsModule.deployToStrategies()
└──────────────┘

8.2 BufferManager Role

BufferManager (src/core/modules/BufferManager.sol) is a standalone contract (not a module, not called via delegatecall). It manages:

  • Deploy: push excess hot funds into warm adapters.
  • Refill: pull from warm adapters back to hot buffer when needed for queue settlement.
  • Warm NAV cache: maintains cachedWarmNav, updated by rebalance() and permissionless refreshWarmNav().

Critical invariant: BufferManager must NEVER hold idle asset balance. All assets flow directly between CoreVault (hot) and WarmAdapters. Source: src/core/modules/BufferManager.sol:16-20.

8.3 Warm NAV Validity

BufferManager.warmNavState() returns (uint256 warmNav, uint40 ts, bool valid).

valid = false if any adapter failed during the last NAV cache update. When valid = false:

  • Deposits are rejected (_depositsAreCurrentlyAllowed() returns false).
  • Ordinary requests reject invalid NAV after their refresh attempt.
  • Haircut funding stays Closed until NAV is valid.
  • Funded claims use their immutable recovery index and remain subject to their own breaker.

The warm NAV timestamp expires after MAX_WARM_NAV_AGE = 15 minutes (src/core/modules/ERC4626Module.sol:73). ERC4626Module._ensureFreshWarmNav() attempts a soft refresh on deposit and reverts if the result is still invalid or stale.

For ordinary requests and haircut funding, _trySoftRefreshWarmNav() catches refresh errors; the following NAV validity check determines whether pricing or funding may proceed.

8.4 Buffer Config

BufferManager.getConfig() returns a BufferConfig struct with:

  • opsReserveTargetBps: percentage of NAV to keep as hot reserve.
  • maxWarmBps: maximum percentage of NAV to deploy to warm adapters.
  • maxWarmSlippageBps: slippage tolerance for warm adapter withdrawals.

These configure when canDeploy() triggers deployment (src/core/modules/LiquidityOpsModule.sol:60-97).


9. Fixed Maturity Lifecycle States

CoreVault supports two vault modes (coexisting deployments can be either):

// FixedMaturityStorage.sol:11-14
enum VaultMode { OpenEnded, FixedMaturity }

For OpenEnded vaults, all FixedMaturity logic is zero-cost (early return guards at every gating function). For FixedMaturity vaults, the vault progresses through a state machine:

// FixedMaturityStorage.sol:17-24
enum VaultState {
    Funding,       // accepting deposits, awaiting activation
    Starting,      // committed assets locked, strategy not yet deployed
    Active,        // capital deployed to fixedTermStrategy
    Matured,       // maturity reached; exits allowed
    Closed,        // terminal: all claims settled
    FundingFailed  // terminal: deadline passed, minFunding not reached
}

9.1 State Transition Diagram

stateDiagram-v2
    [*] --> Funding : setVaultModeFixedMaturity() + configureFixedMaturity()
    Funding --> Starting : startFixedMaturityCycle() [owner] or autoCloseFunding()
    Funding --> FundingFailed : markFundingFailed() [if deadline passed & net < min]
    Starting --> Active : activateFixedMaturityCycle() [owner]
    Active --> Matured : markMatured() [if block.timestamp >= maturityTs]
    Matured --> Closed : closeFixedMaturityCycle() [owner, no pending shares]
    FundingFailed --> [*] : refundClaim() settles all refunds
    Closed --> [*]
Loading

9.2 State-Dependent Operation Gating

Operations are gated by the current state via free functions in src/core/storage/FixedMaturityStorage.sol:

Operation Allowed in states
deposit() Funding (OpenEnded: always)
requestEpochWithdrawal() / requestInstantWithdrawal() Matured (OpenEnded: always)
closeCurrentEpoch() Matured (OpenEnded: always)
forceWithdraw() Active (OpenEnded: always)
deployToStrategies() OpenEnded only

9.3 Funding Failed Path

If the funding deadline passes and totalAssets < minFundingAssets, any user can call markFundingFailed(). A PPS snapshot fundingFailedPPS is taken at this point. Depositors can then call refundClaim() to recover their proportional share of assets at the snapshot PPS.

Source: src/core/modules/FixedMaturityModule.sol:37-80 (governance), src/core/storage/FixedMaturityStorage.sol:55-70 (storage layout).


10. System Sealing

The system sealing mechanism provides a tamper-proof finalization for production deployments. There are now two supported paths:

10.1 Atomic seal (production path): SystemSealer.verifyAndSeal()

ROOT_TIMELOCK schedules a single-call batch:
  systemSealer.verifyAndSeal(config)
    - Verifies all deployment invariants (owner, guardian, vetoer, components, ...)
    - Computes configHash = keccak256(config addresses)   — NO block.timestamp
    - Calls vault.sealBySealer(configHash), which atomically:
        - checks msg.sender == authorizedSealer
        - checks FLAG_ROUTING_FROZEN is set
        - sets pendingSealHash = configHash
        - sets FLAG_SYSTEM_SEALED
  - Permanent — no reversal

sealBySealer() (src/core/CoreVault.sol:369) is the only sealing path — invoked entirely from within SystemSealer.verifyAndSeal().

10.2 Removed: legacy two-step prepareSeal / sealFinalState

An earlier design split sealing into two owner-facing calls on CoreVault:

Owner calls prepareSeal(configHash):
  - Requires FLAG_ROUTING_FROZEN
  - Requires authorized sealer (SystemSealer contract)
  - Stores pendingSealHash = configHash

Owner calls sealFinalState(expectedHash):
  - Verifies expectedHash == pendingSealHash (TOCTOU protection)
  - Sets FLAG_SYSTEM_SEALED
  - Permanent — no reversal

The contract-side half of this pattern, SystemSealer.prepareSeal(), computed configHash including block.timestamp, which made the hash unrecoverable at executeBatch() time: scheduleBatch() and executeBatch() run at different blocks separated by the full timelock delay, so no operator could ever encode the matching hash in advance and the batch always reverted (see test/sprint-test/SystemSealer_TimestampHash_POC.t.sol). verifyAndSeal()/sealBySealer() fixed this by excluding block.timestamp from the hash and sealing atomically in one call.

Once the atomic path existed, CoreVault.prepareSeal() and CoreVault.sealFinalState() had no remaining caller (SystemSealer.prepareSeal() was already gone) and were removed as dead code ahead of audit, along with their now-unused SealHashMismatch/SealNotPrepared errors and SealPrepared event.

After sealing:

  • authorizeModule() reverts (SystemSealed).
  • Further sealer assignment reverts.

Source: src/core/SystemSealer.sol, src/core/CoreVault.sol:343-378.


11. Ownership and Pause

11.1 Ownership (2-Step Transfer)

Ownership transfer is a two-step process to prevent accidental loss:

  1. Current owner calls beginOwnerTransfer(newOwner) — stores pendingOwner.
  2. newOwner calls acceptOwnerTransfer() — atomically transfers ownership.

Source: src/core/CoreVault.sol:394-407.

11.2 Guardian

The guardian is a separate address with limited pause capability, matching review §3.3 ("fast to restrict, slow to restore"): guardians cannot unpause, modify routing, or reach the exceptional owner-only breakers (§11.3) — these require owner.

guardianPause() is the guardian's single rapid action, subject to a cooldown enforced by IParamsProvider.guardianPauseCooldown() (default 7 days). It sets three flags together: FLAG_PAUSED (deposits), FLAG_INSTANT_WITHDRAWAL_PAUSED, and FLAG_EPOCH_CLOSE_FUND_PAUSED — the two withdrawal breakers review §20 approves for Guardian use. It deliberately does not reach queued-request creation, funded claims, or force exit (src/core/CoreVault.sol:453-469).

The guardian may also trip pauseInstantWithdrawalOnly() and pauseEpochCloseFundOnly() individually (both onlyOwnerOrGuardian), for a narrower response than the combined guardianPause().

11.3 Pause Granularity

pauseAll()/unpauseAll()/pauseDepositsOnly()/pauseWithdrawalsOnly() remain owner-only. Five additional breakers (review §20/§21) give targeted, owner- or guardian-scoped control over the withdrawal surface instead of one all-or-nothing flag:

Function Pauses Flag Who
pauseAll() Deposits (only — see §11.2) FLAG_PAUSED Owner
pauseDepositsOnly(true) Deposits only FLAG_PAUSED_DEPOSITS Owner
pauseWithdrawalsOnly(true) Instant settlement + epoch close/fund (aggregate) FLAG_PAUSED_WITHDRAWALS Owner
pauseInstantWithdrawalOnly(true) Instant settlement only (falls back to queue, does not revert) FLAG_INSTANT_WITHDRAWAL_PAUSED Owner or Guardian
pauseEpochCloseFundOnly(true) closeCurrentEpoch/fundEpoch/endEpochCrystallize/syncOldestUnfundedEpoch FLAG_EPOCH_CLOSE_FUND_PAUSED Owner or Guardian
pauseQueuedRequestOnly(true) NEW queued-exit requests only (cancelling an existing one is unaffected) FLAG_QUEUED_REQUEST_PAUSED Owner only, exceptional
pauseFundedClaimOnly(true) claimEpochAssets/batchClaimEpochAssets only FLAG_FUNDED_CLAIM_PAUSED Owner only, exceptional
pauseForceExitOnly(true) forceWithdraw/forceWithdrawAll only FLAG_FORCE_EXIT_PAUSED Owner only, dedicated
guardianPause() Deposits + instant settlement + epoch close/fund FLAG_PAUSED + FLAG_INSTANT_WITHDRAWAL_PAUSED + FLAG_EPOCH_CLOSE_FUND_PAUSED Guardian

Two rules follow directly from review §20 and are enforced structurally, not just by convention:

  • Force exit is read by exactly one flag, FLAG_FORCE_EXIT_PAUSED. It ignores FLAG_PAUSED, FLAG_PAUSED_WITHDRAWALS, and every other breaker — pauseAll()/guardianPause()/pauseWithdrawalsOnly() can never block it as a side effect.
  • Queued-request creation and funded claims ignore FLAG_PAUSED_WITHDRAWALS. Exit intent must stay recordable while settlement is paused (review §19), and funded claims must never be blockable by any general administrative flag (review §20) — only their own dedicated, owner-only breaker reaches them.

unpauseAll() clears all eight flags atomically. Source: src/core/CoreVault.sol:410-540, src/core/modules/EpochedQueueModule.sol (queue-side checks), src/core/modules/ERC4626Module.sol (force-exit check). Full per-breaker test coverage: test/invariants/Withdrawal_PauseMatrix_Invariants.t.sol.


12. Strategy Boundary

CoreVault interacts with strategies exclusively through IStrategyRouter. The router is set via AdminModule.setRouter() (or submitRouter / acceptRouter if component timelock is enabled).

12.1 StrategyRouter Interface

From the vault's perspective, the strategy boundary is:

Interface method Called by Purpose
totalStrategyAssetsSafe() CoreVault._totalAssetsBreakdown() NAV contribution from strategies
forceRedeemForWithdraw(amount) ERC4626Module._forcePullAllLiquidity() Emergency redeem for forceWithdrawAll
isStrategyEnabled(strat) ERC4626Module._validateAndExecutePlan() Validate plan in forceWithdraw
executeRedeemBatch(plan) ERC4626Module._validateAndExecutePlan() Execute forceWithdraw plan
list() LiquidityOpsModule.canDeploy() Check for enabled strategies
executeDepositBatch(plan) LiquidityOpsModule.deployToStrategies() Deploy surplus to strategies

The StrategyRouter enforces per-strategy allocation caps and health checks independently of the vault. For the full strategy system documentation see multyr-strategies/docs/usdc-lending/overview.md.

12.2 Strategy Health Registry

IStrategyHealthRegistry (CoreStorage.Layout.healthRegistry) provides health status for strategies. It is consulted by StrategyRouter to determine whether a strategy is deployable.


13. Public Interface Summary

This table covers the 30 most important functions. For the complete selector registry see src/core/libraries/SelectorRegistry.sol.

Function Module/Contract Role Events Key Reverts
deposit(uint256,address) ERC4626Module PUBLIC Deposit, DepositFeeTaken Paused, NavInvalid, NavStale, VaultDepositCapExceeded
mint(uint256,address) ERC4626Module PUBLIC Deposit, DepositFeeTaken same as deposit
withdraw(...) ERC4626Module PUBLIC — AsyncWithdrawalRequired (always)
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)
requestInstantWithdrawal(uint256) EpochedQueueModule PUBLIC InstantExit or EpochWithdrawalRequested ZeroAmount
requestEpochWithdrawal(uint256) EpochedQueueModule PUBLIC EpochWithdrawalRequested ZeroAmount, EpochNotOpen
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
acceptFeeParams() AdminModule OWNER FeeParamsAccepted EtaNotReached, NotPending
setParams(address) AdminModule OWNER — ZeroAddress, ComponentsTimelocked
setRouter(address) AdminModule OWNER — ZeroAddress, ComponentsTimelocked
setBufferManager(address) AdminModule OWNER — ZeroAddress, ComponentsTimelocked
setEcosystem(...) AdminModule OWNER — ZeroAddress
seedDeadDeposit(uint256) AdminModule OWNER DeadDepositSeeded DeadDepositAlreadySeeded, ZeroAmount
setVaultModeFixedMaturity() FixedMaturityModule OWNER VaultModeConfigured InvalidVaultMode, RoutingFrozen
configureFixedMaturity(...) FixedMaturityModule OWNER FixedMaturityConfigured AlreadyConfigured, InvalidVaultState
startFixedMaturityCycle() FixedMaturityModule OWNER FixedMaturityStarted FixedMaturityNotConfigured
markMatured() FixedMaturityModule PUBLIC FixedMaturityMatured, FinalPerformanceFeeApplied MaturityNotReached
refundClaim() FixedMaturityModule PUBLIC RefundClaimed NotFundingFailed
pauseAll() CoreVault OWNER AllPaused —
guardianPause() CoreVault GUARDIAN GuardianPauseActivated GuardianCooldownActive
setModule(bytes4,address,uint8) CoreVault OWNER ModuleSet RoutingFrozen, InvalidRoleForSelector
freezeRouting() CoreVault OWNER RoutingFrozen RoutingFrozen (if already frozen)
sealBySealer(bytes32) CoreVault SEALER SystemSealed RoutingNotFrozen, SystemSealed, InvalidConfigHash

14. Invariants

Invariants enforced by the protocol. For the invariant inventory and test coverage see audit-scope.md.

ID Statement Enforcement location Verified by test
I1 withdraw() and redeem() NEVER transfer assets src/core/modules/ERC4626Module.sol:140-157 (pure revert) test/unit/ERC4626Module.t.sol + Halmos halmos-core/
I2 totalSupply NEVER increases on exit no _mint in exit paths; fees via transfer test/unit/ERC4626Module.t.sol + Halmos
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 Exit fee shares always from owner 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)
I10 Routing table write blocked after freezeRouting() FLAG_ROUTING_FROZEN check in setModule() test/unit/AdminModule.t.sol
I11 processorMint/Burn/Transfer only callable by authorized modules or delegatecall context _requireModuleAccess() test/unit/CoreVault*.t.sol (RBAC assertions)
I12 Dead deposit seeded exactly once FLAG_DEAD_DEPOSIT_DONE + DeadDepositAlreadySeeded guard test/unit/AdminModule.t.sol

15. External Calls

All external calls made by the vault system and their safety properties:

Call Caller Target Max Gas Safety
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, 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() EpochedQueueModule.fundEpoch() BufferManager ~200K best case; up to ~200K × 8 × adapter count worst case1 try/catch; non-blocking
bm.forceRefill() ERC4626Module._forcePullAllLiquidity() BufferManager ~200K best case; up to ~200K × 8 × adapter count worst case1 Best-effort
inc.onDeposit() ERC4626Module._notifyIncentivesDeposit() IIncentives ~50K try/catch; non-blocking
eng.onDeposit/onExit() ERC4626Module IIncentivesEngine ~50K try/catch; non-blocking

16. Edge Cases

16.1 Zero totalSupply at deploy

Before seedDeadDeposit() is called, totalSupply == 0. In this state convertToAssets(0) == 0 and deposits via ERC4626Module will compute previewDeposit(net) == 0 shares. Owners MUST call seedDeadDeposit() before the first real deposit to prevent the ERC-4626 inflation attack. Source: src/core/modules/AdminModule.sol (seedDeadDeposit()).

16.2 Warm NAV = 0

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 requestInstantWithdrawal fallback to the epoch queue

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)

In FixedMaturity/Active state, markMatured() is callable by anyone once block.timestamp >= maturityTs. The final performance fee is applied exactly once at the moment markMatured() is called. If markMatured() is called significantly later than maturityTs, the performance fee base will reflect NAV at that later time — the protocol's fee should be computed on growth during the active period, not extended waiting time. Governance should trigger markMatured() promptly after maturity.

16.5 Max uint values and dust

  • 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.

17. Glossary and Cross-Links

Term Definition
Diamond-lite Delegatecall-based modular architecture; selectors routed to external logic contracts. Not EIP-2535.
Hot buffer USDC held directly in CoreVault (immediate liquidity).
Warm buffer USDC deployed to short-duration adapters (Aave, Morpho) for yield; managed by BufferManager.
Strategy Long-term yield strategy (USDC Lending, Multiply). Accessed via StrategyRouter.
PPS Price-per-share = totalAssets * 1e18 / totalSupply.
Epoch cap Maximum fraction of NAV withdrawable via INSTANT exits per epoch (configurable via capPerEpochBps).
HWM High-water mark for performance fee crystallization.
paramMinDelay Minimum timelock delay for fee/governance param changes (bootstrap: 0, production: ≥2 days).
Sealer SystemSealer contract authorized to call sealBySealer() (via verifyAndSeal()).
Dead deposit A "dead" share minted by owner at genesis to prevent the ERC-4626 inflation attack.
Lock period Minimum time between deposit and claim eligibility (anti-MEV).
NAV validity Ordinary requests and haircut crystallization require valid NAV. Funded claims use their fixed recovery index.

17.1 Protocol-Level Sequence Examples

Canonical deposit flow (OpenEnded, warmNav valid):

  1. User calls deposit(assets, receiver) → CoreVault ERC4626 thin wrapper
  2. CoreVault delegates to ERC4626Module._depositInternal() (src/core/modules/ERC4626Module.sol:457)
  3. FM gate: _checkDepositsAllowed(fm) → passes if OpenEnded or Funding
  4. Pause gate, reentrancy lock acquired (FLAG_REENTRANCY_LOCKED)
  5. _ensureFreshWarmNav() → calls BufferManager.warmNavState() if stale
  6. Limit check: assets <= maxDeposit(receiver) — cap enforcement
  7. Fee computation: feeBps = fee.depBps → feeShares = shares * feeBps / 10000
  8. processorMint(receiver, shares - feeShares) + processorMint(feeCollector, feeShares)
  9. safeTransferFrom(caller, address(this), assets) — USDC pulled
  10. Update lastDepositTs[receiver] and _opsNavCache

Canonical exit flow (INSTANT path):

  1. Acquire the reentrancy guard and roll the cap epoch before asset/supply mutations.
  2. Enforce the deposit lock, refresh warm NAV and validate NAV.
  3. Compare gross share value with the static cap allowance and free hot/warm liquidity.
  4. Select instant or standard fees, transfer fee shares and burn net shares at the fixed price.
  5. An eligible instant request pays the liability and consumes the net payout from the cap.
  6. A fallback records a fixed claim for close, funding and automatic or manual settlement.

Cross-Links

  • Storage layout details → field offsets, EIP-7201 slot computation, forge inspect: storage-layout.md
  • Module-by-module spec → per-function access control, storage access patterns, error codes: modules.md
  • Access control matrix → all selector→role assignments: access-control.md
  • Invariant inventory: audit-scope.md
  • Threat model: audit-scope.md
  • Exit engine + fee policy: cluster 01a.2 (pending)
  • Queue mechanics: cluster 01a.2 (pending)
  • Deployment guide: deployment.md
  • Emergency Module Recovery (post-seal): recovery.md

Code reference: commit 1595a279 on branch pierdev (date: 2026-05-15)

Source .md files that informed this document (topic coverage only, no content copied):

  • docs/01-architecture/DIAMOND-LITE-ARCHITECTURE.md — terminology and section coverage check
  • docs/01-architecture/TECH-DESIGN-COMPLETO.md — section coverage check
  • docs/01-architecture/V10_ENGINE_REPORT.md — design rationale for portfolio-grade allocation engine
  • docs/01-architecture/vault-flow.md — flow diagram structure
  • docs/01-architecture/FEECOLLECTOR-SAFETYRESERVE.md — fee flow section inspiration
  • docs/09-audit/architecture.md — auditor expectation coverage

Discrepancies found (code vs. old source .md):

Footnotes

  1. bm.refill()/bm.forceRefill() (and bm.rebalance()'s refill branch, and realizeForReserveAndOps()) all share _withdrawFromAdapters(), which retries each configured adapter up to MAX_ADAPTER_WITHDRAW_ATTEMPTS (8) times on partial fills before moving to the next adapter — an adapter that rations funds per call (a per-call cap or rate limit) is retried rather than abandoned after a single under-filled attempt. Worst-case external-call count is (legacy adapter + len(_warmAdapters)) × 8. _warmAdapters has no enforced max length (owner-configured), so this scales with adapter count. ↩ ↩2