NAV Lifecycle & Action Choreography

Which on-chain actions move value between NAV buckets, when the off-chain FundNavFeed report has to land, and how the per-asset navDenom / effNavDenom snapshots and PPS are refreshed by updateNav(). For the PPS formula, deviation guard, and staleness rules, see 04 · NAV Management.

① NAV Buckets — the four places value can live
ON-CHAIN IDLE BALANCE_OF[asset] WAD{denom} counter mirroring FundVault's idle ERC20 holdings UintValueLib._balanceOfKey OFF-CHAIN STRATEGY FundNavFeed[asset] Σ per-category nav reported by operator feeds/FundNavFeed.sol CLAIMABLE claimableDenom[vault] fulfilled redeems, assets held in AssetVault awaiting claim UintValueLib._claimableDenomKey PENDING (OBLIGATION) pendingDenom[vault] redeem requested, not yet fulfilled by operator UintValueLib._pendingDenomKey PER-ASSET GROSS NAV (WAD denom) navDenom[asset] = FundNav + BALANCE_OF + claimable PER-ASSET EFFECTIVE NAV (WAD denom) effNavDenom[asset] = navDenompendingclaimable  (floor 0) subtract DERIVED — computed in NavAggregateModel PPS = Σ effNavDenom × 1e18 / effectiveSupply snapshot rewritten by VaultManager.updateNav()
PER-ASSET GROSS NAV
navDenom[asset] = FundNavFeed.fundNavValue(asset) + BALANCE_OF[asset] + claimableDenom[vaultForAsset]
PER-ASSET EFFECTIVE NAV
effNavDenom[asset] = max(0, navDenom[asset]pendingDenom[vaultForAsset]claimableDenom[vaultForAsset])
SNAPSHOT POLICY
• In-flight: each state-changing call adjusts the relevant bucket via adjust* helpers so reads stay coherent between snapshots.
updateNav() overwrites navDenom and effNavDenom from scratch by calling NavAggregateModel.computeNav.
FundNavFeed is read only inside computeNav — off-chain P&L enters NAV only at the next snapshot.
The two-step principle: every action either (a) shifts value between buckets on-chain, or (b) reports the off-chain leg via FundNavFeed. A strategy move (allocate / deallocate / yield) is not visible to NAV until both legs are reflected and updateNav() re-aggregates.
② Action × Bucket Mutation Matrix

Per-call effect on each NAV bucket. increases the slot, decreases, moves between matching slots, untouched. In-flight mutations apply to navDenom / effNavDenom via adjustNavDenom / adjustEffNavDenom in the same call; updateNav() later overwrites those slots from scratch.

Action (caller) BALANCE_OF navDenom effNavDenom pending claimable redeemShares
AssetVault.deposit / mint
user, oracle conversion
AssetVault.requestRedeem
user, oracle at request time
AsyncRequestManager.fulfillRedeem
operator
AssetVault.redeem / withdraw (claim)
user, assets leave AssetVault
AssetVault.cancelRedeem
operator, return to FundVault
FundVault.allocate
curator, ratio conversion (pre-pull)
FundVault.deallocate
curator, oracle conversion
VaultManager.adjustBalanceOf
FundVault or AssetVault internal
± ± ±
VaultManager.syncBalanceOf
OPERATOR_ROLE override
= ± ±
FundNavFeed.syncNavValue
OPERATOR_ROLE off-chain report
VaultManager.updateNav
OPERATOR_ROLE, snapshot refresh
= = =
Reading the matrix: ± = signed (caller picks sign), = = overwritten with a computed value, = the slot ends roughly flat because the same call both adds and subtracts. The FundNav slot in FundNavFeed is conceptually a 7th column — only syncNavValue mutates it, and updateNav() reads it on the next snapshot.
③ Timeline — allocate → FundNav report → updateNav()
STAGE A — STEADY STATE STAGE B — ALLOCATE STAGE C — REPORT STAGE D — UPDATE NAV BALANCE_OF 1,000,000 FundNav 0 navDenom (snapshot) 1,000,000 PPS (live) 1.000 BALANCE_OF 500,000 ↓ FundNav (stale) navDenom (in-flight) 500,000 ↓ PPS (if updateNav now) 0.500 ↓ BALANCE_OF 500,000 FundNav 500,000 ↑ navDenom (snapshot) 500,000 (stale) PPS (if updateNav now) 1.000 ↑ BALANCE_OF 500,000 FundNav 500,000 navDenom (snapshot) 1,000,000 ↑ PPS (snapshot) 1.000 ✓ before allocate FundVault.allocate(strategy, 500_000) adjustBalanceOf(asset, −500_000e18) FundNavFeed.syncNavValue (USDC, "hyperliquid", 500_000e18) VaultManager.updateNav() overwrites navDenom + pricePerShare SNAPSHOT vs IN-FLIGHT Stage B mutates BALANCE_OF and navDenom in-flight (gross NAV temporarily dips). Stage C lifts FundNav off-chain. Stage D's updateNav() re-aggregates the snapshot and PPS is restored.
Why allocate dips NAV temporarily: FundVault.allocate moves capital from the FundVault's idle balance (counted by BALANCE_OF) into a strategy. The strategy's value lives off-chain until the operator reports it via FundNavFeed.syncNavValue. Between Stage B and Stage C, an on-chain updateNav() would read a truncated picture and PPS would collapse. Operational discipline: report the strategy NAV in the same block as allocate, or atomically inside the same operator transaction.
④ Mirror — deallocate → FundNav reduce → updateNav()
PRE-DEALLOCATE DEALLOCATE REDUCE FUNDNAV UPDATE NAV BALANCE_OF: 500,000 FundNav: 500,000 navDenom: 1,000,000 PPS: 1.000 BALANCE_OF: 800,000 ↑ FundNav: 500,000 (stale) navDenom: 1,300,000 (gross up) PPS (live): 1.300 (overstated) BALANCE_OF: 800,000 FundNav: 200,000 ↓ navDenom (stale): 1,300,000 PPS (live): 1.000 ✓ BALANCE_OF: 800,000 FundNav: 200,000 navDenom (snapshot): 1,000,000 ✓ PPS (snapshot): 1.000 deallocate(strategy, 300_000) oracle conversion bumps BALANCE_OF syncNavValue(USDC, "hyperliquid", 200_000e18) updateNav() → snapshot crystallises
Deallocate is the mirror image: on-chain idle increases while the off-chain bucket still reflects the strategy's previous size. Without the corresponding syncNavValue reduce, gross NAV is overstated and an early updateNav() would publish an inflated PPS — which the deviation guard (deviationPps) may or may not catch depending on the magnitude. Always reduce FundNav by the deallocated amount before the next snapshot.
⑤ Numeric Walkthrough — PPS dip and recovery

