> For the complete documentation index, see [llms.txt](https://docs.kpk.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kpk.io/funds/infrastructure/architecture/onchain-accounting/balance-adapters.md).

# 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`

<figure><picture><source srcset="/files/JNHIkGNEbDXelMCC37fC" media="(prefers-color-scheme: dark)"><img src="https://1699303810-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FUsQM4bzcmOPSgkWddkeN%2Fuploads%2FUJP2fI6SHw3Ebib0c1HC%2Flegend-bar.png?alt=media&amp;token=227c8838-b900-49d2-ba06-2b6c2da567f4" alt="Diagram key: Input/source is light blue, Transform is violet, Staleness gate is amber, Output is green, Off-chain is dashed grey."></picture><figcaption><p>Diagram key: the same colours are used across every adapter and price-feed diagram.</p></figcaption></figure>

## 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):

```solidity
interface IBalanceAdapter is IERC165 {
    /// Balance-only positions for an account (assetFilter == address(0) for all assets).
    function getAdapterPositions(address account, address assetFilter)
        external view returns (PositionBalance[] memory positions);

    function getAdapterBalances(address account)
        external view returns (address[] memory assets, uint256[] memory amounts, bool[] memory isDebt);

    function getAdapterBalanceForAsset(address account, address asset)
        external view returns (uint256 amount, bool isDebt);

    /// Assets this adapter reports on (may be empty if determined dynamically).
    function underlyingAssetsSupported() external view returns (address[] memory);
}
```

`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](/funds/infrastructure/architecture/onchain-accounting/concepts/position-identity.md)). Labels are **not** on the position; adapters implement [`IPositionDescribable`](/funds/infrastructure/architecture/onchain-accounting/concepts/position-identity.md#human-readable-labels) (`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):

```
getAdapterPositions(0x1111…1111, address(0))   // address(0) = all assets
→ [
    PositionBalance { balanceAsset:{0xA0b8…eB48,"USDC",6}, balanceAdapter:0xa1b3…60C1,
      amount:12000000000 /* 12,000 USDC */, isDebt:false,
      protocolBrand:keccak256("aave"), protocolId:keccak256("aave-v3"), protocolSubId:keccak256("aave-v3-lending"),
      positionId:abi.encode(pool), positionKind:3 /* Collateral */, isLocked:false, positionInstanceId:bytes32(0) },
    PositionBalance { balanceAsset:{0xC02a…6Cc2,"WETH",18}, balanceAdapter:0xa1b3…60C1,
      amount:1500000000000000000 /* 1.5 WETH */, isDebt:true,
      protocolBrand:keccak256("aave"), protocolId:keccak256("aave-v3"), protocolSubId:keccak256("aave-v3-lending"),
      positionId:abi.encode(pool), positionKind:2 /* Borrowed */, isLocked:false, positionInstanceId:bytes32(0) }
  ]

getAdapterBalances(0x1111…1111)
→ ( [0xA0b8…eB48, 0xC02a…6Cc2],          // assets
    [12000000000, 1500000000000000000], // amounts
    [false, true] )                      // isDebt

getAdapterBalanceForAsset(0x1111…1111, 0xC02a…6Cc2)   → (1500000000000000000, true)   // 1.5 WETH debt

underlyingAssetsSupported()   → [0xA0b8…eB48, 0xC02a…6Cc2, …]   // assets the configured instances report on
```

## Plain adapters vs. meta-adapters

Adapters come in two flavours:

{% tabs %}
{% tab title="Plain adapter" %}
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:

```solidity
function addBalanceAdapters(address[] calldata adapters) external;     // MANAGER
function removeBalanceAdapters(address[] calldata adapters) external;
```

Examples: `Cap`, `AaveV3SafetyModule`, `SparkLend`, the withdrawal-queue adapters.
{% endtab %}

{% tab title="Meta-adapter" %}
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, onchain **instance set** (`bytes32` coordinates, usually a vault/pool/market address widened to `bytes32`, or a raw `bytes32` market id).

Registered and adjusted with:

