NAV Calculator
The core accounting contract — registers assets, feeds, and adapters, and returns NAV and positions.
The NAV Calculator (NAVCalculator) is the core accounting contract. It registers the assets a fund can hold, the price feeds that value them, and the balance adapters that locate them, then returns the fund's NAV and full position breakdown for any account on that chain.
Source: NAVCalculator.sol
Contract
NAVCalculator
Type
UUPS upgradeable proxy — one instance per chain
Roles
DEFAULT_ADMIN_ROLE · MANAGER (governance Safe)
Chains
Ethereum · Optimism · Arbitrum · Base · Gnosis (+ 19 canonical-only)
Addresses
It is fund-agnostic — a single instance per chain prices any account. It is deployed as a UUPS proxy at a canonical CREATE2 address, identical across all chains, and is upgradeable by the governance Safe.
Deployed addresses are listed on the Deployment addresses page. Always call the proxy; the implementation may change on upgrade.
How it works
getAccountNav(account, quoteAsset)
├─ for each registered balance adapter:
│ └─ adapter.getAdapterPositions(account, …) → PositionBalance[]
├─ for each position:
│ ├─ (price, decimals, stale) = primary feed for balanceAsset
│ ├─ irregular = primary vs monitor feeds beyond tolerance (signal only)
│ └─ value = ±(amount × price) / 10^(assetDecimals) // − when isDebt
└─ return NAV {
value, // total in quote asset units (USD = 8 decimals)
quoteAsset,
timestamp,
stalePriceAssets, // assets whose primary feed was stale
irregularPriceAssets,// assets whose primary diverged from monitors / peg
sequencerDown, // L2 sequencer status
quoteAssetStale, // quote-asset feed stale → value falls back to USD
monitorsUnhealthyPriceAssets // monitors configured, none readable → no divergence check ran
}Solidity interface
Structs
The interface defines the position model, the NAV snapshot, and the price-feed snapshot. Expand each group for the full definitions.
NAV snapshot — NAV
New fields are appended, never inserted mid-struct, so an in-place UUPS upgrade cannot shift an existing field's ABI offset. monitorsUnhealthyPriceAssets is the most recent append.
Partial NAV snapshot — PartialNAV
Returned by getAccountNavForAdapters, for paging a NAV read that no longer fits in a single eth_call. It mirrors NAV's first eight fields in order, then appends the adapters the slice actually covered:
The positional mirror stops at those eight fields. monitorsUnhealthyPriceAssets sits after adaptersCovered here, while in NAV it is the last field — so the two structs no longer line up past the prefix. That is deliberate: keeping adaptersCovered at the offset a live off-chain reassembler already decodes matters more than the documentation convenience of a positional mirror. Decode each struct against its own ABI; never assume the mirror extends to new fields.
It is a distinct type from NAV, deliberately: a partial result must not be assignable or decodable where a complete NAV is expected. Both reads run the same underlying computeNav, with the validated slice in place of the full adapter set, so a complete read and the sum of its slices agree because they are literally the same code path.
Only value composes by addition. The health signals do not. stalePriceAssets, irregularPriceAssets and monitorsUnhealthyPriceAssets cover only the assets that slice touched, and the three booleans are reported per slice. A caller that reassembles the total but checks the flags on one slice — the last one, or whichever it kept — silently drops every staleness, depeg and sequencer-down signal the other slices carried, and ends up with a NAV that looks healthy. Union the asset arrays and OR the booleans across every slice.
Pass a caller-supplied adapter set, never an index range. The adapter list is maintained with swap-and-pop, so removing an adapter moves the last one down into the freed slot. A caller paging [0,10) then [10,20) across a governance transaction therefore silently skips the moved adapter — both pages look well-formed and the total is short by its positions. Because elements only ever move down, the hazard is a missed adapter, never a double-counted one.
A slice is rejected outright rather than returning a quietly wrong value: EmptyAdapterSlice(), AdapterNotRegistered(address) for anything absent from getAllAdapters(), and DuplicateAdapterInSlice(address) for a repeat.
A meta-adapter is the atomic unit. Naming one in a slice includes all of its governed instances; there is no paging within a single adapter, since the instance set is itself swap-and-pop mutable and an instance range would carry the identical hazard. If one meta-adapter alone exceeds the budget, pagination cannot help.
Price-feed snapshot — PriceFeedData
For a Custom feed — a composite of a rate and a base/USD leg — (updatedAt, chainlinkHeartbeat) report the governing leg: the underlying Chainlink leg with the greatest age / heartbeat ratio, i.e. the one closest to its own staleness deadline. The pair is therefore a real, matched pair from one actual oracle rather than a mix of two, and it reduces to the single leg's values for a single-leg feed. Which leg governs can change between reads as the ratios cycle, so a two-leg feed's reported heartbeat may alternate between its legs' values. See Price feeds.
(updatedAt, chainlinkHeartbeat) are advisory; stale is the authoritative verdict. The pair describes only the governing oracle leg's push freshness. stale additionally folds in the rate answer's validity, the L2 sequencer state, and the feed's own configured heartbeat gate — so the pair can read fresh (age < heartbeat) while stale is true. Always gate on stale / sequencerDown; never recompute staleness from the pair alone.
The stable reconciliation key is keccak256(abi.encode(chainId, protocolSubId, positionId, balanceAsset.asset, positionKind)) (see Position identity). It keys on protocolSubId — the product-level slug — not protocolId; protocolBrand and protocolId are display/grouping layers only. isLocked marks a leg that is not withdrawable right now (the pending/cooling portion of a withdrawal queue or cooldown) but is excluded from the key — it is a mutable per-leg attribute (a leg flips true→false as it finalizes), so a withdrawal queue's claimable and locked legs share one key and sum. positionInstanceId (the ephemeral per-item handle — an NFT id, a withdrawal-request id, an exit ticket) is likewise excluded, so the legs of one anchor sum and identity survives item churn. Labels are not on the position — they are computed lazily by the verbose read methods (getAccountPositionsVerbose / getAccountNavVerbose), which call each adapter's IPositionDescribable (protocolName() + positionLabels(positionId)) and render the full breadcrumb [protocolName, ...labels]. These live on the NAV Calculator itself (there is no separate lens contract).
Read functions
getAccountNav
Total chain-local NAV for an account in a quote currency (address(0) = USD)
getAccountNavForAdapters
NAV over a caller-supplied subset of adapters, for paging a read too large for one call (returns PartialNAV)
getAccountPositions
One priced Position per balance entry reported for an account
getAccountPositionsForAsset
Priced positions for a single asset across all adapters that report it
getAccountNavVerbose
getAccountNav plus per-position protocolName + labels[] (returns VerboseNAV)
getAccountPositionsVerbose
getAccountPositions enriched with protocolName + labels[] (returns VerbosePosition[])
getAccountPositionsForAssetVerbose
Single-asset verbose positions (returns VerbosePosition[])
healthCheck
Sequencer status + currently-stale and currently-irregular assets, without a full NAV
getPriceData
Detailed data for an asset's primary feed + divergence signal (price, stale, irregular …)
getPriceDataNoDivergence
Same as getPriceData but skips the monitor read (used on the base/USD hot path)
getPriceDivergence
Live primary-vs-monitor divergence read (median, worst-case bps, irregular, direction)
hasPositions
Whether an account has any non-zero position
getAssetsWithPositions
Assets for which an account holds a non-zero position
calculateValue
Value of a set of (asset, amount) pairs in a quote currency
getAssetInfo
One asset's classification and display labels, in a single call
getRegisteredAsset
Resolves one asset to its (address, symbol, decimals) in O(1), plus a found flag
getRegisteredAssets · isAssetRegistered · getAssetCount
Asset-registry introspection
usdDecimals · version
USD value decimals (e.g. 8); implementation version
To resolve one asset, use getRegisteredAsset, not getRegisteredAssets. The plural getter copies the entire registry across the ABI boundary, so resolving M tokens by calling it M times pays M full fetches — and the cost is super-linear, not linear, because each call allocates a fresh N-element array. At scale that is enough to exhaust an adapter's gas stipend and turn a NAV read into a revert, not merely a slow one. getRegisteredAsset is O(1).
It is also non-reverting by design: an unregistered asset returns found == false rather than reverting, because "not registered" is an ordinary outcome on an adapter's emission path — the adapter simply skips that leg. A reverting getter would turn every unregistered token an adapter happens to encounter into a failed instance read, converting a skipped leg into a dropped position.
Examples
Illustrative calls and returns. Values are example data (not live); addresses are abbreviated. The account is a portfolio Safe, USDC = 0xA0b8…eB48 (6 dp), WETH = 0xC02a…6Cc2 (18 dp), and the USD quote asset is address(0).
Stale prices and sequencer status
The NAV struct carries hard reliability signals — stalePriceAssets, sequencerDown, and quoteAssetStale — that must block a pricing read, plus two soft signals: irregularPriceAssets (and quoteAssetIrregular), which flags a price the primary and monitor feeds disagree on, and monitorsUnhealthyPriceAssets, which flags assets where no monitor answered at all, so the disagreement check never ran. See Stale prices & sequencer for the full model, and use healthCheck() to poll chain health without computing a full NAV — noting that healthCheck does not carry the third array.
Admin / Manager API
Configuration is privileged and held by the governance Safe. Most mutations require the MANAGER role; the sequencer-feed setters and proxy upgrades require DEFAULT_ADMIN_ROLE. Both roles are held by the governance Safe. All batch calls are all-or-nothing — if any item fails its check, the whole transaction reverts.
Assets & price feeds (MANAGER)
An asset enters the system with its primary feed via registerAsset, carries one pricing feed plus optional monitor feeds and tolerances, and leaves via unregisterAsset. See Price feeds for the primary-feed + divergence-monitor model.
registerAsset(asset, priceFeed, priceType, heartbeat)
Registers a new asset and its primary price feed in one call; reads symbol/decimals from the ERC-20 (or the native-token constant) and adds the asset to the default (wallet) adapter's inclusion list. Reverts InvalidArguments for an address with no code (the native-token sentinel aside), so a typo'd or not-yet-deployed token cannot be registered.
addPriceFeed(asset, priceFeed, priceType, heartbeat)
Adds another pricing feed to an already-registered asset (transitional; production keeps one). Reverts PriceFeedAlreadyRegistered (same feed twice) or PriceFeedDecimalsMismatch (a new feed's decimals must equal the existing feeds'); heartbeat must be non-zero.
removePriceFeed(asset, priceFeed)
Removes one pricing feed by address. Reverts PriceFeedNotFound, or CannotRemoveLastFeed if it is the only pricing feed left.
removePriceFeedAt(asset, index)
Same, by array index. Reverts PriceFeedIndexOutOfBounds or CannotRemoveLastFeed.
setDivergenceTolerance(asset, bps)
Sets the primary-vs-monitor divergence tolerance. Must be in (0, 10000]; 0 disables (InvalidTolerance). Required before any monitor feed can be added.
setPegTolerance(asset, bps)
Sets the $1-peg tolerance (stablecoins). Must be in [0, 10000]; 0 disables the peg check (InvalidTolerance). Also the switch that decides the asset's peg classification — crossing zero flips it.
setAssetKindRegistry(registry)
Points this NAV at the chain's AssetKindRegistry. Rejects address(0) (InvalidArguments). Re-settable: the registry is a plain contract, so replacing it is a redeploy plus this call.
addMonitorFeed(asset, priceFeed, priceType, heartbeat)
Adds a divergence-only monitor feed. Reverts DivergenceToleranceRequired (no tolerance set), MonitorFeedDecimalsMismatch (monitor must report 8 decimals), or MonitorFeedAlreadyRegistered.
removeMonitorFeed(asset, priceFeed) · removeMonitorFeedAt(asset, index)
Removes a monitor feed by address or index (MonitorFeedNotFound). Monitors are optional — the last one may be removed.
unregisterAsset(asset)
Fully removes the asset: clears all its pricing and monitor feeds, removes it from the default-adapter list, and drops it from the asset registry. Use only after the asset is no longer held anywhere.
Balance adapters (MANAGER)
Plain (single-scope) adapters only — meta-adapters use the next group. See Balance adapters.
addBalanceAdapters(address[] adapters)
Batch-registers plain adapters into the global registry. Reverts InvalidArguments (empty list / zero address), DuplicateBalanceAdapter, or MetaAdapterNotAllowed if any adapter implements IMetaBalanceAdapter (those must use addMetaBalanceAdapter).
removeBalanceAdapters(address[] adapters)
Batch-removes adapters from the registry. Reverts if any is not registered or is the built-in ERC20Default adapter (which cannot be removed).
Meta-adapters & their instances (MANAGER)
A meta-adapter is registered with its initial instance set; the set is then grown or shrunk incrementally. See Meta balance adapters.
addMetaBalanceAdapter(adapter, bytes32[] instances)
Registers a meta-adapter and seeds its instance set atomically. The adapter must implement IMetaBalanceAdapter (else NotMetaAdapter / InvalidBalanceAdapter). Reverts DuplicateBalanceAdapter or DuplicateMetaInstance. An empty instance list is allowed (seed later).
addMetaInstances(adapter, bytes32[] instances)
Adds instance coordinates to a registered meta-adapter. Reverts DuplicateMetaInstance (already present or repeated in the batch — a duplicate would double-count NAV), InvalidArguments (empty), or NotMetaAdapter.
removeMetaInstances(adapter, bytes32[] instances)
Removes coordinates. Reverts MetaInstanceNotFound, InvalidArguments (empty), or NotMetaAdapter. Order is not preserved.
Adding a market/vault/pool to coverage is thus an addMetaInstances transaction — never a new contract deploy.
Default (wallet) adapter inclusion (MANAGER)
excludeAssetFromDefaultAdapter(asset)
Stops the built-in ERC20Default adapter from reporting wallet balances for an asset, without unregistering the asset (its protocol positions still count).
includeAssetInDefaultAdapter(asset)
Re-enables wallet-balance reporting for the asset.
L2 sequencer & upgrades (DEFAULT_ADMIN_ROLE)
setChainlinkL2SequencerUptimeFeed(feed)
Sets the Chainlink L2 sequencer uptime feed (address(0) on L1). Admin-gated because disabling sequencer detection on an L2 has upgrade-level impact.
setChainlinkL2SequencerGracePeriod(seconds)
Sets the post-recovery grace period. Reverts UptimeFeedNotSet if no uptime feed is configured.
UUPS upgrade
upgradeToAndCall(...) (UUPSUpgradeable) is authorized by DEFAULT_ADMIN_ROLE; the implementation can change, the proxy address never does.
Plain adapters are registered with addBalanceAdapters; meta-adapters (one adapter covering many protocol instances) are registered with addMetaBalanceAdapter and have their queried instance set adjusted with addMetaInstances / removeMetaInstances. See Balance adapters.
Multichain aggregation
The offchain NAV Worker collects per-chain NAV values and sums them:
Call
getAccountNavon every chain the fund operates on.Verify
stalePriceAssetsis empty,sequencerDownisfalse, andquoteAssetStaleisfalseon every chain.If any chain is unhealthy, the global NAV is withheld until the condition clears.
Otherwise, sum all per-chain
valuefields to produce the global fund NAV.Treat a non-empty
irregularPriceAssetsas an alert, not a hard block: the value is still usable, but the divergence should be investigated before it widens into a stale/blocking condition.Treat a non-empty
monitorsUnhealthyPriceAssetsas an alert on the check itself rather than on the price: those assets lost their cross-check this read, so an emptyirregularPriceAssetssays nothing about them. Reconcile the two lists together — an asset in the third array is unverified, not verified-clean.
See Code examples for TypeScript and Python examples of the single-chain and multichain read paths.
Last updated