Single-asset USDC vault. Genesis state: 1,000,000 USDC idle in FundVault, zero off-chain position, 1,000,000 shares outstanding, PPS = 1.000. Amounts shown in millions of USDC denom (1e18 scale). Live PPS = PPS that would result if updateNav() were called at that exact moment; snapshot PPS = the value currently stored in pricePerShare.

Step BALANCE_OF FundNav navDenom effNavDenom PPS (live) PPS (snapshot)
0. Genesis 1.000M 0 1.000M 1.000M 1.000 1.000
1. allocate(strategy, 0.5M) 0.500M 0 0.500M 0.500M 0.500 1.000
2. syncNavValue(USDC, "hyperliquid", 0.5M) 0.500M 0.500M 1.000M 1.000M 1.000 1.000
3. updateNav() 0.500M 0.500M 1.000M 1.000M 1.000 1.000
4. strategy earns 10k (off-chain) 0.500M 0.500M (stale) 1.000M (stale) 1.000M (stale) 1.000 1.000
5. syncNavValue(USDC, "hyperliquid", 0.510M) 0.500M 0.510M 1.010M 1.010M 1.010 1.000
6. updateNav() 0.500M 0.510M 1.010M 1.010M 1.010 1.010
CHECK — row 3, single-asset USDC
navDenom = 0.5e24 + 0.5e24 + 0 = 1.0e24
effNavDenom = 1.0e24 − 0 − 0 = 1.0e24
PPS = 1.0e24 × 1e18 / 1e24 = 1.0e18   ✓
CHECK — row 1 (allocate, pre-report)
navDenom = 0 + 0.5e24 + 0 = 0.5e24
PPS = 0.5e24 × 1e18 / 1e24 = 0.5e18   ← dip
Reading the live vs snapshot columns: rows 1, 2, 4, 5 are all between snapshots — the on-chain pricePerShare doesn't change until step 3 (and again at step 6). The "live" PPS column shows what a hypothetical immediate updateNav() would produce. Conversion functions (convertToShares, convertToAssets) use the snapshot; only NAV reads via computeNav see the live picture. Discipline: never call updateNav() at step 1 — do step 2 first.
⑥ In-flight Mutations vs Snapshot Refresh
IN-FLIGHT (every state-changing call)
adjustBalanceOf(asset, delta) — bumps BALANCE_OF, navDenom, effNavDenom by delta.
adjustPendingDenom(vault, delta) — bumps pendingDenom on requestRedeem / fulfillRedeem / cancelRedeem.
adjustClaimableDenom(vault, delta) — bumps claimableDenom on fulfillRedeem / claim.
adjustGlobalRedeemShares(delta) — bumps the global redeem-shares counter.
• Saturating subtract on all denom-scaled counters: oracle drift between paired in/out events clamps to 0 instead of reverting.
SNAPSHOT (only inside updateNav())
• Calls NavAggregateModel.computeNav(registeredAssets) — reads FundNavFeed, BALANCE_OF, vault state, ShareToken.totalSupply().
• Overwrites navDenom[asset] and effNavDenom[asset] per asset from result.assetTotalNavs[i] / assetEffNavDenoms[i].
• Overwrites globalRedeemShares, pricePerShare, lastNavUpdated.
• Does not mutate BALANCE_OF, pendingDenom, or claimableDenom — those flow only through in-flight deltas.
• Reverts with InvalidPricePerShare() if NavAggregateModel._isValidPps rejects the new PPS (deviation guard).
Why both: in-flight adjusters keep the per-asset slots coherent so that reads between snapshots (e.g. convertAssetToDenomByOracle inside deposits, or NAV reader queries) don't lag user activity. updateNav() is the moment the off-chain leg (FundNavFeed) joins the on-chain picture and PPS is republished. Anything that only moves value off-chain (strategy yield, allocate) is invisible to PPS until syncNavValue + updateNav().
⑦ Cross-reference — where the rest of the NAV story lives
Topic Where
PPS formula & _computePps edge cases (genesis, all-redeem freeze) 04 · NAV Management — ① NAV Formula
PPS deviation guard (deviationPps) and staleness (maxNavStaleness) 04 · NAV Management — ③ Deviation Guard
Fee accrual effect on PPS (management + performance, high-water mark) 08 · Fee Management
Allocate / deallocate flow at the action level (cap enforcement, ratio vs oracle) 02 · Allocate & Deallocate
Async redeem lifecycle (requestRedeem → fulfillRedeem → redeem/withdraw) 03 · Withdraw Flow
Recommended operator sequencing (harvest → syncNavValue → updateNav) 06 · Common Operations