```solidity
function addMetaBalanceAdapter(address adapter, bytes32[] calldata instances) external;  // register + seed
function addMetaInstances(address adapter, bytes32[] calldata instances) external;       // add coordinates
function removeMetaInstances(address adapter, bytes32[] calldata instances) external;    // remove coordinates
```

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`, `ConvexCurveLPStakingMeta`. See [Meta balance adapters](/funds/infrastructure/architecture/onchain-accounting/meta-balance-adapters.md) for the full model and catalogue.
{% endtab %}
{% endtabs %}

{% hint style="info" %}
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.
{% endhint %}

{% content-ref url="/pages/WVS5FrCAb7oPDpSaxXEw" %}
[Meta balance adapters](/funds/infrastructure/architecture/onchain-accounting/meta-balance-adapters.md)
{% endcontent-ref %}

## Assisted adapters

A third family, **assisted adapters**, handles positions that cannot be discovered purely onchain (behind a non-enumerable NFT or an opaque exit-queue ticket). The position owner caches *only the position coordinates* itself, onchain 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:

{% content-ref url="/pages/5jla9uxBHtbur2nGloBe" %}
[Assisted balance adapters](/funds/infrastructure/architecture/onchain-accounting/assisted-balance-adapters.md)
{% endcontent-ref %}

## Attested adapters

A fourth family, **attested adapters**, covers positions the chain can *verify* but not *value*. The position itself is real and onchain: the protocol custodies the assets and will honour the claim. What is absent is the information that says how much a given account is owed: it is computed offchain, and the contract stores only a **commitment** to it, typically a merkle root. So there is no coordinate to cache and value live, as with assisted adapters; the amount itself must be cached, and integrity comes from a **proof carried by every write and verified onchain** against that commitment. The first member is [Merkl Rewards](/funds/infrastructure/architecture/onchain-accounting/attested-balance-adapters/merkl.md), live on seven of the eight asset-bearing chains (all but Arc).

{% content-ref url="/pages/myv3ymA7EBBaSHc67iKs" %}
[Attested balance adapters](/funds/infrastructure/architecture/onchain-accounting/attested-balance-adapters.md)
{% endcontent-ref %}

## The default adapter

`ERC20DefaultBalanceAdapter` 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. A token whose address a meta-adapter claims as an instance coordinate is excluded automatically, so the two never both report it. An asset's wallet reporting can also be muted/unmuted without unregistering it:

```solidity
function excludeAssetFromDefaultAdapter(address asset) external;
function includeAssetInDefaultAdapter(address asset) external;
```

## 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.

```solidity
function getAccountPositions(address account, address quoteAsset)
    external view returns (Position[] memory);
```

For human-readable labels, use the NAV Calculator's verbose reads (`getAccountPositionsVerbose` / `getAccountNavVerbose`); see [NAV Calculator](/funds/infrastructure/architecture/onchain-accounting/contracts/nav-calculator.md#read-functions).

## 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](/funds/infrastructure/architecture/onchain-accounting/assisted-balance-adapters.md), 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`:

* `amount` is **always positive**; direction is carried by `isDebt` (`true` subtracts from NAV).
* `balanceAdapter` is `address(this)` (provenance).
* set the three protocol ids (`protocolBrand` / `protocolId` / `protocolSubId`, `keccak256` slug constants from `ProtocolIds.sol`), encode `positionId` per the `protocolSubId` schema, tag `positionKind`, set `isLocked` (`false` unless the leg is a non-withdrawable withdrawal-queue / cooldown portion), and set `positionInstanceId` to the per-item handle (NFT id, withdrawal-request id, exit ticket) or `bytes32(0)`; see [Position identity](/funds/infrastructure/architecture/onchain-accounting/concepts/position-identity.md).
* 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.

{% hint style="warning" %}
**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.
{% endhint %}

{% hint style="warning" %}
**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.
{% endhint %}

**Plain vs. meta:**

