Meta balance adapters
One immutable adapter per protocol, serving many instances via a governed on-chain instance set.
A meta-adapter is a single immutable contract that reports positions for many instances of one protocol — every Morpho vault, every Aave market, every Balancer pool — instead of one deployed adapter per instance. It is the model used for all high-cardinality protocols.
Source: MetaBalanceAdapter.sol
Why
The original model deployed one adapter contract per pool / market / vault: covering N Morpho vaults meant N immutable contracts, each a CREATE deploy plus a governance registration. For protocols with many fungible instances this scaled badly — every new pool was a deploy, an audit-surface delta, and a governance action.
The framework's core rule is that adapters are immutable — no Ownable, no admin functions. So the instance set can't live (mutably) inside the adapter. Instead:
The adapter stays a pure function of (account, instance) over any instance of its protocol. The governed "which instances do we actually query" set lives on the NAVCalculator — which is already upgradeable and MANAGER-gated — and is passed to the adapter at query time.
This keeps every meta-adapter immutable while making the instance set a governed, on-chain registry.
How it works
IMetaBalanceAdapterextendsIBalanceAdapterand is keyed by abytes32instance coordinate. Its inheritedgetAdapterPositions(account, assetFilter)reads the adapter's own NAV-configured set viaNAV_CALCULATOR.getMetaInstances(address(this))and fans out over it — so the NAV Calculator's read path is unchanged (no meta-specific branch).MetaBalanceAdapteris the abstract base. It provides ERC-165, the registered-asset helper, and per-instance fan-out with per-instance revert isolation — a single bad/unknown instance coordinate is caught and skipped, and cannot zero out the rest of the batch. An instance that instead runs out of gas is not absorbed: the fan-out raisesInstanceGasExhausted(adapter, instance, stipend, consumed), because recording a starved instance as an empty leg list would drop its debt leg and over-report NAV behind a complete-looking answer. See a starved read is not an empty read. Concrete adapters implement two hooks (_instancePositions,_instanceAssets) and optionally_instancePositionId.Deploy-time compatibility check. The base constructor probes the NAVCalculator it is given for
getRegisteredAssetand revertsNAVCalculatorTooOld(navCalculator)if it cannot serve it — meta-adapters rely on that O(1) lookup, and pointing one at an older implementation would otherwise fail later, at read time. The guard binds the next adapter deployed, not those already live.Instance coordinate (
bytes32). For most protocols this is an address widened tobytes32(a vault, pool, gauge, or data-provider address). For protocols whose native identifier is already 32 bytes it is used raw — Morpho market ids and Balancer V2 pool ids.Identity is derived on-chain from the coordinate:
positionId = abi.encode(vault)and the 3-level taxonomy (protocolBrand/protocolId/protocolSubId) set fromProtocolIds.sol. Display labels are produced lazily — the adapter'spositionLabels(positionId)derives the breadcrumb (e.g. from the vault's ERC-20name()or the pool's coin symbols) on the verbose read path only. Nothing instance-specific is stored in config — see Position identity.
Registration
Meta-adapters are registered and curated by the governance Safe (MANAGER role) on the NAV Calculator. The adapter contract is immutable; the instance set is the governed, mutable part, with its own add/remove lifecycle:
Register the adapter + seed instances (atomic)
addMetaBalanceAdapter(adapter, instances)
Add instances (extend coverage)
addMetaInstances(adapter, instances)
Remove instances (drop coverage)
removeMetaInstances(adapter, instances)
Remove the adapter entirely
removeBalanceAdapters([adapter])
Inspect the configured set
getMetaInstances(adapter) (view)
addMetaInstances reverts DuplicateMetaInstance on a coordinate already present or repeated in the batch (a duplicate would double-count NAV); removeMetaInstances reverts MetaInstanceNotFound if a coordinate isn't present; both revert NotMetaAdapter if the target isn't a meta-adapter, and on an empty list. All are all-or-nothing. Full detail: NAV Calculator Admin / Manager API.
Example — the configured instance set of the Morpho Vaults meta-adapter (illustrative; each bytes32 is a vault address widened to 32 bytes):
For raw-bytes32 adapters (Morpho Markets, Balancer V2 Pools) the coordinates are the native ids themselves, not widened addresses.
Adding a new pool/vault/market is therefore a single addMetaInstances transaction — not a contract deploy.
Trust model. MANAGER can add an arbitrary instance coordinate, but a malicious or incorrect instance can only mis-report through the adapter's own read path (e.g. a fake ERC-4626 that lies about convertToAssets); it cannot fabricate values out of band, and NAV still drops positions whose asset is unregistered. Instance curation is a governance responsibility — index → whitelist → MANAGER transaction — mirroring how price feeds and plain adapters are added.
Plain (single-scope) adapters use addBalanceAdapters instead and have no instance set. See Balance adapters. A separate assisted-adapter family (ether.fi exit, StakeWise exit, Nexus Mutual) caches position coordinates the owner feeds permissionlessly on-chain (msg.sender-keyed, no roles) — distinct from the meta model here.
Meta-adapter catalogue
The Uniswap V3 / PancakeSwap V3 meta-adapters discover an account's LP NFTs on-chain (the NonfungiblePositionManager is ERC721Enumerable) and value them per configured pool. They link the GPL-2.0-or-later Uniswap V3 math libraries, so those two contracts are GPL-2.0-or-later rather than the repo's default BUSL-1.1.
Last updated