For the complete documentation index, see llms.txt. This page is also available as Markdown.

Position identity

How positions are identified: the 3-level protocol taxonomy, the positionId / positionKind / isLocked fields, the ephemeral positionInstanceId, and the reconciliation key.

Every position the NAV Calculator reports carries a machine-readable identity so consumers can branch, index, and reconcile without parsing display strings. The stable identity is the typed key protocolSubId + positionId + balanceAsset.asset + positionKind on each PositionBalance / Position, scoped per chainId.

Source: INAVCalculator.sol · ProtocolIds.sol


The identity fields

Each position carries a 3-level protocol taxonomy (protocolBrandprotocolIdprotocolSubId). Only the most specific level, protocolSubId, is part of the identity key; the two broader levels exist for grouping and roll-up.

Field
Type
Meaning

protocolBrand

bytes32

keccak256 of the version-stripped brand slug (e.g. aave, balancer, uniswap). Broadest grouping layer — display/roll-up only, not in the identity key.

protocolId

bytes32

keccak256 of the ecosystem slug, version included (e.g. aave-v3, balancer-v2). Mid grouping layer — not in the identity key (functionally determined by protocolSubId).

protocolSubId

bytes32

keccak256 of the product slug (e.g. aave-v3-lending, balancer-v3-pools). In the identity key — it selects the positionId decode schema: one slug ⇒ exactly one schema, so no runtime sniffing is needed.

positionId

bytes

ABI-encoded static position coordinates. How to decode it is fully determined by protocolSubId.

positionKind

enum

Semantic action family (below). isDebt remains the canonical NAV-sign signal; positionKind adds category context.

isLocked

bool

true when the leg is not withdrawable right now — a pending/cooling withdrawal request or cooldown. false for every other leg. A mutable per-leg attribute, NOT part of the identity key — a leg flips true→false as its portion finalizes, so keying on it would split one economic position. A withdrawal queue's claimable (isLocked=false) and locked (isLocked=true) legs share one key and are summed in reconciliation; read isLocked per leg for the claimable-vs-locked breakdown.

positionInstanceId

bytes32

Ephemeral per-item handle — an NFT tokenId, a withdrawal-request id, or a StakeWise exit ticket; bytes32(0) when there is no per-item handle. Excluded from the stable identity key by design, so the legs of one anchor sum and identity survives item churn.

balanceAdapter (the address that reported a position) is provenance, not identity — the same logical position can move to a different adapter across a redeploy. protocolBrand and protocolId are grouping only. The stable identity is protocolSubId + positionId + balanceAsset.asset + positionKind (per chain).


PositionKind

enum PositionKind {
    Wallet,      // plain ERC20 / native balance held directly
    Supplied,    // credit deployed into a protocol: lending supply, vault shares, unstaked LP principal
    Borrowed,    // debt owed to a protocol (isDebt = true)
    Collateral,  // credit locked as collateral
    Staking,     // principal locked behind an unstake action (Safety Module, gauge/Convex-staked LP, …)
    Rewards,     // accrued but unclaimed reward tokens (CRV, CVX, BAL, COMP, …)
    Fees         // uncollected protocol / LP fees (e.g. Uniswap V3 position fees)
}

The kind reflects the outermost action family — what the account does to enter/exit the position. Gauge-staked LP is Staking regardless of the LP wrapped inside; the inner exposure stays recoverable from protocolId / positionId. Unstaked LP sitting in the wallet is Supplied.


Protocol taxonomy catalogue

Slugs are defined in src/utils/ProtocolIds.sol; each constant is keccak256 of the slug. The protocolSubId (product) column is the one that enters the identity key and selects the decode schema; protocolId (ecosystem) and protocolBrand (brand) group it. Single-product ecosystems collapse — brand = id = subId.

Family

protocolId (ecosystem)

protocolSubId (product)

Wallet

wallet

wallet

Aave V3

aave-v3

aave-v3-lending · aave-v3-safety-module · aave-v3-umbrella

Spark

spark

spark-lend

Morpho

morpho

morpho-vaults · morpho-markets

Balancer V3

balancer-v3

balancer-v3-pools · balancer-v3-gauges

Balancer V2

balancer-v2

balancer-v2-pools · balancer-v2-gauges

Curve

curve

curve-pools · curve-gauges

Convex

convex

convex-curve-lp-staking

