NAV Management & updateNav

How Net Asset Value and Price-Per-Share are calculated, updated, and guarded

① NAV Formula — How total and effective NAV are computed
OFF-CHAIN ORACLE FundNavFeed strategy positions (HyperLiquid, etc.) ON-CHAIN BALANCE balanceOf(FundVault) idle assets sitting in FundVault VAULT STATE claimableAssets fulfilled but unclaimed by users PER-ASSET TOTAL NAV (IN ASSET UNITS) totalNAV[i] = offChainNav[i] + balanceOf(FV) + claimable[i] ORACLE PRICE priceFeed.getPrice(asset) GROSS NAV (DENOMINATION) navDenomination = Σ (totalNAV[i] × price[i]) summed across all registered assets EFFECTIVE NAV (DENOMINATION) effNavDenom = Σ max(0, totalNAV[i]owed[i]) × price DEDUCTIONS PER ASSET owed[i] = pending + claimable subtract PENDING ASSETS requested, not yet fulfilled CLAIMABLE ASSETS fulfilled, not yet claimed PPS = effNavDenom / effectiveSupply EFFECTIVE SUPPLY totalSupply − globalRedeemShares TOTAL SUPPLY ShareToken.totalSupply() GLOBAL REDEEM SHARES Σ redeemShares across vaults components
PER-ASSET TOTAL NAV
totalNAV[i] = offChainNav[i] + IERC20(asset).balanceOf(fundVault) + state.claimableAssets
GROSS NAV (COMMON DENOMINATION)
navDenomination = Σ totalNAV[i] × price[i]
EFFECTIVE NAV (WHAT ACTIVE HOLDERS OWN)
effNavDenom = Σ max(0, totalNAV[i]pendingAssets[i]claimableAssets[i]) × price[i]
EFFECTIVE SUPPLY
effectiveSupply = totalSupply − globalRedeemShares
PRICE PER SHARE
PPS = effNavDenom / effectiveSupply   (floor rounding)
• if effectiveSupply == 0 & totalSupply == 0 → PPS = 1.0 (genesis)
• if effectiveSupply == 0 & totalSupply > 0 → PPS = currentPps (all shares pending)
Why two NAV values? navDenomination is the gross total — everything the protocol controls. effNavDenom subtracts what is owed to redeemers (pending + claimable), giving the value attributable to active holders only. PPS uses effNavDenom / effectiveSupply so that redemptions don't dilute remaining holders.
② updateNav() — The operator refreshes on-chain PPS
ROLE: OPERATOR Operator FUND NAV FEED syncNavValue(asset, desc, nav) 0 update off-chain strategy NAV before calling updateNav() ROLE: OPERATOR updateNav() VAULT MANAGER VaultManager.updateNav() computes NAV, validates PPS, persists state 1 VALIDATION GATE if (!isValidPps) revert InvalidPricePerShare() PERSISTED STATE UPDATES assetNav[i] per-asset NAV = totalNAV[i] navDenomination gross total = Σ converted effNavDenomination net of deductions = Σ net converted globalRedeemShares locked shares total = Σ redeemShares pricePerShare the new PPS = computed pps pass + lastNavUpdated = block.timestamp  •  emit PricePerShareUpdated(oldPps, newPps)
Step Action Effect
0 Operator calls FundNavFeed.syncNavValue(asset, description, nav) Updates off-chain strategy NAV (e.g., HyperLiquid position value)
Repeat for each asset / category that has changed OPERATOR_ROLE — must reflect current strategy values
1 Operator calls VaultManager.updateNav() Restricted to OPERATOR_ROLE
2 NavAggregateModel.computeNav(registeredAssets) Reads FundNavFeed + balanceOf(FV) + vaultState for each asset
3 Converts each asset NAV to denomination via priceFeed.getPrice() All assets expressed in a common unit (1e18 scale)
4 Validates PPS: _isValidPps(newPps, currentPps) Reverts if newPps == 0 or deviation exceeds deviationPps
5 Persists: assetNav, navDenomination, effNavDenomination, globalRedeemShares Full snapshot saved to storage
6 Updates pricePerShare and lastNavUpdated Emit PricePerShareUpdated(oldPps, newPps)
Step 0 — What value to sync: The nav parameter in syncNavValue(asset, description, nav) should reflect the current total value of the asset held by the strategy. For example, if the strategy deployed 800 USDC into HyperLiquid and it is now worth 850 USDC, sync nav = 850. This is the off-chain position value that the operator (or an automated keeper) reads from the strategy's external platform and pushes on-chain so that updateNav() sees the correct picture.
③ PPS Deviation Guard & Staleness Check
DEVIATION CHECK (inside computeNav, enforced by updateNav)
absDiff = |newPpscurrentPps|
allowedDiff = currentPps × deviationPps / 1e18
isValid = (newPps != 0) && (absDiffallowedDiff)
• if deviationPps == 0 → check disabled, any non-zero PPS is valid
• example: deviationPps = 0.01e18 (1%) → PPS can move at most 1% per updateNav call
STALENESS CHECK (enforced by AssetVault on deposit & requestRedeem)
isStale = block.timestamp − lastNavUpdated > maxNavStaleness
• if maxNavStaleness == 0 → check disabled
• reverts with NavStale(lastUpdated) if stale
• checked at deposit() and requestRedeem() — NOT inside updateNav itself

Deviation Guard Examples

deviationPps = 0.02e18 (2%), currentPps = 1.00e18

