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
PER-ASSET TOTAL NAV
totalNAV[i] = offChainNav[i] + IERC20(asset).balanceOf(fundVault) + state.claimableAssetsGROSS 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 − globalRedeemSharesPRICE 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
| 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 = |newPps − currentPps|allowedDiff = currentPps × deviationPps / 1e18
isValid = (newPps != 0) && (absDiff ≤ allowedDiff)
• 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.01e18 | 0.01e18 | 0.02e18 | Pass ✓ |
| Exactly at limit (+2%) | 1.02e18 | 0.02e18 | 0.02e18 | Pass ✓ |
| Too large jump (+3%) | 1.03e18 | 0.03e18 | 0.02e18 | Revert ✗ |
| Suspicious drop (−5%) | 0.95e18 | 0.05e18 | 0.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
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) | 0 | 1,000 | 0 | 1,000 | 0 | 1,000 | 1,000 | 1.00 |
|
allocate(800) to strategy + syncNavValue(800) |
800 | 200 | 0 | 1,000 | 0 | 1,000 | 1,000 | 1.00 |
|
Yield accrues (+200) syncNavValue(1000) |
1,000 | 200 | 0 | 1,200 | 0 | 1,200 | 1,000 | 1.20 |
|
requestRedeem(100 shares) assets = 100 × 1.20 = 120 |
1,000 | 200 | 0 | 1,200 | 120 | 1,080 | 900 | 1.20 |
|
fulfillRedeem(120) 200 idle ≥ 120 needed |
1,000 | 80 | 120 | 1,200 | 0 | 1,080 | 900 | 1.20 |
|
withdraw(120 USDC) burns 100 shares |
1,000 | 80 | 0 | 1,080 | 0 | 1,080 | 900 | 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
This order ensures fees are charged on the previous period's NAV, then NAV is refreshed with current data.
| 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 |
2. Always sync FundNavFeed after allocate / deallocate
When the curator moves USDC between FundVault and a strategy:
•
• Must call
• If not synced: totalNAV will be understated → PPS drops falsely
• Deallocate has the reverse problem: idle rises but feed hasn't decreased → PPS overstated
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 understated → PPS 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
•
• Option A: Admin temporarily increases
• Option B: Perform incremental
• Option B is preferred as it doesn't weaken the safety guard
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
• The operator should call
•
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
• PPS can change if asset prices change, even without yield or loss
•
• Keep price feeds fresh — stale Chainlink prices will miscalculate share conversions
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").
•
•
• Categories can be deactivated via
• Updated by
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