* **Plain**: pin the single scope (one vault / pool / market / token, or hardcoded constants) in the constructor and implement the `IBalanceAdapter` reads 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` (default `abi.encode(_toAddress(instance))`; use `abi.encode(instance)` for raw-`bytes32` coordinates 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.

{% hint style="info" %}
**`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.
{% endhint %}

**Shape enumeration (optional)**; an adapter whose position shapes are bounded and derivable onchain may also implement [`IPositionEnumerable`](/funds/infrastructure/architecture/onchain-accounting/concepts/position-identity.md#enumerating-position-shapes) (`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 `MANAGER` role on the NAV Calculator, held by the NAV timelock and the Security Council Safe (the Governance Safe on Robinhood Chain), and by `AssetKindRegistry`, which holds it so that it can register, classify, and label an asset in one transaction; see [Admin / Manager API](/funds/infrastructure/architecture/onchain-accounting/contracts/nav-calculator.md#admin-manager-api). Because the contracts are immutable, 'changing' an adapter means deploying a new one and swapping it in the registry.

|            | Plain adapter                                                                    | Meta-adapter                                                                                                                                                                                   |
| ---------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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 and clear its set. Neither puts a removed token back on the default adapter's inclusion list |

## Adapter catalogue

### Default

| Adapter                                                                                                 | Protocol | Positions                           |
| ------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------- |
| [ERC20Default](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/erc20-default.md) | Wallet   | Wallet balance per registered asset |

### Plain (singleton) adapters

| Adapter                                                                                                                      | Protocol | Positions                                               |
| ---------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------- |
| [SparkLend](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/sparklend.md)                             | Spark    | Supply · Collateral · Borrow                            |
| [Cap Stablecoin](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/cap-stablecoin.md)                   | Cap      | Held cUSD (Supplied), in USDC                           |
| [Cap Savings](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/cap-savings.md)                         | Cap      | Staked stcUSD (Staking), in USDC                        |
| [Aave Safety Module](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/aave-safety-module.md)           | Aave     | Staked (locked + claimable) · Rewards                   |
| [Aave Umbrella](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/aave-umbrella.md)                     | Aave     | Staked (locked + claimable) · Rewards                   |
| [Lido Withdrawal Queue](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/lido-withdrawal-queue.md)     | Lido     | One ETH claim per open request (`isLocked` per request) |
| [Stader Withdrawal Queue](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/stader-withdrawal-queue.md) | Stader   | One ETHx→ETH claim per open request                     |
| [Kelp Withdrawal Queue](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/kelp-withdrawal-queue.md)     | Kelp     | One exit claim per open request                         |
| [Ethena Cooldown](/funds/infrastructure/architecture/onchain-accounting/balance-adapters/ethena-cooldown.md)                 | Ethena   | sUSDe→USDe cooldown (single leg, `isLocked` toggles)    |

### Assisted adapters

Permissionless, owner-fed adapters (`msg.sender`-keyed, no roles) for positions not discoverable onchain, ether.fi Withdrawal Queue, StakeWise V3 Exit Queue, Nexus Mutual Staking. The full model and catalogue are on a dedicated page:

{% content-ref url="/pages/5jla9uxBHtbur2nGloBe" %}
[Assisted balance adapters](/funds/infrastructure/architecture/onchain-accounting/assisted-balance-adapters.md)
{% endcontent-ref %}

### Attested adapters

Proof-verified caches for positions whose value the chain commits to but cannot compute. Writes are `SUBMITTER_ROLE`-gated and each carries a proof checked against that commitment; the stale-gated clears are permissionless. The full model and members are on a dedicated page.

{% content-ref url="/pages/myv3ymA7EBBaSHc67iKs" %}
[Attested balance adapters](/funds/infrastructure/architecture/onchain-accounting/attested-balance-adapters.md)
{% endcontent-ref %}

### 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:

{% content-ref url="/pages/WVS5FrCAb7oPDpSaxXEw" %}
[Meta balance adapters](/funds/infrastructure/architecture/onchain-accounting/meta-balance-adapters.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.kpk.io/funds/infrastructure/architecture/onchain-accounting/balance-adapters.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