Scenario newPps absDiff allowedDiff Result
Normal yield (+1%) 1.01e180.01e180.02e18 Pass ✓
Exactly at limit (+2%) 1.02e180.02e180.02e18 Pass ✓
Too large jump (+3%) 1.03e180.03e180.02e18 Revert ✗
Suspicious drop (−5%) 0.95e180.05e180.02e18 Revert ✗
Zero PPS (broken oracle) 0 Revert ✗
Purpose: The deviation guard prevents a single bad oracle update or stale FundNavFeed from causing a catastrophic PPS swing. If the strategy had a genuine large gain or loss beyond the limit, the admin must either adjust deviationPps temporarily or perform multiple smaller updateNav calls that each move within the limit.
④ Fee Harvesting — How fees interact with PPS
MANAGEMENT FEE harvestManagementFee() CURATOR_ROLE • time-based accrual PERFORMANCE FEE harvestPerformanceFee() CURATOR_ROLE • high-watermark EFFECT ON PPS Mint shares → PPS drops slightly NAV unchanged, supply ↑ → PPS ↓ MANAGEMENT FEE FORMULA feeAmt = effNav × period × feeRate / (365d × 1e18) shares = feeAmt × effSupply / (effNav − feeAmt) PERFORMANCE FEE FORMULA profit = (pps − watermark) × effSupply / 1e18 shares = profit × feeRate × effSupply / (effNav − fee)
Fee mechanics: Both fees are collected by minting new shares to the fee receiver — no USDC is moved. This dilutes existing holders' PPS proportionally to the fee. Management fees accrue continuously based on time elapsed and effective NAV. Performance fees only accrue when PPS exceeds the highWatermark (previous peak), ensuring the protocol only charges on net-new gains.
⑤ Numeric Example — NAV through operations

Single-asset vault (USDC, 6 decimals, price = 1e18). Starting: 1,000 USDC idle in FundVault, 0 in strategy, totalSupply = 1,000 shares.

Event offChainNav balanceOf(FV) claimable totalNAV pending effNAV effSupply PPS
Initial (post-deposit) 01,00001,000 01,0001,000 1.00
allocate(800) to strategy
+ syncNavValue(800)
80020001,000 01,0001,000 1.00
Yield accrues (+200)
syncNavValue(1000)
1,00020001,200 01,2001,000 1.20
requestRedeem(100 shares)
assets = 100 × 1.20 = 120
1,00020001,200 1201,080900 1.20
fulfillRedeem(120)
200 idle ≥ 120 needed
1,000801201,200 01,080900 1.20
withdraw(120 USDC)
burns 100 shares
1,0008001,080 01,080900 1.20
Key observation: PPS only changes when actual value enters or leaves the system (yield, loss, fees). Deposits, redeems, allocations, and fulfillments are all PPS-neutral by design — the numerator and denominator always move in proportion.
⑥ Operational Patterns — updateNav best practices
1. Recommended call order for a full NAV cycle
Step 1 harvestManagementFee() Mint fee shares based on old effNAV → PPS updated for new supply
Step 2 harvestPerformanceFee() Mint performance fee shares if PPS > watermark
Step 3 FundNavFeed.syncNavValue(asset, desc, nav) Update off-chain positions (e.g., strategy NAV from HyperLiquid)
Step 4 VaultManager.updateNav() Re-compute PPS with fresh NAV data + new supply from fee minting
This order ensures fees are charged on the previous period's NAV, then NAV is refreshed with current data.
2. Always sync FundNavFeed after allocate / deallocate
When the curator moves USDC between FundVault and a strategy:
allocate(strategy, 800)balanceOf(FV) drops by 800, but offChainNav is stale
• Must call syncNavValue(asset, desc, newStrategyValue) before updateNav()
• If not synced: totalNAV will be understatedPPS drops falsely
• Deallocate has the reverse problem: idle rises but feed hasn't decreased → PPS overstated
3. Handling large PPS changes that exceed deviation limit
If a strategy had a genuine large gain or loss that causes PPS to move more than deviationPps:
updateNav() will revert with InvalidPricePerShare
Option A: Admin temporarily increases deviationPps via VaultManagerAdmin.setDeviationPps() (time-locked)
Option B: Perform incremental syncNavValue() calls, each followed by updateNav(), stepping PPS gradually within the limit
• Option B is preferred as it doesn't weaken the safety guard
4. NAV staleness blocks user operations
If block.timestamp − lastNavUpdated > maxNavStaleness, then deposit() and requestRedeem() will revert with NavStale.
• The operator should call updateNav() regularly (e.g., daily) to keep NAV fresh
fulfillRedeem() and withdraw() / redeem() are not staleness-gated — users can always claim fulfilled requests
5. Multi-asset vault — PPS is denomination-based
When the protocol supports multiple assets (e.g., USDC + WETH), each asset's NAV is converted to a common denomination using priceFeed.getPrice(asset). PPS is expressed in denomination units (1e18 scale), not in any single asset's units. This means:
• PPS can change if asset prices change, even without yield or loss
convertToAssets() uses the current asset price at call time, not the price at updateNav time
• Keep price feeds fresh — stale Chainlink prices will miscalculate share conversions
6. FundNavFeed supports multiple categories per asset
Each asset can have multiple NAV categories (e.g., "HyperLiquid", "T-Bills", "Aave").
syncNavValue(asset, description, nav) updates a single category
fundNavValue(asset) sums all isActive categories
• Categories can be deactivated via setCategoryStatus() (admin only) — inactive categories read as 0
• Updated by OPERATOR_ROLE — typically an automated keeper or off-chain bot