Uniswap V3 / V2

uniswap-v3 · uniswap-v2

uniswap-v3 · uniswap-v2

PancakeSwap V3 / V2

pancakeswap-v3 · pancakeswap-v2

pancakeswap-v3 · pancakeswap-v2

Gearbox V3

gearbox-v3

gearbox-v3-markets · gearbox-v3-credit-accounts

Euler

euler

euler-vaults

Compound V3

compound-v3

compound-v3-comets

Fluid

fluid

fluid-ftokens · fluid-vaults

StakeWise V3

stakewise-v3

stakewise-v3-vaults · stakewise-v3-exit

Nexus Mutual staking

nexus-mutual

nexus-mutual-staking-pools

Generic ERC-4626

erc4626

erc4626

Cap

cap

cap

Withdrawal / cooldown queues

lido · stader · kelp · etherfi · ethena

lido-withdrawal · stader-withdrawal · kelp-withdrawal · etherfi-withdrawal · ethena-cooldown

For grouping, protocolBrand strips the version: aave-v3 → aave, balancer-v2/balancer-v3 → balancer, uniswap-v3/uniswap-v2 → uniswap, pancakeswap-* → pancakeswap, gearbox-v3 → gearbox, stakewise-v3 → stakewise, compound-v3 → compound. Single-version protocols reuse their ecosystem id as the brand.


positionId decode schemas

All positionId values are abi.encode(...). The schema is keyed by protocolSubId:

protocolSubId

positionId schema

wallet

(address asset)

aave-v3-lending · spark-lend

(address pool) — one position per pool; the reserve is carried in balanceAsset, not the positionId

aave-v3-safety-module · aave-v3-umbrella

(address stakeToken) — the two staking legs are told apart by isLocked, the reward leg by positionKind

morpho-vaults · gearbox-v3-markets · euler-vaults · erc4626 · stakewise-v3-vaults · fluid-vaults

(address vault)

gearbox-v3-credit-accounts

(address creditAccount) — one anchor per Credit Account; the Borrowed leg and each Collateral leg share it

cap

(address token) — the cUSD leg or the stcUSD leg

morpho-markets

(bytes32 marketId)

balancer-v3-pools · curve-pools

(address pool)

balancer-v3-gauges · balancer-v2-gauges · curve-gauges

(address gauge)

convex-curve-lp-staking

(address rewarder) — Convex BaseRewardPool

balancer-v2-pools

(bytes32 poolId)

uniswap-v3 · pancakeswap-v3

(address pool) — one per fee-tier pool; the NFT tokenId is in positionInstanceId, not the positionId

uniswap-v2 · pancakeswap-v2

(address pair)

compound-v3-comets · fluid-ftokens

(address market)

nexus-mutual-staking-pools

(address stakingPool) — the NFT tokenId is in positionInstanceId

stader-withdrawal

(address userWithdrawalManager) — one anchor; one leg per open request (positionInstanceId = request id)

kelp-withdrawal

(address withdrawalManager) — one anchor across all payout assets; one leg per open request (positionInstanceId = request id), legs told apart also by balanceAsset.asset

lido-withdrawal

(address withdrawalQueue) — one anchor; one leg per open request (positionInstanceId = request id)

ethena-cooldown

(address sUSDe) — single leg, isLocked toggles

etherfi-withdrawal

(address withdrawRequestNFT) — one anchor; one leg per open request (positionInstanceId = request NFT id)

stakewise-v3-exit

(address vault) — static, per vault; one leg per exit ticket (positionInstanceId = ticket)

Static positionId + per-item legs. The withdrawal-queue and exit-queue adapters use a static positionId (the anchor contract above) and emit one leg per open request, each carrying its request id in positionInstanceId and its own isLocked (finalized/claimable → false, pending → true); the owed asset is in balanceAsset. The Ethena cooldown is a single leg whose isLocked toggles. Example — to decode a Morpho market leg: confirm protocolSubId == keccak256("morpho-markets"), then marketId = abi.decode(positionId, (bytes32)). To decode an Aave supply leg: pool = abi.decode(positionId, (address)) — the supplied/borrowed reserve is the position's balanceAsset.asset.


Stable reconciliation key

To match the same position across reads (and across adapter redeploys), use:

