Status: draft | Audit-scope: multyr-core@pierdev Last reviewed by code: commit
1595a279on branchpierdev(date: 2026-05-15) Version: 1.0.0-draft
- Overview
- ERC4626Module
- EpochedQueueModule
- AdminModule
- BufferManager
- FixedMaturityModule
- LiquidityOpsModule
- FeeCollector
- Incentives (v1)
- IncentivesEngine (v2)
- BatchGuardrails
- PriceOracleMiddleware
- ExecutionMemory
- StrategyRouter
- StrategyScorer
- StrategyHealthRegistry
- RouterAllocationPolicy V10
- RouterRebalanceGuard V10
- Module Interaction Diagram
- Module Invariant Summary
- Module Deployment and Wiring Reference
Multyr Core uses a Diamond-lite architecture where all economic logic is implemented in separate module contracts invoked via delegatecall from CoreVault. For the routing mechanism see architecture.md §2.
Module execution context: Every module function that operates via delegatecall executes in CoreVault's storage context. address(this) equals CoreVault. msg.sender equals the original caller. msg.value is forwarded.
Module categories:
| Category | Modules | Deployment pattern |
|---|---|---|
| Delegatecall modules | ERC4626Module, EpochedQueueModule, AdminModule, LiquidityOpsModule, FixedMaturityModule | External contracts, invoked via delegatecall |
| Standalone modules | BufferManager, FeeCollector, BatchGuardrails, PriceOracleMiddleware, ExecutionMemory | Standard external contracts, external call |
| Strategy infrastructure | StrategyRouter, StrategyHealthRegistry | Standalone external contracts; called by LiquidityOpsModule |
| V10 allocation | StrategyScorer, RouterAllocationPolicy, RouterRebalanceGuard | Standalone view / guard contracts; called by LiquidityOpsModule and StrategyRouter |
| Legacy incentives | Incentives (v1), IncentivesEngine (v2) | External contracts, called via try/catch |
Security model for delegatecall modules:
- They access storage ONLY via EIP-7201 namespaced pointers (
CoreStorage.layout(),FeeStorage.layout(), etc.). - They call back to CoreVault via
address(this).call(...)for share operations (processor functions). - They must NOT call arbitrary external contracts without try/catch protection.
File: src/core/modules/ERC4626Module.sol
Version: v3 (ExitEngineLib Architecture)
Delegatecall: yes (authorized external module for processor functions)
Storage namespaces: CoreStorage, FeeStorage, FixedMaturityStorage
Handles all user-facing deposit and force-exit operations. Standard withdraw() / redeem() always revert — users must use EpochedQueueModule.requestInstantWithdrawal() / requestEpochWithdrawal() for queue-based exits.
| Function | Selector | Access | Description |
|---|---|---|---|
deposit(uint256,address) |
0x6e553f65 |
PUBLIC | Deposit assets, receive shares |
depositFor(uint256,address) |
0x36efd16f |
PUBLIC | Deposit on behalf of receiver; msg.sender is always the payer (router model) |
mint(uint256,address) |
0x94bf804d |
PUBLIC | Mint exact shares, pay gross assets |
deposit(uint256,address,uint256) |
0x0efe6a8b |
PUBLIC | Deposit with min-shares slippage guard |
mint(uint256,address,uint256) |
0x2a1f2a0c |
PUBLIC | Mint with max-assets slippage guard |
withdraw(uint256,address,address) |
0xb460af94 |
PUBLIC | Always reverts AsyncWithdrawalRequired |
redeem(uint256,address,address) |
0xba087652 |
PUBLIC | Always reverts AsyncWithdrawalRequired |
withdraw(uint256,address,address,uint256) |
0x9a0e7d66 |
PUBLIC | Always reverts AsyncWithdrawalRequired |
redeem(uint256,address,address,uint256) |
0xc6e6f592 |
PUBLIC | Always reverts AsyncWithdrawalRequired |
forceWithdraw(uint256,address,address,(address,uint256)[],uint256) |
0x439fdeb4 |
PUBLIC | Guaranteed exit with user plan |
forceWithdrawAll(address,uint256) |
0xe375b48f |
PUBLIC | Best-effort exit: burns only the proportional slice of shares/fees matching assets actually raised; reverts SlippageExceeded if the fill is below the caller's minAssetsOut (F-03) |
Source: src/core/modules/ERC4626Module.sol:81-332.
deposit(assets, receiver):
1. _checkDepositsAllowed() — FixedMaturity gate
2. _notPausedDeposits() — FLAG_PAUSED / FLAG_PAUSED_DEPOSITS
3. _enterNonReentrant() — FLAG_REENTRANCY_LOCKED
4. _ensureFreshWarmNav() — auto-refresh, reverts if invalid/stale
5. _enforceDepositLimits() — min/vault/user cap checks
6. feeA = mulBpsDown(assets, depBps)
7. net = assets - feeA
8. shares = convertToShares(net)
9. sharesFee = convertToShares(feeA)
10. safeTransferFrom(payer → vault, assets)
11. processorMint(receiver, shares + sharesFee)
12. processorTransfer(receiver → feeCollector, sharesFee)
13. emit Deposit, DepositFeeTaken
14. notifyIncentives (try/catch)
15. FM auto-close check (if FixedMaturity + Funding + autoClose)
16. _exitNonReentrant()
Source: src/core/modules/ERC4626Module.sol:457-519.
forceWithdraw(assets, receiver, owner_, plan, maxShares):
1. _checkForceExitAllowed() — FixedMaturity gate
2. _notPausedWithdrawals()
3. _enterNonReentrant()
4. _trySoftRefreshWarmNav() — best-effort, never blocks
5. baseShares = previewWithdraw(assets)
6. (totalFeeShares, _) = ExitEngineLib.computeFeeShares(baseShares, FORCE, fee)
7. sharesSpent = baseShares + totalFeeShares
8. if sharesSpent > maxShares → revert SlippageExceeded
9. _processorSpendAllowance() if caller != owner
10. _checkWithdrawalLimitsForForce() — per-tx and per-block limits
11. _sourceLiquidityForForceWithdraw() — hot → warm → plan execution
12. processorTransfer(owner → feeCollector, totalFeeShares)
13. notifyIncentivesExit (try/catch)
14. processorBurn(owner, baseShares)
15. safeTransfer(receiver, assets)
16. emit ForceWithdrawExecuted, ForceExit
Source: src/core/modules/ERC4626Module.sol:163-250.
| ID | Statement |
|---|---|
| M2-I1 | withdraw() and redeem() NEVER transfer assets (pure revert) |
| M2-I2 | totalSupply NEVER increases on any exit (no _mint in exit paths) |
| M2-I3 | forceWithdraw does NOT consume epoch cap |
| M2-I4 | Fee shares transferred via processorTransfer (not mint) — non-dilutive |
| M2-I5 | Deposit rejected if warmNavValid=false or warmNav age > 15 min |
| Error | Condition |
|---|---|
AsyncWithdrawalRequired |
withdraw/redeem always |
Paused, DepositsPaused, WithdrawalsPaused |
Flag checks |
NavInvalid |
bufferManager=0 or warmNavValid=false after refresh attempt |
NavStale |
warmNav timestamp > 15 min |
VaultDepositCapExceeded(after, cap) |
Total NAV + deposit > cap |
UserDepositCapExceeded(after, cap) |
User assets + deposit > user cap |
SlippageExceeded |
Slippage-protected overloads |
EmptyPlan, PlanTooLong, PlanSumInsufficient |
forceWithdraw plan validation |
InsufficientLiquidity |
Plan executed but hot still insufficient |
ReentrancyGuardLocked |
Reentrant call detected |
File: src/core/modules/EpochedQueueModule.sol
Delegatecall: yes
Storage namespaces: CoreStorage, EpochQueueStorage, FeeStorage, FixedMaturityStorage
History: this module replaced
QueueModule.sol(a FIFO array with a keeper-scanned settle loop) as the sole production queue-settlement mechanism.QueueModule.solhas been deleted; seedocs/queue-mechanics.mdfor the full behavioral writeup and migration notes. The retiredQueueStorage.sollayout is kept only as a permanently-reserved EIP-7201 slot — no live code reads or writes it.
Manages the async exit queue using Renzo ezETH-style epoch batching: accepts claim requests
into a currently-open epoch, closes the epoch to lock a single price-per-share for every claim
in it, pulls liquidity once per epoch, and lets users self-serve their claim via a pull-based
call. Also owns performance-fee crystallization and NAV smoothing (decoupled from the epoch
lifecycle — see docs/queue-mechanics.md §7).
| Function | Access | Description |
|---|---|---|
requestEpochWithdrawal(uint256 shares) |
PUBLIC | Submit a standard (queued) withdrawal into the current open epoch |
cancelEpochWithdrawal(uint256 epochId, uint256 claimId) |
PUBLIC | Cancel a claim while its epoch is still Open |
closeCurrentEpoch() |
PUBLIC | Lock PPS for the current epoch, open the next one |
fundEpoch(uint256 epochId) |
PUBLIC | Pull liquidity (warm refill → strategy redeem) for a Closed epoch |
claimEpochAssets(uint256 epochId, uint256 claimId) |
PUBLIC | Self-serve claim from a Funded epoch |
batchClaimEpochAssets(uint256 epochId, uint256[] claimIds) |
PUBLIC | Batch self-serve claim for one user's multiple claims |
requestInstantWithdrawal(uint256 shares) |
PUBLIC | Cap-eligible instant exit; falls back to the epoch queue otherwise |
endEpochCrystallize() |
PUBLIC | Crystallize perf fee + update NAV smoothing (independent of epoch state) |
currentEpochId() / epochData(id) / epochClaim(id, claimId) |
PUBLIC view | Epoch and claim state |
nextClaimIdForEpoch(id) |
PUBLIC view | Next claim ID counter for a given epoch |
totalEscrowedShares() |
PUBLIC view | Total shares in escrow across all epochs |
outstandingClaimCount() |
PUBLIC view | Total unclaimed claims across all epochs (dynamic-cap signal) |
oldestUnfundedEpochId() |
PUBLIC view | Keeper cursor — oldest Closed-not-yet-Funded epoch |
epochDeficit(id) |
PUBLIC view | Remaining liquidity shortfall for a Closed epoch |
canCloseCurrentEpoch() / currentEpochClaimCount() |
PUBLIC view | Keeper eligibility + anti-churn checks |
Source: src/core/modules/EpochedQueueModule.sol:212-833.
requestInstantWithdrawal(shares):
1. _checkStandardExitAllowed(fm, immediate=true)
2. _trySoftRefreshWarmNav(); rollEpochIfNeeded() — the CAP epoch (ExitEngineLib), not the settlement epoch
3. gross = convertToAssets(shares)
4. if _canInstant(gross, wp, core):
INSTANT PATH:
- computeFeeShares(shares, INSTANT, fee)
- _transferShares(user → feeCollector, feeShares); _burn(user, netShares)
- safeTransfer(user, netAssets); consumeEpochCap(gross)
- emit InstantExit
- return (settledImmediately=true, epochId=0, claimId=0)
else:
FALLBACK — same as requestEpochWithdrawal(shares):
- _transferShares(user → vault, shares) [escrow ALL gross shares]
- claimId = ++nextClaimId[epochId]; record EpochClaim{user, netShares, feeShares, claimed=false}
- escrowedShares += shares; outstandingClaimCount += 1
- emit EpochWithdrawalRequested
- return (settledImmediately=false, epochId, claimId)
Callers must branch on settledImmediately — a cap-exhausted instant request never reverts,
it silently becomes a standard epoch claim (W2 rule).
Source: src/core/modules/EpochedQueueModule.sol:212-289, 698-749.
Settlement is a three-step, epoch-wide (not per-claim) process — the core structural difference from the old per-claim settle loop:
Step A — closeCurrentEpoch() (permissionless, gated on minEpochDuration):
- Snapshots
ppsAtClose = totalAssets/totalSupplyonce for the whole epoch. - Batch-transfers accumulated fee shares to
feeCollectorin one call. - Opens the next epoch immediately so new submissions are never blocked.
Step B — fundEpoch(epochId) (permissionless, repeatable):
- One liquidity pull covers the epoch's entire net liability, not a per-batch slice.
- Waterfall: warm refill first (
bm.refill), then strategy redeem (router.planRedeem/executeRedeemBatch) for any remaining gap — both try/catch, W2 rule. - Epoch transitions to
Fundedonly oncehot >= totalNetAssets; otherwise staysClosedfor a later retry.
Step C — claimEpochAssets(epochId, claimId) (pull-based, per claimant, any time after Funded):
assets = claim.netShares * epoch.ppsAtClose / WAD— deterministic, no live-PPS exposure.- No keeper required for a user to receive funds.
Source: src/core/modules/EpochedQueueModule.sol:327-513.
EpochedQueueModule's settlement epoch (currentEpochId, duration from
IParamsProvider.QueueParams.epochDuration) is a separate concept from ExitEngineLib's
withdrawal-cap epoch (CoreStorage.epochStart, rolled by rollEpochIfNeeded()). The settlement
epoch only advances when closeCurrentEpoch() is explicitly called; the cap epoch rolls
automatically on interaction. See docs/queue-mechanics.md §6 for the full distinction — they
are not architecturally coupled even though test fixtures often configure matching durations.
_crystallize() (src/core/modules/EpochedQueueModule.sol:576-653) — ported verbatim from
QueueModule.sol, unchanged logic:
- Compute PPS =
totalAssets / totalSupply. - If PPS <= HWM: update HWM, no fee.
- If PPS > HWM:
profit = totalAssets - HWM * totalSupply,feeAssets = profit * perfRateX. feeShares = convertToShares(feeAssets)— minted (dilutive, by design).- Update HWM = new PPS post-mint.
This logic has zero dependency on EpochQueueStorage — crystallization can be triggered
independent of any epoch's open/closed/funded state (see docs/queue-mechanics.md §7).
Performance fee minting is the ONLY exit-related path that mints new shares (fee accrual is dilutive). All other exits are non-dilutive.
| ID | Statement |
|---|---|
| M3-I1 | totalSupply NEVER increases on exit (only decreases via burn) |
| M3-I2 | feeShares transferred via processorTransfer (TRANSFER, not mint) |
| M3-I3 | epochWithdrawn ≤ cap (INSTANT only; STANDARD claims have no cap) |
| M3-I4 | Intra-batch PPS deterministic: all claims in same settleFeesAndProcessQueue use same cachedTA/cachedTS |
| M3-I5 | Queue escrow: vault holds pendingShares; settlement decrements pendingShares on each claim |
| Error | Condition |
|---|---|
ZeroAmount, ClaimTooSmall |
Validation on requestClaim |
TooManyClaimsThisEpoch |
Anti-spam per-epoch limit exceeded |
ClaimCooldownActive |
Anti-spam cooldown active |
NotClaimOwner |
cancelClaim: caller != claim.user |
AlreadySettled |
cancelClaim on already-settled claim |
ReentrancyGuardLocked |
Reentrant requestClaim |
File: src/core/modules/AdminModule.sol
Delegatecall: yes
Storage namespaces: CoreStorage, FeeStorage
All timelock-protected governance functions. Parameter changes go through submit → accept → revoke workflow with paramMinDelay enforced ETA.
All mutable params (fees, perf rate, paramMinDelay, components) follow:
submit*(params):
- validate params (caps from GlobalConfig)
- set pending* with eta = block.timestamp + paramMinDelay
- revert if already pending (must revoke first)
accept*():
- validate block.timestamp >= eta AND < eta + MAX_WINDOW (7 days)
- apply params
revoke*():
- clear pending* (onlyOwner or vetoer)
Correction:
setGuardian(address)previously appeared in this table but is not anAdminModulefunction — it's implemented directly onCoreVault(see architecture.md §2.3). It is nowonlyOwnerand blocked post-seal, matchingsetVetoer()below.
| Function | Role | Timelock | Description |
|---|---|---|---|
submitFeeParams(dep,wit,immExit,forceExit,treasury) |
OWNER | yes | Queue fee change |
acceptFeeParams() |
OWNER | ETA check | Apply queued fees |
revokeFeeParams() |
OWNER/VETOER | — | Cancel pending fees |
submitPerfParams(rateX,minInterval) |
OWNER | yes | Queue perf fee change |
acceptPerfParams() |
OWNER | ETA check | Apply perf params |
submitMinDelay(newDelay) |
OWNER | yes | Queue min delay change |
acceptMinDelay() |
OWNER | ETA check | Apply new min delay |
setParams(address) |
OWNER | conditionally | Set IParamsProvider (timelock if componentsTl) |
setBufferManager(address) |
OWNER | conditionally | Set BufferManager |
setRouter(address) |
OWNER | conditionally | Set StrategyRouter |
setFeeCollector(address) |
OWNER | — | Update fee recipient |
setVetoer(address) |
OWNER | — | Update vetoer (blocked post-seal via _requireNotSealed()) |
enableComponentsTimelock() |
OWNER | — | Enable timelock for setParams/setRouter/setBM |
submitBufferManager(address) |
OWNER | yes | Queue BM change (if componentsTl) |
acceptBufferManager() |
OWNER | ETA check | Apply queued BM |
seedDeadDeposit(uint256) |
OWNER | — | Anti-inflation dead deposit (one-shot) |
setInitialFees(...) |
OWNER | — | One-shot fee initialization |
setEcosystem(...) |
OWNER | — | Batch-set all component addresses |
freezeParams() |
OWNER | — | Permanently freeze paramMinDelay |
setRebalancePolicy(address) |
OWNER | — | V10: set allocation policy |
setRebalanceGuard(address) |
OWNER | — | V10: set allocation guard |
setExecutionMemory(address) |
OWNER | — | V10: set execution memory |
getPendingFeeParams() |
PUBLIC view | — | Read pending fee change |
getFeeParams() |
PUBLIC view | — | Read active fees |
getEcosystem() |
PUBLIC view | — | Read all component addresses |
getImmediateExitPenalty() |
PUBLIC view | — | Read immediateExitPenaltyBps |
isFeesInitialized() |
PUBLIC view | — | Check fees initialized flag |
Source: src/core/modules/AdminModule.sol:63-end, src/core/libraries/SelectorRegistry.sol:61-109.
- H4: Block overwrite of pending params:
submitFeeParamsreverts withPendingParamsNotResolvedifpendingFee.existsis already true. Owner must revoke before re-submitting. Source:src/core/modules/AdminModule.sol:82. - ETA window: accepted params expire after
MAX_WINDOW = 7 days. After expiry, must revoke and resubmit. Source:src/core/modules/AdminModule.sol:45. - Max caps: all fee bps and perf rate are validated against
GlobalConfigcaps (not hardcoded). Caps are governance-configurable but changes require a separate flow viaGlobalConfig. Source:src/core/modules/AdminModule.sol:83-87.
seedDeadDeposit(uint256 amount) makes a one-time deposit that mints "dead shares" to a zero address (or dead address). This ensures totalSupply > 0 from the first real deposit, preventing the ERC-4626 inflation attack. Flag FLAG_DEAD_DEPOSIT_DONE is set; subsequent calls revert DeadDepositAlreadySeeded.
File: src/core/modules/BufferManager.sol
Delegatecall: NO (standalone external contract, not via delegatecall)
Called by: CoreVault directly, VaultUpkeep (keeper)
Manages the hot/warm liquidity buffer around target percentages. The vault holds USDC (hot); warm adapters (Aave, Morpho, etc.) hold additional liquidity for short-term yield and rapid refill.
Deploy path: excess hot funds → warm adapters (keeper-triggered via rebalance()).
Refill path: warm adapters → vault hot buffer (triggered by settle scan or forceWithdraw).
NAV cache: cachedWarmNav is updated during rebalance(), valid for navRefreshInterval.
| Function | Caller | Description |
|---|---|---|
rebalance() |
keeper or core | Deploy/withdraw to hit targets + refresh warm NAV cache |
refreshWarmNav() |
anyone | Update cachedWarmNav without rebalancing |
refill(uint256 amount) |
onlyCore | Pull amount from warm adapters to vault |
forceRefill(uint256 amount) |
onlyCore | Force-pull from warm adapters (best-effort) |
warmNavState() |
public view | Returns (cachedWarmNav, lastWarmNavUpdate, warmNavValid) |
getConfig() |
public view | Returns BufferConfig |
setConfig(...) |
onlyOwner | Update buffer percentages |
addWarmAdapter(address) |
onlyOwner | Register warm adapter |
removeWarmAdapter(address) |
onlyOwner | Remove warm adapter |
setKeeper(address) |
onlyOwner | Update authorized keeper |
BufferManager must NEVER hold idle USDC. All assets flow directly between CoreVault (hot) and WarmAdapters. If BM held USDC, cachedWarmNav would undercount warm assets, causing share dilution on next deposit. Source: src/core/modules/BufferManager.sol:16-20.
warmNavValid = false if ANY adapter's totalAssets() call fails during rebalance() or refreshWarmNav(). When warmNavValid = false:
- Deposits are blocked (CoreVault
_depositsAreCurrentlyAllowed()returns false). - Exits proceed with stale NAV (W2 policy — never block exits).
canDeploy()(LiquidityOpsModule) returns false.
Recovery: keeper calls rebalance() → adapter failure → if adapter is removed or fixed, subsequent rebalance() call may restore warmNavValid = true.
Multiple warm adapters are supported (_warmAdapters[] array). On deploy, BM distributes funds across adapters using a primary/fallback strategy. If the primary adapter fails, the fallback is attempted. Source: src/core/modules/BufferManager.sol:40-43, events WarmDeployFallbackUsed, WarmDeployAllFailed.
File: src/core/modules/FixedMaturityModule.sol
Delegatecall: yes
Storage namespaces: FixedMaturityStorage, CoreStorage, FeeStorage
Manages all FixedMaturity vault lifecycle transitions. OpenEnded vaults have zero interaction with this module (gating helpers early-return). For lifecycle state diagram see architecture.md §9.
| Function | Description | State requirement |
|---|---|---|
setVaultModeFixedMaturity() |
Irreversibly switch to FM mode | OpenEnded + routing not frozen |
configureFixedMaturity(...) |
One-shot parameter setup | FixedMaturity + Funding + !configured |
startFixedMaturityCycle() |
Transition Funding → Starting | FixedMaturity + Funding |
activateFixedMaturityCycle() |
Transition Starting → Active | FixedMaturity + Starting |
closeFixedMaturityCycle() |
Transition Matured → Closed | Matured + no pending shares |
recallFixedTermCapital() |
Recall capital from FM strategy | Active or Matured |
| Function | Description | Time gate |
|---|---|---|
markMatured() |
Trigger Matured state + final perf fee | block.timestamp >= maturityTs |
markFundingFailed() |
Trigger FundingFailed state | block.timestamp >= fundingDeadlineTs AND net < min |
refundClaim() |
Claim refund after FundingFailed | FundingFailed state |
autoCloseFunding() |
Auto-close Funding on target reached | FixedMaturity + Funding + autoClose enabled |
isDepositOpen() |
View: deposits currently open | any |
isSettlementOpen() |
View: settlement currently open | any |
currentVaultModeAndState() |
View: current mode + state | any |
fundingProgressBps() |
View: % of target reached | any |
At markMatured(), the final performance fee is applied once:
- Snapshot
finalPerformanceFeeBaseAssets = totalAssets()(immutable after this point). - Compute fee on principal growth:
profit = totalAssets - fixedTermPrincipalBaseAssets. - Mint fee shares to
feeCollector. - Set
finalPerformanceFeeApplied = true.
Source: src/core/modules/FixedMaturityModule.sol. The finalPerformanceFeeBaseAssets snapshot is audit-grade — it captures NAV at the instant of maturity declaration before any fee calculation.
At markFundingFailed(), fundingFailedPPS = current PPS is recorded. Each user can call refundClaim() to receive assets proportional to their shares at that PPS. This ensures that if the vault never activated, depositors recover their capital at the price they deposited at.
File: src/core/modules/LiquidityOpsModule.sol
Delegatecall: yes
Storage namespaces: CoreStorage, FixedMaturityStorage
Handles all strategy capital flows: deploy surplus to strategies, realize assets for queue settlement or reserve maintenance, and rebalance strategy allocations.
| Function | Access | Description |
|---|---|---|
canDeploy() |
PUBLIC view | Returns true if surplus deployable |
deployToStrategies(uint256 amount) |
PUBLIC | Deploy amount to strategies (keeper-triggered) |
deployToStrategiesWithPlan(AllocationTypes.AllocPlan) |
ROLE_OWNER_OR_GUARDIAN | Deploy with explicit, caller-supplied allocation plan (V10) |
realizeForQueue(uint256 amount) |
PUBLIC | Realize assets from strategies for queue settle |
realizeForReserveAndOps(uint256 maxAmount) |
PUBLIC | Realize for hot buffer reserve maintenance |
canRebalanceStrategies() |
PUBLIC view | Returns true if rebalance warranted |
rebalanceStrategies(...) |
PUBLIC | Rebalance existing strategy allocations |
canDeploy() checks three conditions (src/core/modules/LiquidityOpsModule.sol:60-97):
bufferManagerandrouterare set.- Surplus after hot reserve + warm headroom >
minDeployAmount(from GlobalConfig). - At least one enabled strategy exists.
Deploy surplus = hot - opsReserveTargetBps% - warmHeadroom.
OpenEnded-only check: _checkOpenEndedDeployAllowed(fm) reverts if FixedMaturity vault tries to deploy (capital must flow to fixedTermStrategy via FixedMaturityModule, not general router). Source: src/core/modules/LiquidityOpsModule.sol:18-20.
deployToStrategiesWithPlan() accepts an externally supplied allocation plan (plan[i].strat, plan[i].amount) rather than deriving it on-chain, so it is restricted to ROLE_OWNER_OR_GUARDIAN — with a caller-supplied plan, a ROLE_PUBLIC caller could steer which registered strategies receive capital and in what proportion, even though each strat is validated to be enabled (UnregisteredStrategy revert otherwise). deployToStrategies() remains ROLE_PUBLIC/keeper-triggered because its allocation is computed internally, not caller-supplied.
If rebalancePolicy, rebalanceGuard, and executionMemory are set in CoreStorage, deployToStrategiesWithPlan() and rebalanceStrategies() use the portfolio-grade allocation engine:
RouterAllocationPolicycomputes an optimal allocation plan.RouterRebalanceGuardvalidates the plan (NAV delta, adapter caps, oracle freshness).ExecutionMemoryrecords outcomes (gas cost, slippage).
strictExecutionMemory = true makes execution conditional on ExecutionMemory recording succeeding.
File: src/core/modules/FeeCollector.sol
Delegatecall: NO (standalone external contract)
Called by: CoreVault (processor functions route fees to feeCollector), VaultUpkeep
Receives vault share fees and distributes them to configured sinks: treasury, ops multisig, and safety reserve vault. Handles three share modes:
| ShareMode | Behavior |
|---|---|
SPLIT_SHARES |
Split share tokens directly to sinks at their current value |
HOLD_TO_TREASURY |
Hold share tokens and send all to treasury on distribution |
AUTO_HARVEST |
Call requestClaim(true, shares) on the vault to convert to USDC before distributing |
In AUTO_HARVEST mode, FeeCollector calls IQueueModule.requestInstantWithdrawal(bal) on the vault to convert shares to USDC. If the instant exit falls back to the epoch queue (cap exhausted, or free liquidity below the ask), pendingHarvestShares[token] is incremented and the claim's (epochId, claimId) is appended to a per-token list. harvestQueued() walks that list and settles whichever claims sit in a funded epoch, leaving the rest queued, so one epoch that never funds delays only its own claim instead of blocking the token. An instant harvest whose payout rounds down to zero emits HarvestDustBurned and records nothing, since the inline-settlement path returns (0, 0) for the claim handle.
Source: src/core/modules/FeeCollector.sol:55-58.
distribute(address token) splits the token balance among three sinks:
- Treasury (
treasuryBpsbps): protocol treasury. - Ops (remainder up to
OPS_MAX_BPS): operations multisig. - Safety reserve (
safetyReserveBpsbps): safety reserve vault.
Fee provenance is tracked per-vault via vaultFeeAccumulated[vault]. Source: src/core/modules/FeeCollector.sol:34.
FeeCollector has an immutable governor (timelock/multisig executor) for parameter changes. An allowlist of accepted tokens can be toggled (allowlistEnabled). Distribution below minDistribution[token] is a no-op.
File: src/core/modules/Incentives.sol
Delegatecall: NO (external call with try/catch)
Legacy incentives hook. Called by ERC4626Module on deposit and exit with try/catch (W2: never blocks operations). Notifies the incentives contract of balance changes so it can update user reward accrual.
interface IIncentives {
function onDeposit(address user, uint256 netUsdc18, uint256 totalUsdc18) external;
function onExit(address user, uint256 netUsdc18) external;
}Called with values scaled to 18 decimals regardless of USDC's 6-decimal precision (net * 1e12). Source: src/core/modules/ERC4626Module.sol:741-756.
IIncentives (v1) is superseded by IIncentivesEngine (v2). Both may coexist during migration. The v1 interface is retained for backward compatibility and is called only if core.incentives != address(0).
File: src/core/modules/IncentivesEngine.sol
Delegatecall: NO (external call with try/catch)
Tranche-based incentives engine. Tracks user participation across configurable tranches with entry/exit events. Supports more complex reward accrual rules than v1.
interface IIncentivesEngine {
function onDeposit(address user, uint256 netUsdc18) external;
function onExit(address user, uint256 assetsExited18) external;
function onExitLight(address user, uint256 assetsExited18) external; // gas-efficient
}onExitLight() is called in the queue settle path (gas-constrained). Source: src/core/modules/EpochedQueueModule.sol:892-901 (_notifyIncentivesExit).
All IncentivesEngine calls are wrapped in try/catch. A failure does NOT block the corresponding deposit or exit operation. This maintains the W2 policy (never block exits) and also ensures deposit failures (rare misconfiguration) don't lock users out.
File: src/core/modules/BatchGuardrails.sol
Delegatecall: NO (standalone validator)
Pre-execution validator for batch strategy operations. Used by RouterRebalanceGuard (V10 allocation engine) to validate allocation plans before execution. All addresses are immutable — contract is vault-specific and stateless beyond configuration.
| Guardrail | Check |
|---|---|
| Max actions per batch | count <= Config.maxActionsPerBatch() |
| Cooldown | elapsed >= Config.rebalanceCooldown() |
| NAV delta | ` |
| Adapter allowlist | Config.isAdapterAllowed(adapter) for each leg |
| Adapter caps | allocation <= Config.adapterCap(adapter) |
| Oracle freshness | PriceOracleMiddleware.isPriceFresh(asset) |
Source: src/core/modules/BatchGuardrails.sol:20-56.
BatchGuardrails is constructed with immutable config, oracle, and vault addresses. It is NOT a module in the module-routing sense — it is called externally by RouterRebalanceGuard or directly by keepers for pre-flight validation.
File: src/core/modules/PriceOracleMiddleware.sol
Delegatecall: NO (standalone external contract)
Abstraction layer over price oracles (Chainlink, Pyth, custom). Provides a unified interface for price queries with staleness protection. Used by BatchGuardrails for oracle freshness checks.
- Oracle staleness: heartbeat-based validity (configurable per-asset, default 86400s = 24h for Chainlink).
- Fallback: configurable primary/secondary oracle per asset.
- Admin: set by owner; oracle addresses are updatable.
interface IPriceOracleMiddleware {
function isPriceFresh(address asset) external view returns (bool);
function getPrice(address asset) external view returns (uint256 price, bool fresh);
}File: src/core/modules/ExecutionMemory.sol
Delegatecall: NO (standalone external contract)
Records per-strategy execution outcomes for the V10 portfolio-grade allocation engine. Maintains exponential moving averages (EMA) of gas cost and slippage per strategy. Used by RouterAllocationPolicy to compute optimal allocation plans.
// ExecutionMemory.sol:17-26
struct ExecRec {
uint64 emaGasCost; // EMA of gas cost (USD, 6 decimals)
uint32 emaSlippageBps; // EMA of slippage in bps
uint32 failedCount; // Total failed executions
uint32 successCount; // Total successful executions
int32 emaRealizedVsExpectedBps; // EMA of realized vs expected return deviation
uint64 lastUpdateTs; // Timestamp of last update
uint16 observationCount; // Total observations (bootstrap threshold)
}
mapping(address => ExecRec) _records;Before minObservationsForLiveCost (default: 10) and minObservationsForPenalty (default: 20) observations are collected:
gasCostreturnsfallbackGasCostUsd = 50 USDCslippageBpsreturnsfallbackSlippageBps = 5penaltyBpsreturnsfallbackPenaltyBps = 50
This prevents the allocation engine from making extreme decisions based on too few data points.
If a strategy has not been executed for inactivityDecayThresholdSeconds (default: 30 days), its historical statistics are blended toward fallback values using inactivityDecayBetaBps (default: 50%). This prevents stale historical data from artificially favoring inactive strategies.
Source: src/core/modules/ExecutionMemory.sol:50-51.
File: src/core/modules/StrategyRouter.sol
Lines: 1164
Deployment: Standalone (NOT delegatecall). Called by LiquidityOpsModule via standard external call.
Access: onlyCore for batch operations, onlyOwner for emergency / governance operations.
StrategyRouter is the single entry point for all capital movements between CoreVault and registered yield strategies. It enforces guardrail checks (cooldown, NAV delta, oracle freshness, adapter allowlist), applies per-strategy and aggregate loss caps, and dispatches across three intake modes: PRIORITY, WEIGHTED, and SCORED.
| Variable | Type | Default | Description |
|---|---|---|---|
core |
address |
constructor | CoreVault address — onlyCore guard |
owner |
address |
constructor | Timelock / Safe |
intakeMode |
IntakeMode |
PRIORITY |
Allocation mode for deposits and redemptions |
lossCapBps |
uint16 |
50 (0.5%) |
Aggregate loss cap across all strategies in one batch |
lossCapPerStrategy |
mapping(address→uint16) |
0 | Per-strategy loss cap; 0 = no cap |
maxStrategyBps |
mapping(address→uint16) |
0 | Max allocation fraction per strategy; 0 = no cap |
gasPerStrategyWithdraw |
uint256 |
100_000 |
Gas reserved per strategy in planRedeem gas-adaptive sizing |
MAX_DEPOSIT_LEGS |
uint256 |
12 |
Hard limit on deposit plan length |
secondaryOracle |
address |
0 | Optional secondary oracle for deviation cross-check |
healthRegistry |
IStrategyHealthRegistry |
0 | Optional health registry; absence = all strategies treated as healthy |
scorer |
IStrategyScorerV10 |
0 | Optional scorer used in SCORED intake mode |
Source: src/core/modules/StrategyRouter.sol:38-76.
| Mode | Deposit behaviour | Redeem behaviour |
|---|---|---|
PRIORITY |
Deposits to strategies in registered priority order | Drains highest-priority enabled strategies first |
WEIGHTED |
Plan built off-chain; router executes best-effort | Proportional withdrawal weighted by each strategy's totalAssets() share |
SCORED |
Off-chain scoring via StrategyScorer; router executes pre-built plan |
Falls back to PRIORITY ordering |
All paths through executeDepositBatch and executeRedeemBatch pass through stacked modifiers evaluated before any state change:
| Modifier | Purpose |
|---|---|
checkCooldown |
Minimum time between consecutive batches (params.batchCooldownSeconds()) |
checkBatchSize(n) |
Rejects batches exceeding MAX_DEPOSIT_LEGS |
checkAdapterAllowlist(addrs[]) |
Rejects any strategy address not registered via register() |
checkNavDelta |
Reverts post-execution if ` |
checkOracleFreshness |
Triple-check: (1) isFresh flag on params, (2) timestamp not in the future, (3) age ≤ maxStaleSeconds |
Source: src/core/modules/StrategyRouter.sol:235-380.
| Function | Access | Description |
|---|---|---|
register(address) |
onlyOwner |
Register strategy; validates strategy.asset() == core.asset() |
toggle(address,bool) |
onlyOwner |
Enable / disable a registered strategy |
setIntakeMode(IntakeMode) |
onlyOwner |
Switch PRIORITY / WEIGHTED / SCORED |
setLossCap(uint16) |
onlyOwner |
Set aggregate loss cap in bps |
setLossCapPerStrategy(address,uint16) |
onlyOwner |
Set per-strategy loss cap |
setMaxStrategyBps(address,uint16) |
onlyOwner |
Set per-strategy max allocation fraction |
executeDepositBatch(Allocation[]) |
onlyCore |
Best-effort deposit batch with all guardrails |
planRedeem(uint256) |
view |
Compute optimal Pull[] plan for a target withdrawal amount |
executeRedeemBatch(Pull[]) |
onlyCore |
Best-effort redeem batch with loss cap and NAV delta checks |
harvest(uint256) |
onlyCore |
Batch harvest with per-strategy try/catch; emits HarvestBatchSummary |
emergencyRedeemBatch(Pull[]) |
onlyOwner |
Emergency exit: bypasses lossCap, navDelta, cooldown, oracle checks |
forceRedeemForWithdraw(uint256) |
onlyCore |
Greedy extraction sorted by available liquidity; no loss cap (W2 policy) |
withdrawAllToCore(address) |
onlyOwner |
Calls IStrategy.withdrawAll(core) on a single strategy |
totalStrategyAssetsSafe() |
view |
Gas-capped staticcall across all enabled strategies; never reverts |
Source: src/core/modules/StrategyRouter.sol:390-1164.
withdrawAllToCore(address) and emergencyRedeemBatch(Pull[]) are the concrete implementation of the architecture review's recommended incident-response sequence for a compromised strategy — "Guardian quarantine + existing timelocked recovery" — without any new privileged asset-moving contract:
- Guardian calls
StrategyHealthRegistry.setStrategyState(strategy, BROKEN, reason)(§16.4) — immediate, stops new deposits into the strategy via_isHealthy/planDepositfiltering. - Operations stop routing new allocations through the affected strategy.
ROOT_TIMELOCK(asowner) scheduleswithdrawAllToCore(strategy)(single strategy) oremergencyRedeemBatch(plan)(multiple strategies / partial amounts) through the normal governance delay.- Recovered assets land in
core(the vault); accounting is reconciled before the strategy is re-enabled or removed.
Both recovery functions are onlyOwner-gated only — neither checks StrategyState at all. This is deliberate, not an oversight: review §33 requires that BROKEN mean "no new exposure," not "no possible capital recovery" — recovery must never be blocked by the same state flag that stops new deposits. The consequence is that the four-step sequence above is a procedural incident-response runbook enforced by governance discipline and the existing timelock delay, not an on-chain precondition chaining StrategyHealthRegistry state to StrategyRouter's recovery functions. emergencyRedeemBatch additionally bypasses lossCap/navDelta/cooldown/oracle-freshness checks for exactly this reason — see the code comment at src/core/modules/StrategyRouter.sol:1128-1131 referencing the v6 recovery incident this path was built for.
The architecture review rejected a generic EmergencyExecutor (§26) specifically because these two functions already cover the recommended first-release incident-response model; a narrower exit-only mechanism (review §28) remains a possible future addition if incident drills show the existing timelock delay is too slow in practice, not a current gap.
planSum ≤ availableSurplusbefore any deposit transfer —planSum > availablereverts withInvalidPlanSum(src/core/modules/StrategyRouter.sol:679-680).- Per-strategy allocation cap uses the
navBeforesnapshot (not live NAV inside the loop), preventing double-counting whenfundsAlreadyTransferred=true(src/core/modules/StrategyRouter.sol:712). emergencyRedeemBatchandforceRedeemForWithdrawintentionally bypass loss cap — W2 policy: forced exit must never be blocked by loss accounting._isHealthyis FAIL-CLOSED: ifhealthRegistry.isHealthyForDeposit()reverts, the strategy is excluded from the batch (src/core/modules/StrategyRouter.sol:992-996).withdrawAllToCore/emergencyRedeemBatchare intentionally NOT gated byStrategyState— see §14.6.
File: src/core/modules/StrategyScorer.sol
Lines: 826
Deployment: Standalone view contract. Implements IStrategyScorerV10.
Version: V10 (EMA, confidence, capital buckets, execution quality multiplier).
StrategyScorer scores yield strategies across five dimensions and produces proportional capital allocations. It is called by RouterAllocationPolicy.buildRebalancePlan() and by StrategyRouter in SCORED intake mode. All keeper pokes are the only state mutations; all scoring logic is view-only.
rawScore(s) = wAPY × apyNorm(s)
+ wLiq × effectiveLiq(s)
+ wRisk × effectiveRisk(s)
+ wStability × effectiveStability(s)
+ wIncentive × decayedIncentive(s)
finalScore(s) = rawScore(s) × _executionQualityMultiplierBps(s) / 1e4
Default weights (must sum to exactly 10 000):
| Weight | Default | Dimension |
|---|---|---|
wAPY |
4 000 (40%) | Annualized yield |
wLiq |
1 500 (15%) | Liquidity depth |
wRisk |
2 500 (25%) | Risk (inverted: low risk → high contribution) |
wStability |
1 000 (10%) | Historical NAV stability |
wIncentive |
1 000 (10%) | Decaying incentive bonus |
Source: src/core/modules/StrategyScorer.sol:33-45.
| Feature | Description |
|---|---|
| Time-aware EMA | Bucketed alpha by Δt since last poke: <6 h → 10%, <1 d → 25%, <3 d → 40%, ≥3 d → 60%; long-gap (> maxEmaGap) reseeds from spot (src/core/modules/StrategyScorer.sol:661-701) |
| APY volatility tracking | EMA of ` |
| Confidence | Per-strategy confidenceBps; source-validity flag; stale decay: age > 1× → halved, age > 2× → clamped to staleConfidenceFloorBps; missing source → defaultConfidenceBps (src/core/modules/StrategyScorer.sol:724-735) |
| Capital buckets | 0 = CORE, 1 = TACTICAL; TACTICAL bucket zeroes allocation when confidence < minConfidenceForAllocationBps (src/core/modules/StrategyScorer.sol:601-622) |
| Risk-adjusted APY | riskAdjustedAPY = emaApy − volPenaltyBps − illiqPenaltyBps − opRiskBps (src/core/modules/StrategyScorer.sol:713-722) |
When a signal is stale (age > staleness_seconds), its score is halved and clamped to a floor:
| Signal | Floor constant | Value |
|---|---|---|
| Risk | STALE_RISK_FLOOR_BPS |
4 000 |
| Stability | STALE_STABILITY_FLOOR_BPS |
3 000 |
| Liquidity | STALE_LIQ_FLOOR_BPS |
2 000 |
Source: src/core/modules/StrategyScorer.sol:20-24.
| Function | Access | Description |
|---|---|---|
computeScores(strategies[],tvl) |
view |
2-pass: collect maxAPY for normalization, then score + normalize to sum 10 000 |
computeAllocations(strategies[],tvl) |
view |
Calls computeScores then proportional allocation with absolute and relative caps |
shouldRebalance(strategies[],currentAllocs[],tvl) |
view |
Returns true if total drift ≥ rebalanceMinMoveBps |
isEligible(strategy) |
view |
FAIL-CLOSED: false if healthRegistry.isHealthyForDeposit reverts |
effectiveConfidence(strategy) |
view |
Source-valid, staleness-adjusted confidence bps |
riskAdjustedAPY(strategy) |
view |
EMA APY minus penalty signals |
emaState(strategy) |
view |
Returns (lastUpdateTs, emaApyBps, apyVolatilityBps) |
pokeStrategyMetrics(strategy,apy,liq,stab,conf) |
onlyKeeper |
Single-call batch poke for all signals + EMA update |
Source: src/core/modules/StrategyScorer.sol:86-266.
setScoringWeightsreverts withWeightsSumInvalidif weights do not sum to exactly 10 000 (src/core/modules/StrategyScorer.sol:387-394).MAX_STRATEGIES = 10boundscomputeScoresiteration — safe for on-chain execution.- Score normalization falls back to quality-weighted equal share when all raw scores are zero (
src/core/modules/StrategyScorer.sol:502-525). - TACTICAL strategies below confidence threshold receive
allocationMultiplierBps = 0, fully zeroing their allocation (src/core/modules/StrategyScorer.sol:605-607).
File: src/core/modules/StrategyHealthRegistry.sol
Lines: 185
Deployment: Standalone. Implements IStrategyHealthRegistry.
StrategyHealthRegistry is the guardian-controlled state machine tracking health status for each registered strategy. It is the FAIL-CLOSED gate consulted by StrategyRouter before deposits and by StrategyScorer.isEligible().
| State | Value | Deposit eligible |
|---|---|---|
OK |
0 | Yes |
DEGRADED |
1 | No |
BROKEN |
2 | No |
isHealthyForDeposit(address) returns true only when state is OK.
| Role | Authority | Permitted transitions |
|---|---|---|
owner |
Constructor; Timelock/Safe | OK, DEGRADED, BROKEN |
guardian |
Constructor; hot EOA | DEGRADED, BROKEN only (GuardianCannotMarkOK) |
authorizedCallers |
Added by owner | NAV updates only (updateLastKnownNAV) |
The guardian restriction enforces that recovery to OK always requires owner (Timelock) action — a guardian can quarantine but cannot unquarantine (review §3.3 "fast to restrict, slow to restore" / §33 BROKEN semantics). Source: src/core/modules/StrategyHealthRegistry.sol:36 (GuardianCannotMarkOK error declaration), src/core/modules/StrategyHealthRegistry.sol:113-115 (revert in setStrategyState), src/core/modules/StrategyHealthRegistry.sol:138-140 (revert per-iteration in batchSetStrategyState).
| Function | Access | Description |
|---|---|---|
setStrategyState(address,StrategyState,string) |
onlyOwnerOrGuardian |
Set health state with reason string |
batchSetStrategyState(address[],StrategyState[],string) |
onlyOwnerOrGuardian |
Batch version — one reason string applied to every strategy in the batch, not a per-index array |
updateLastKnownNAV(address,uint256) |
onlyAuthorizedCaller |
Cache last known NAV (called post-deposit/redeem by StrategyRouter) |
isHealthyForDeposit(address) |
view |
Returns state == OK |
getStrategyState(address) |
view |
Returns raw StrategyState enum value |
getStrategyHealth(address) |
view |
Returns full StrategyHealth struct (state + lastKnownNAV + reason) |
Source: src/core/modules/StrategyHealthRegistry.sol:95-185.
- Guardian cannot set
OK—GuardianCannotMarkOKis a hard revert (src/core/modules/StrategyHealthRegistry.sol:36,113-115,138-140). - Absence of registry (
address(0)) inStrategyRouterdefaults to all strategies healthy — permissive path for bootstrap phase. BROKEN/DEGRADEDmarks inflow prohibition only, never outflow/recovery prohibition (review §33): marking a strategyBROKENhere has no on-chain effect on whetherStrategyRouter.withdrawAllToCore/emergencyRedeemBatchcan act on it — see §14.6 below. The state machine in this contract and the recovery functions inStrategyRouterare deliberately independent; the link between them is procedural (an incident-response runbook), not enforced by a code-level precondition.
File: src/core/modules/RouterAllocationPolicy.sol
Lines: 448
Deployment: Standalone view contract. Called by LiquidityOpsModule.
RouterAllocationPolicy builds deterministic RebalancePlan structs from current allocations and scorer targets. It applies regime-aware core/tactical bucket constraints and classifies plans as normal or safety using AllocationInvariantLib.isSafetyCondition.
buildRebalancePlan(strategies[], currentAllocs[], tvl) executes 8 steps:
- Sort strategies ascending by address (insertion sort, deterministic, N ≤ 10).
- Compute target allocations via
scorer.computeAllocations(). - Apply regime-aware bucket constraints (CORE min/max, TACTICAL remainder).
- Compute per-strategy
withdrawAmountsanddepositAmountsas signed deltas. - Compute
driftBps = totalMoveUsd / tvl × 10 000. - Compute
weightedCurrentAPYBpsandweightedTargetAPYBps(risk-adjusted EMA APY, allocation-weighted). - Compute
aggregateConfidence(allocation-weighted average of per-strategy confidence). - Classify safety via
AllocationInvariantLib.isSafetyCondition.
Source: src/core/modules/RouterAllocationPolicy.sol:44-163.
| Regime | Core min | Core max |
|---|---|---|
| 0 — STABLE | 80% | 90% |
| 1 — VOLATILE | 75% | 85% |
| 2 — STRESS | 90% | 95% |
currentRegime() reads from RouterRebalanceGuard.currentRegime() via low-level staticcall to avoid circular import. Source: src/core/modules/RouterAllocationPolicy.sol:187-194.
classifySafety() calls AllocationInvariantLib.isSafetyCondition with default thresholds:
| Parameter | Default |
|---|---|
healthThresholdBps |
7 000 |
liquidityReadinessThresholdBps |
3 000 |
maxStrategyExposureBps |
4 000 |
queuePressureThresholdBps |
3 000 |
A plan flagged as safety bypasses most gate checks in RouterRebalanceGuard (see §18). Source: src/core/modules/RouterAllocationPolicy.sol:350-398.
- Sort is deterministic ascending address — strategies in any input order produce an identical plan.
- Bucket redistribution preserves total TVL allocation (proportional scale-up/scale-down).
classifySafetyview usesqueuePressureBps = 0(no queue state accessible in view context); the guard enforces queue safety separately.
File: src/core/modules/RouterRebalanceGuard.sol
Lines: 591
Deployment: Standalone. Single source of decision for whether a rebalance proceeds.
RouterRebalanceGuard is the portfolio-grade gate that decides whether a RebalancePlan may proceed. It applies hysteresis, minimum move thresholds, benefit/cost analysis, budget limits, and safety exceptions. The result is a PlanEvaluation struct containing the (possibly scaled) plan and a GuardReason code.
| Regime | Hysteresis mult | Budget mult | Horizon days | Confidence mult |
|---|---|---|---|---|
| 0 — STABLE | 100% | 100% | 30 | 100% |
| 1 — VOLATILE | 150% | 70% | 14 | 80% |
| 2 — STRESS | 200% | 30% | 7 | 50% |
Keeper or owner can switch regime with setRegime(); owner can force with forceRegime(). Source: src/core/modules/RouterRebalanceGuard.sol:126-155.
| Step | Check | Safety plan override |
|---|---|---|
| 1 | Plan validity (non-empty strategies, tvl > 0) | None |
| 2 | STRESS regime block (non-safety plans) | Safety plans pass |
| 3 | Queue safety (pressure threshold + idle after plan) | Safety plans skip |
| 4 | Hysteresis (entry/exit drift thresholds; consecutive-skip relaxation) | Safety plans skip |
| 5 | Minimum move (max(minMoveUsd, tvl × minMoveBps)) |
Safety plans skip |
| 6 | Benefit / cost (net benefit ≥ minNetBenefitBps; ratio ≥ minBenefitCostRatioBps) |
Safety plans skip |
| 7 | Budget → compute allowedMoveBps (regime-scaled; safety uses safetyMaxMoveBpsPerDay) |
Higher budget cap |
| 8 | Scale plan (AllocationInvariantLib.scalePlan) when driftBps > allowedMoveBps |
None |
| 9 | Post-scale re-evaluation of minimum move and net benefit | Safety plans skip |
Source: src/core/modules/RouterRebalanceGuard.sol:162-296.
| Parameter | Default | Description |
|---|---|---|
maxMoveBpsPerCycle |
1 000 (10%) | Cap per single rebalance call |
maxMoveBpsPerDay |
2 000 (20%) | Daily cumulative cap (normal plans) |
safetyMaxMoveBpsPerDay |
5 000 (50%) | Daily cumulative cap (safety plans) |
budgetMode |
HARD_RESET |
Hard reset vs rolling decay at interval |
budgetResetIntervalSeconds |
86 400 | Reset / decay period |
consumeBudget(movedBps) and notifySkip() are called by the orchestrator (LiquidityOpsModule) after execution. Source: src/core/modules/RouterRebalanceGuard.sol:437-467.
After maxConsecutiveSkips (default: 5) consecutive skips, hysteresis thresholds are multiplied by skipRelaxMultBps (default: 70%), lowering the barrier to the next rebalance. Counter resets to zero on successful consumeBudget. Source: src/core/modules/RouterRebalanceGuard.sol:199-213.
- In STRESS regime, all non-safety plans return
STRESS_BLOCK— no capital movement unless flagged safety (src/core/modules/RouterRebalanceGuard.sol:177-179). - Benefit formula uses
AllocationInvariantLib.computeNetBenefitBps— single formula eliminates dual-path inconsistency (Fix #2). - Plan scaling uses
AllocationInvariantLib.scalePlanwith formal derivation (Fix #3). consumeBudgetandnotifySkipareonlyOrchestrator— keeper cannot manipulate budget state directly.
graph LR
CV["CoreVault"]
EM["ERC4626Module"]
QM["EpochedQueueModule"]
AM["AdminModule"]
LOM["LiquidityOpsModule"]
FM["FixedMaturityModule"]
BM["BufferManager"]
FC["FeeCollector"]
SR["StrategyRouter"]
INC["Incentives (v1)"]
IE["IncentivesEngine (v2)"]
EE["ExecutionMemory"]
RAP["RouterAllocationPolicy"]
RRG["RouterRebalanceGuard"]
BG["BatchGuardrails"]
POM["PriceOracleMiddleware"]
CV -->|delegatecall| EM
CV -->|delegatecall| QM
CV -->|delegatecall| AM
CV -->|delegatecall| LOM
CV -->|delegatecall| FM
EM -->|processorMint/Burn/Transfer| CV
QM -->|processorTransfer/Burn| CV
AM -->|reads/writes CoreStorage, FeeStorage| CV
EM -->|try/catch| INC
EM -->|try/catch| IE
QM -->|try/catch| IE
EM -->|safeTransfer| BM
QM -->|refill| BM
LOM -->|executeDepositBatch| SR
LOM -->|query scorer| SR
LOM -->|allocPlan| RAP
LOM -->|validatePlan| RRG
LOM -->|recordExecution| EE
RRG -->|validateBatch| BG
BG -->|isPriceFresh| POM
CV -->|safeTransfer shares to| FC
FC -->|requestClaim(true)| CV
SR -->|totalStrategyAssetsSafe| CV
| Module | Key Invariants |
|---|---|
| ERC4626Module | withdraw/redeem always revert; no mint on exit; deposit blocked if warmNavInvalid |
| EpochedQueueModule | totalSupply decreases only on exit; PPS deterministic per-epoch (locked at close); escrow balance == totalEscrowedShares |
| AdminModule | Pending params must be resolved before new submission; ETA window 7 days; fee caps enforced |
| BufferManager | Never holds idle USDC; cachedWarmNav reflects 100% of warm assets |
| FixedMaturityModule | finalPerformanceFeeApplied exactly once; fundingFailedPPS immutable after markFundingFailed |
| LiquidityOpsModule | OpenEnded-only deploy path; surplus calculation = hot - reserve - warm headroom |
| FeeCollector | AUTO_HARVEST tracks pendingHarvestShares on queue fallback; governor is immutable |
| ExecutionMemory | Bootstrap fallbacks below observation threshold; inactivity decay after 30 days |
When deploying a new vault or replacing a module, the following registration sequence must be followed:
// Step 1 — register each module selector
vault.setModule(selector, moduleAddress, requiredRole);
// Step 2 — authorize module for processorMint/Burn/Transfer (if needed)
vault.setAuthorizedModule(moduleAddress, true);
// Step 3 — (optional) set SelectorRegistry before first routing freeze
vault.setSelectorRegistry(registryAddress);
// Step 4 — freeze routing (irreversible; requires FLAG_ROUTING_FROZEN=false)
vault.freezeRouting();For the bootstrap deployment, paramMinDelay = 0 allows instant parameter acceptance. Governance should raise paramMinDelay to ≥2 days via submitParamMinDelay + acceptParamMinDelay before any TVL is committed. Source: src/core/modules/AdminModule.sol:280-310.
Module addresses are mutable until routing is frozen. The recommended upgrade workflow:
- Deploy new module contract.
- Call
vault.setModulesBatch(selectors[], newAddresses[], roles[])— atomically updates all selectors belonging to the module. - Call
vault.setAuthorizedModule(oldModule, false)andvault.setAuthorizedModule(newModule, true)if the module usesprocessorMint/Burn/Transfer. - Smoke-test all upgraded selectors via a read-only call.
- If a
paramMinDelay > 0governs module changes, step 2 must go through the timelock submit/accept cycle in AdminModule.
Source: src/core/CoreVault.sol:263-309.
BatchGuardrails and PriceOracleMiddleware are NOT registered as CoreVault modules (no delegatecall). They are independent contracts called directly by RouterRebalanceGuard and LiquidityOpsModule:
RouterRebalanceGuard.validateBatch()callsBatchGuardrails.checkBatch()— validates allocation plan before execution.BatchGuardrails.checkBatch()callsPriceOracleMiddleware.isPriceFresh(adapter)— ensures each allocation target has a fresh oracle price.
These contracts have their own access control (owner, keeper roles) separate from CoreVault's SelectorRegistry.
Approximate gas costs for key module operations (Arbitrum, FOUNDRY_PROFILE=default, warm storage):
| Operation | Approx Gas | Notes |
|---|---|---|
deposit(1000e6) |
~140,000 | Includes delegatecall, warmNav check, mint, transfer |
requestClaim(true, shares) |
~110,000 | INSTANT path with epoch cap check |
settleFeesAndProcessQueue(n=1) |
~90,000 | Single claim settlement |
deployIdle() |
~180,000 | Includes StrategyRouter allocation scan |
endEpochCrystallize() |
~70,000 | No perf fee due; ~+40,000 if fee due |
markMatured() (FM) |
~150,000 | Includes final perf fee computation |
Source: estimated from forge test --gas-report output on branch pierdev. Actual on-chain gas may differ by ±20% depending on oracle update and adapter state.
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— section coverage checkdocs/01-architecture/TECH-DESIGN-COMPLETO.md— module terminologydocs/01-architecture/FEECOLLECTOR-SAFETYRESERVE.md— FeeCollector modes terminology
Discrepancies found (code vs. old source .md):