Balance adapters
How positions are located — the adapter interface, the default wallet adapter, and the singleton adapters.
A balance adapter is a contract that tells the NAV Calculator how much of an underlying asset an account holds in a given protocol — including positions that are not visible as a simple ERC-20 balanceOf.
Source: IBalanceAdapter.sol
Why balance adapters are needed
Many DeFi positions are not directly represented by a wallet balance:
A Morpho supply position is tracked as shares inside the Morpho protocol.
An Aave deposit is represented by an
aToken; a borrow by a debt token.A staked Curve/Balancer LP sits in a gauge, with rewards accruing separately.
Each adapter abstracts these differences and returns balance-only PositionBalance entries denominated in the underlying asset, tagged with a machine-readable identity. The NAV Calculator prices them uniformly.
The adapter interface
Every adapter implements IBalanceAdapter (and advertises it via ERC-165):
getAdapterPositions is the path the NAV Calculator uses; it returns PositionBalance entries with the typed identity — the 3-level protocol taxonomy (protocolBrand / protocolId / protocolSubId), positionId, positionKind, isLocked, plus the ephemeral positionInstanceId (see Position identity). Labels are not on the position; adapters implement IPositionDescribable (protocolName() + positionLabels(positionId)) so the verbose reads can build the breadcrumb lazily.
Example — querying the Aave V3 meta-adapter for an account that supplies USDC and borrows WETH (illustrative values, abbreviated addresses):
Plain adapters vs. meta-adapters
Adapters come in two flavours:
A plain adapter covers a single, fixed scope — one protocol singleton, one queue, one reward distributor. It needs no external configuration once deployed.
Registered with:
Examples: Cap, AaveV3SafetyModule, SparkLend, the withdrawal-queue adapters.
A meta-adapter covers many instances of one protocol from a single immutable contract — e.g. one MorphoMarketsMeta handles every Morpho market the fund touches. Which instances it queries is a governed, on-chain instance set (bytes32 coordinates — usually a vault/pool/market address widened to bytes32, or a raw bytes32 market id).
Registered and adjusted with:
Adding a new market/vault/pool is then just an addMetaInstances call — no new contract deploy. A malformed coordinate is isolated per-instance by the adapter's fan-out and cannot zero out the rest of the batch.
Examples: AaveV3InstancesMeta, MorphoVaultsMeta, BalancerV3PoolsMeta, ConvexRewardsMeta. See Meta balance adapters for the full model and catalogue.
A single meta-adapter contract serves all instances of its protocol, configured through its instance set, rather than one deployed adapter per market or pool.
Assisted adapters
A third family — assisted adapters — handles positions that cannot be discovered purely on-chain (behind a non-enumerable NFT or an opaque exit-queue ticket). The position owner caches only the position coordinates itself, on-chain and permissionlessly (msg.sender-keyed, no roles) — typically via a delegatecall assistant, a thin wrapper you use in place of the protocol's own deposit/withdraw call that performs the same action and records the resulting position on the adapter. The amounts are still valued live on every read, so a faulty feeder can only under-report, never inflate NAV. Full model and catalogue:
The default adapter
ERC20DefaultAdapter is deployed automatically inside NAVCalculator.initialize() and reports plain wallet balances for every registered ERC-20 plus the native token (ERC-7528 sentinel 0xEeee…EeeE). It cannot be removed. An asset's wallet reporting can be muted/unmuted without unregistering it:
Reading positions
getAccountPositions returns one priced Position per balance entry across all registered adapters, making a full portfolio breakdown straightforward. The same underlying asset can be reported by several adapters at once (held in the wallet, supplied to Morpho, staked in a gauge) — each is a distinct position with its own identity.
For human-readable labels, use the NAV Calculator's verbose reads (getAccountPositionsVerbose / getAccountNavVerbose); see NAV Calculator.
How an adapter is built
Most balance adapters are small, immutable, view-only contracts: no admin functions and no governed config stored on them. They only read chain state and return balance-only PositionBalance entries — the NAV Calculator does all the pricing. The sanctioned exception is the assisted adapters, which hold a permissionless, msg.sender-keyed coordinate cache (no roles) fed by the position owner.
Required of every adapter — implement IBalanceAdapter and advertise it via ERC-165 (supportsInterface(IBalanceAdapter) → true). When building each PositionBalance:
amountis always positive; direction is carried byisDebt(truesubtracts from NAV).balanceAdapterisaddress(this)(provenance).set the three protocol ids (
protocolBrand/protocolId/protocolSubId,keccak256slug constants fromProtocolIds.sol), encodepositionIdper theprotocolSubIdschema, tagpositionKind, setisLocked(falseunless the leg is a non-withdrawable withdrawal-queue / cooldown portion), and setpositionInstanceIdto the per-item handle (NFT id, withdrawal-request id, exit ticket) orbytes32(0)— see Position identity.only registered assets contribute to NAV; returning an unregistered asset is harmless for a credit leg (it's filtered out). It is not harmless for a debt leg — see the invariant below. Adapters usually take the NAV Calculator address in their constructor to resolve asset metadata.
Never report collateral without its offsetting debt. Adapters may only ever under-report. If an adapter cannot report a debt leg — the debt asset is unregistered on the NAV Calculator, or the debt cannot be read — it must drop the whole position, not just the debt leg. Emitting the credit legs alone would make a leveraged position read as pure collateral and over-report NAV by the entire borrow, the one direction the invariant forbids.
The check is independent of assetFilter: a filtered view that shows collateral while hiding an unreportable debt over-reports exactly the same way. Registering an instance whose debt token nobody registered is a MANAGER config transaction rather than a deploy, so this is a live configuration risk, not a theoretical one.
Adapters whose debt is denominated in the same asset as the credit leg (Euler vaults, StakeWise V3 vaults) are structurally unaffected — an unregistered asset already suppresses both legs together.
A starved read is not an empty read. Adapter failures fail open — a reverting adapter is isolated and contributes nothing, because a revert is the adapter answering. But an adapter that runs out of the gas forwarded to it out-of-gasses inside its own frame, and a try/catch cannot tell that from "I have no positions". Treating the two alike silently drops that adapter's legs, and a dropped debt leg raises the reported NAV.
Exhaustion is therefore surfaced rather than absorbed. Where a read cannot finish an adapter it reverts — AdapterGasExhausted(adapter, stipend, consumed), or InstanceGasExhausted(adapter, instance, stipend, consumed) one frame in, which also names the governed instance coordinate since the adapter alone does not locate the fault. getAccountNav, getAccountPositions, getAccountNavVerbose, hasPositions and healthCheck all revert in that case, rather than returning a NAV quietly missing those legs.
The failure is retryable, not a dead account: there is no fixed gas ceiling, the stipend is simply what the EVM forwards anyway (63/64 of what the read holds), so exhaustion is keyed to the caller's own budget and means "this read needs more gas". Read stipend and consumed as a ratio rather than a balance — the 63/64 rule is applied again at the call itself, so consumed lands slightly above stipend on a genuine exhaustion.
The distinction is deliberately preserved in both directions: a reverting instance stays isolated, because one misconfigured market must not cost the account every other market on the same adapter — a far more common situation than starvation.
Plain vs. meta:
Plain — pin the single scope (one vault / pool / market / token, or hardcoded constants) in the constructor and implement the
IBalanceAdapterreads directly.Meta — extend
MetaBalanceAdapter; pin only protocol-level constants in the constructor (e.g. the Morpho singleton, a position manager) — never the instances. Implement_instancePositions(account, instance, assetFilter)and_instanceAssets(instance), and optionally override_instancePositionId(defaultabi.encode(_toAddress(instance)); useabi.encode(instance)for raw-bytes32coordinates like Morpho market ids). The base supplies ERC-165, per-instance revert isolation (a starved instance raises rather than being absorbed — see above), and reads the configured instance set from the NAV Calculator.
Labels — implement IPositionDescribable (protocolName() + positionLabels(bytes positionId)) so the verbose reads can build the breadcrumb. Both are called lazily on the verbose path only (fail-open, gas-capped staticcalls) — labels are not stored on the PositionBalance. positionLabels derives its segments purely from the static positionId; the ephemeral per-item id rides in positionInstanceId, never in labels.
protocolName() names the protocol, never the product. It is not a constructor argument — adapters derive it from their own protocolId against a canonical table of display names, so same protocolId ⇒ same protocolName holds by construction and cannot drift between two adapters of one protocol. Adapters that are different products of the same protocol (Aave V3 lending / Safety Module / Umbrella; Morpho vaults / markets; Balancer pools / gauges) therefore all return the same name; what distinguishes the product is positionLabels() and the typed protocolSubId.
This matters because consumers group configured adapters by display name. The typed protocolBrand / protocolId / protocolSubId would group them correctly, but they only ride on PositionBalance structs from an account read — a consumer enumerating registered adapters never sees them. The display name is the only protocol identity at that layer, so an adapter returning its product name splits itself into a phantom sibling protocol.
Shape enumeration (optional) — an adapter whose position shapes are bounded and derivable on-chain may also implement IPositionEnumerable (positionIds()), so an account-less configuration view can list and label what the NAV is configured to account for. Like IPositionDescribable it is ERC-165 advertised, read through a fail-open staticcall, and never called on the NAV path.
Adding & removing adapters
Registration is governed by the Safe (MANAGER role) on the NAV Calculator — see Admin / Manager API. Because the contracts are immutable, "changing" an adapter means deploying a new one and swapping it in the registry.
Add
addBalanceAdapters([adapter])
addMetaBalanceAdapter(adapter, instances) (register + seed); then addMetaInstances to grow coverage
Remove
removeBalanceAdapters([adapter]) — the ERC20Default adapter cannot be removed
removeMetaInstances to drop instances; removeBalanceAdapters([adapter]) to remove the adapter entirely
The full authoring → test → deploy recipe lives in the contracts repo: docs/howto/add-balance-adapter.md and src/balances/BALANCE_ADAPTERS.md.
Adapter catalogue
Default
Plain (singleton) adapters
Assisted adapters
Permissionless, owner-fed adapters (msg.sender-keyed, no roles) for positions not discoverable on-chain — ether.fi Withdrawal Queue, StakeWise V3 Exit Queue, Nexus Mutual Staking. The full model and catalogue are on a dedicated page:
Meta-adapters
One immutable contract per protocol, each serving many instances via a governed instance set. The full model and catalogue are on a dedicated page:
Last updated