This keys on protocolSubId (the product level), and deliberately excludes protocolBrand, protocolId, balanceAdapter, positionInstanceId, isLocked, amount, and all pricing — those either are grouping-only, change between reads, or are mutable/ephemeral, while the position's identity does not. isLocked is excluded because it is a mutable per-leg attribute (a leg flips true→false as its portion finalizes), so keying on it would give one economic position two keys over its lifetime.

Because isLocked and positionInstanceId are both excluded, a withdrawal queue's claimable and locked legs — and all its per-request legs — collapse onto a single reconciliation key and sum to the full holding. Read isLocked per leg if you want the claimable-vs-locked breakdown, and append positionInstanceId if you want per-item granularity — at the cost of an ephemeral key that blinks in and out as items finalize, mint, claim, or burn. Never use those mutable/ephemeral fields for stable identity.


Human-readable labels

Labels are not carried on the position. They are produced lazily, on the verbose read path only: getAccountPositionsVerbose / getAccountPositionsForAssetVerbose / getAccountNavVerbose call each adapter's IPositionDescribableprotocolName() and positionLabels(positionId) — via fail-open, gas-capped staticcalls, and return VerbosePosition { position, protocolName, labels }. Non-verbose reads return positions without labels (lower gas). The full breadcrumb a consumer renders is [protocolName, ...labels] — e.g. ["Morpho", "Market", "WETH/USDC"] or ["Uniswap V3", "AMM Liquidity Pool", "USDC/WETH 0.3%"].

Because positionLabels receives only the static positionId, it never includes a per-item id; the ephemeral NFT/request id lives in positionInstanceId. An adapter that does not implement IPositionDescribable (or whose call reverts) simply yields an empty protocolName / empty labels; the position's other fields are unaffected.

Enumerating position shapes

A configuration view — a registry page listing what the NAV Calculator is set up to account for — has no account, so it has no positionId to pass to positionLabels. For meta-adapters the shapes are the governed instance set, readable via getMetaInstances(adapter). For other adapters there was no enumeration primitive at all, so the only question a consumer could ask was positionLabels("") — which answers only for adapters whose breadcrumb happens to be positionId-independent. An adapter that derives labels per positionId correctly returns an empty array to that probe, and was therefore indistinguishable from one that was simply broken.

IPositionEnumerable closes that gap. It is the companion to IPositionDescribable and read the same way — optional, ERC-165 advertised, called through a fail-open staticcall, and never on the NAV path:

Its one binding guarantee: each returned id is byte-identical to the positionId the adapter emits for that shape, so a configuration view can be joined against real positions. An id that merely labels correctly while differing from the emitted bytes silently breaks that join. The ERC-165 bit is itself the signal that an empty answer was a real answer rather than a failure.

These are shapes, not positions: an id being listed says nothing about whether any account holds a balance, and there are no amounts. As with labels, don't treat the enumeration as an identity or indexing mechanism — reconcile on the typed protocolSubId / positionId / positionKind fields.

Adapters implement it only where the shape set is bounded and derivable on-chain — pinned at construction, or a governed instance set. That is decided per adapter rather than per family: an adapter whose shapes are discovered per account (a coordinate cache fed by the position owner) has no account-independent answer and must not implement it, while one that pins its shapes at construction does, even if it is assistant-fed. Neither positionLabels("") nor the IAssistedBalanceAdapter marker discriminates — check the ERC-165 bit.

The whole meta family implements it. A meta-adapter's shape set is its governed instance set, so positionIds() reads getMetaInstances(address(this)) off the NAV Calculator and maps each coordinate through the same internal hook that builds the emitted positionId. Enumeration therefore tracks governance with no adapter state and no admin surface — adding an instance is already a MANAGER config transaction — and the adapters stay immutable. A coordinate that cannot be resolved is dropped and the remaining shapes still returned, so one bad instance never blanks the enumeration.

So a consumer sees three answers, and the ERC-165 bit tells them apart: an enumerable adapter returns its shapes from positionIds(); a single-shape adapter answers the account-less positionLabels("") probe directly; and a per-account adapter returns an empty array, meaning its shapes only exist once an owner has fed coordinates.

There is no NAV Calculator passthrough for positionIds(), just as there is none for protocolName() or positionLabels(): a consumer takes the adapter set from getAllAdapters() and staticcalls each adapter directly, exactly as it already does for the display surface.

Label catalogue

