Status: draft | Audit-scope: multyr-core@pierdev Last reviewed by code: commit
1595a279on branchpierdev(date: 2026-05-15) Version: 1.0.0-draft
- Overview
- Diamond-Lite Architecture
- Module Routing and Access Control
- Storage Model
- NAV Accounting and totalAssets()
- Exit Semantics (v9 Async Protocol)
- Fee Mechanics
- Buffer Management (Hot/Warm)
- Fixed Maturity Lifecycle States
- System Sealing
- Ownership and Pause
- Strategy Boundary
- Public Interface Summary
- Invariants
- External Calls
- Edge Cases
- Glossary and Cross-Links
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.
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
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.
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.
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 |
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.
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 withInvalidRoleForSelectorif a registered selector is assigned the wrong role.requireKnownSelector(bytes4): reverts withUnknownSelectorif 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).
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 |
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:
- Delegatecall context:
msg.sender == address(this)— EpochedQueueModule, AdminModule, LiquidityOpsModule call these from within delegatecall, somsg.senderis the vault itself. - Authorized external module:
isAuthorizedModule[msg.sender]— ERC4626Module executes as an external call (not delegatecall), so it must be registered viaauthorizeModule().
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.
| 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.
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.
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.
A separate cache exists for gas-sensitive operational decisions:
// CoreVault.sol:80-82
uint256 internal _opsNavCache;
uint64 internal _opsNavCacheTs;
uint32 public opsNavCacheTtl = 60; // secondsAllowed 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).
Deposits are gated by _depositsAreCurrentlyAllowed() (src/core/CoreVault.sol:616-627). A deposit is rejected if:
FLAG_PAUSEDorFLAG_PAUSED_DEPOSITSis set.bufferManageris 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.
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.
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 |
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.
closeCurrentEpoch()closes a settlement bucket and opens the next one. Prices, fee transfers and share burns are already fixed by each request.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, fixesrecoveryIndex, writes the haircut out of liabilities and marks the epoch Funded.claimEpochAssetsandbatchClaimEpochAssetslet owners receive their fixed recovery payout. PermissionlesskeeperSettleClaimspays 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.
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 + forceExitPenaltyBpsfee (andpreMaturityForceExitPenaltyBpsin 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.
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).
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.
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.
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.
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.
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()
└──────────────┘
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 byrebalance()and permissionlessrefreshWarmNav().
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.
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.
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).
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
}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 --> [*]
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 |
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).
The system sealing mechanism provides a tamper-proof finalization for production deployments. There are now two supported paths:
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().
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.
Ownership transfer is a two-step process to prevent accidental loss:
- Current owner calls
beginOwnerTransfer(newOwner)— storespendingOwner. newOwnercallsacceptOwnerTransfer()— atomically transfers ownership.
Source: src/core/CoreVault.sol:394-407.
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().
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 ignoresFLAG_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.
CoreVault interacts with strategies exclusively through IStrategyRouter. The router is set via AdminModule.setRouter() (or submitRouter / acceptRouter if component timelock is enabled).
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.
IStrategyHealthRegistry (CoreStorage.Layout.healthRegistry) provides health status for strategies. It is consulted by StrategyRouter to determine whether a strategy is deployable.
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 |
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 |
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 |
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()).
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.
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.
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.
- Epoch cap: if
capPerEpochBps == 0,_epochCapRemaining()returnstype(uint256).max(uncapped). Source:src/core/modules/EpochedQueueModule.sol:946-969. maxDeposit(receiver): if both vault and user caps are 0, returnstype(uint256).max. Source:src/core/CoreVault.sol:555-583.- Dust: minimum deposit enforced by
DepositBelowMinimumifminDepositAmount > 0.
| 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. |
Canonical deposit flow (OpenEnded, warmNav valid):
- User calls
deposit(assets, receiver)→ CoreVault ERC4626 thin wrapper - CoreVault delegates to
ERC4626Module._depositInternal()(src/core/modules/ERC4626Module.sol:457) - FM gate:
_checkDepositsAllowed(fm)→ passes ifOpenEndedorFunding - Pause gate, reentrancy lock acquired (
FLAG_REENTRANCY_LOCKED) _ensureFreshWarmNav()→ callsBufferManager.warmNavState()if stale- Limit check:
assets <= maxDeposit(receiver)— cap enforcement - Fee computation:
feeBps = fee.depBps→feeShares = shares * feeBps / 10000 processorMint(receiver, shares - feeShares)+processorMint(feeCollector, feeShares)safeTransferFrom(caller, address(this), assets)— USDC pulled- Update
lastDepositTs[receiver]and_opsNavCache
Canonical exit flow (INSTANT path):
- Acquire the reentrancy guard and roll the cap epoch before asset/supply mutations.
- Enforce the deposit lock, refresh warm NAV and validate NAV.
- Compare gross share value with the static cap allowance and free hot/warm liquidity.
- Select instant or standard fees, transfer fee shares and burn net shares at the fixed price.
- An eligible instant request pays the liability and consumes the net payout from the cap.
- A fallback records a fixed claim for close, funding and automatic or manual settlement.
- 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 checkdocs/01-architecture/TECH-DESIGN-COMPLETO.md— section coverage checkdocs/01-architecture/V10_ENGINE_REPORT.md— design rationale for portfolio-grade allocation enginedocs/01-architecture/vault-flow.md— flow diagram structuredocs/01-architecture/FEECOLLECTOR-SAFETYRESERVE.md— fee flow section inspirationdocs/09-audit/architecture.md— auditor expectation coverage
Discrepancies found (code vs. old source .md):
Footnotes
-
bm.refill()/bm.forceRefill()(andbm.rebalance()'s refill branch, andrealizeForReserveAndOps()) all share_withdrawFromAdapters(), which retries each configured adapter up toMAX_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._warmAdaptershas no enforced max length (owner-configured), so this scales with adapter count. ↩ ↩2