The breadcrumb each adapter returns from positionLabels, keyed by protocolSubId (the full rendered breadcrumb is [protocolName, ...labels]). Leaf placeholders (<…>) are read live on-chain; a read that fails drops only the leaf, collapsing to the bare category (e.g. ["Vault"], ["Market"], ["AMM Liquidity Pool"]).

That degradation is a guarantee, not best effort: positionLabels is display-only and never reverts. The metadata readers behind it guard the call target's codesize and validate the ABI head before decoding, rather than relying on try/catch — which catches neither a call to a code-less address nor a return-data decode failure, since both revert in the calling frame. This matters because the NAV Calculator collapses a reverting labels read into an empty array, losing the category as well as the leaf, so the row would render with no breadcrumb at all — strictly worse than a missing leaf.

A malformed positionId is handled the same fail-open way rather than reverting: an id that is not exactly one 32-byte word returns an empty array, and a word that is not a valid address (non-zero upper 96 bits) degrades to the bare category. The accept window is exactly 32 bytes, not "at least 32" — abi.decode silently ignores trailing bytes, so a looser guard would let a foreign multi-word coordinate through and label it from its first word, producing a confident, wrong breadcrumb for a position the adapter never emitted.

Deriving a leaf can also be capped on gas. Some leaves are built by reaching further into the chain from the governed coordinate — pool coins, a gauge's LP token, an ERC-20 symbol() per token — so those reads sit behind a fixed per-leaf ceiling; the rest need none, because they bottom out in the shared metadata readers, which are already capped per read. A leaf that does not fit its ceiling is dropped, degrading to the bare category — the same answer an unreadable symbol has always produced.

This is a liveness bound, not a correctness one, and it applies only to the display path. Valuation reads are untouched: nothing here can change an amount or a price, and a label that cannot be derived never suppresses a position. Bounding the valuation reads the same way would be a different and riskier trade — too tight a bound there would skip a real market rather than a display string.

protocolSubId

positionLabels

wallet

["<symbol>"] (or ["native"] for the native token)

aave-v3-lending

["Market", "<market id>"]

spark-lend

["Market", "SparkLend"]

aave-v3-safety-module

["Safety Module"]

aave-v3-umbrella

["Umbrella Module"]

morpho-markets

["Market", "<collateral>/<loan>"]

morpho-vaults · euler-vaults

["Vault", "<vault name>"]

fluid-vaults

["Vault", "<collateral>/<debt> #<vaultId>"] (e.g. wstETH/USDC #4) — a Fluid vault is not an ERC20, and the pair alone is not unique

stakewise-v3-vaults

["Vault", "<vault name>"], falling back to ["Vault", "<underlying symbol> <vault address>"] — only the …Erc20Vault variants answer name()

fluid-ftokens

["Lending", "<fToken symbol>"]

compound-v3-comets

["Market", "<comet symbol>"] (e.g. cUSDCv3)

gearbox-v3-markets

["Market", "<market name>"]

gearbox-v3-credit-accounts

["Credit Account", "<credit-manager name>"] (→ underlying asset symbol if name is empty)

cap

["Stablecoin", "cUSD"] · ["Savings", "stcUSD"]

curve-pools · balancer-v2-pools · balancer-v3-pools

["AMM Liquidity Pool", "<sym0>/<sym1>/…"]

uniswap-v2 · pancakeswap-v2

["AMM Liquidity Pool", "<sym0>/<sym1>"]

uniswap-v3 · pancakeswap-v3

["AMM Liquidity Pool", "<sym0>/<sym1> <fee>%"] (e.g. USDC/WETH 0.3%)

curve-gauges · balancer-v2-gauges · balancer-v3-gauges

["Staking Gauge", "<sym0>/<sym1>/…"]

convex-curve-lp-staking

["Staking", "Curve LP", "<coins>"]

nexus-mutual-staking-pools

["Staking Pools", "Pool #<poolId>"]

lido-withdrawal

["Withdrawal Queue", "stETH"]

stader-withdrawal

["Withdrawal Queue", "ETHx"]

kelp-withdrawal

["Withdrawal Queue", "rsETH"]

etherfi-withdrawal

["Withdrawal Queue", "eETH"]

stakewise-v3-exit

["Exit Queue", "<receiptSymbol>"] (e.g. osETH; falls back to ["Exit Queue"])

ethena-cooldown

["Unstaking Cooldown", "sUSDe"]

Last updated