> 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/onchain-accounting/balance-adapters.md).

# Balance 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`](https://github.com/karpatkey/onchain-accounting/blob/main/src/balances/IBalanceAdapter.sol)

<figure><img src="/files/gQagfplUSMnAUF2AdUqq" alt="Diagram key: Input/source is light blue, Transform is violet, Staleness gate is amber, Output is green, Off-chain is dashed grey."><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/onchain-accounting/concepts/position-identity.md)). Labels are **not** on the position; adapters implement [`IPositionDescribable`](/funds/infrastructure/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(0x1F98…E984, address(0))   // address(0) = all assets
→ [
    PositionBalance { balanceAsset:{0xA0b8…eB48,"USDC",6}, balanceAdapter:0x5035…a550,
      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:0x5035…a550,
      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(0x1F98…E984)
→ ( [0xA0b8…eB48, 0xC02a…6Cc2],          // assets
    [12000000000, 1500000000000000000], // amounts
    [false, true] )                      // isDebt

getAdapterBalanceForAsset(0x1F98…E984, 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, 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:

```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/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/onchain-accounting/meta-balance-adapters.md)
{% endcontent-ref %}

***

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

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

***

## Attested adapters — NOT LIVE, NOT SCHEDULED

{% hint style="warning" %}
**Everything in this section describes a proposed design that is not part of the system. None of it is deployed, none of it is scheduled, and none of it is part of anything described elsewhere on this page.**

Read the rest of this page as a description of the live system. Read **this** section as a proposal that has been **deferred** — it is not on a roadmap, no date is attached to it, and it may change substantially or never ship. **No attested adapter exists, no Merkl position can appear in any NAV read, and nothing here can be relied on or planned around.** This is a **decision**, not a stage in a process: the design was reviewed and deferred. Shipping it would take a deliberate act: the code is not merged, and no configuration registering such an adapter exists on `main`, so it cannot arrive as a side effect of other work. If it is ever revived and shipped, this section would be rewritten in the present tense and folded into the families above.
{% endhint %}

A proposed **fourth family**, peer to plain adapters, meta-adapters and assisted adapters, for positions whose data cannot be derived from on-chain state at all — not even located, as with assisted adapters, but *valued*. The motivating case is off-chain reward distribution: an allocation lives in an off-chain merkle tree, and the on-chain contract stores only what has already been claimed plus the current tree root. There is no coordinate to cache and value live, so the assisted family's rule — *cache coordinates, never amounts* — cannot apply.

The family is called **attested** because of what would replace that rule: **every write would carry a protocol-specific validity proof, verified on-chain.** Integrity would come from the proof, not from who submitted it.

**Who would be able to write.** Anyone. The submit call would take an explicit account and be callable by any address — no roles, no access control, no delegatecall assistant — so a single off-chain agent could feed many tracked accounts. That is a deliberate departure from the assisted family, where the *owner* feeds their own positions.

**Why that would be safe.** Four properties, each doing distinct work:

* **Proof-verified.** A write would only land if its proof validated against the protocol's live root, so a faulty or hostile feeder could only ever cache an account's *real* data — it could **under-report, never over-report**.
* **Upsert, never suppress.** A submit would add or refresh the entries it carries and leave omitted entries untouched, so a third party could not wipe someone's balance with a partial or empty submission.
* **Removal is stale-gated.** The clear calls would revert while any entry is still contributing to NAV — garbage collection for dead entries only, never a way to silence a live one.
* **Stale reads as zero.** If a cached entry's root no longer matched the live root, the read would return `0` rather than extrapolate across a root rotation.

**What a position would look like.** The first member would report unclaimed Merkl rewards under the protocol name `Merkl`, with the single label `Claimable Rewards` and `positionKind: Rewards`. A token's unclaimed total would be its proof-verified cumulative minus the amount already claimed on-chain.

{% hint style="danger" %}
**If this ships, consumers must SUM Merkl legs and must not de-duplicate them.**

The read would emit **one leg per cached campaign share of a reward token, not one leg per token** — the token's unclaimed total split pro-rata across its campaign shares. A token submitted with no campaign breakdown would yield a single untagged leg carrying the whole amount.

Each campaign leg carries its campaign coordinate in `positionInstanceId`, which is **excluded from the stable reconciliation key**. So every campaign leg of one token would share an *identical* key while carrying a *different* amount. **A consumer that treats a repeated key as duplicate data and keeps one row would under-report that token by up to 32×.**

Campaign attribution would also be **display-grade, not audit-grade**: the proof commits only to the per-token total, and the contract would check only that the shares sum to it — so campaign identifiers could be duplicated or fabricated by a submitter. **The per-token total, the sum of a token's legs, is the proof-backed figure.** Treat the split as presentation.
{% endhint %}

**Two consequences that would follow from the design.** Reported balances would depend on agent **freshness** — after a root rotation an account would read `0` until the agent resubmitted, an intentional under-count rather than a fault. And the per-account leg count would be bounded by the product of two caps, at most **256 tokens × 32 campaigns = 8,192 legs**, not by the token cap alone; because the campaign split is not proof-bound, that multiplier is influenceable by whoever submits.

***

## 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. An asset's wallet reporting can 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/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/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/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 on-chain may also implement [`IPositionEnumerable`](/funds/infrastructure/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 Safe (`MANAGER` role) on the NAV Calculator — see [Admin / Manager API](/funds/infrastructure/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 entirely |

The full authoring → test → deploy recipe lives in the contracts repo: [`docs/howto/add-balance-adapter.md`](https://github.com/karpatkey/onchain-accounting/blob/main/docs/howto/add-balance-adapter.md) and [`src/balances/BALANCE_ADAPTERS.md`](https://github.com/karpatkey/onchain-accounting/blob/main/src/balances/BALANCE_ADAPTERS.md).

***

## Adapter catalogue

### Default

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

### Plain (singleton) adapters

| Adapter                                                                                                         | Protocol | Positions                                               |
| --------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------- |
| [SparkLend](/funds/infrastructure/onchain-accounting/balance-adapters/sparklend.md)                             | Spark    | Supply · Collateral · Borrow                            |
| [Cap Stablecoin](/funds/infrastructure/onchain-accounting/balance-adapters/cap-stablecoin.md)                   | Cap      | Held cUSD (Supplied), in USDC                           |
| [Cap Savings](/funds/infrastructure/onchain-accounting/balance-adapters/cap-savings.md)                         | Cap      | Staked stcUSD (Staking), in USDC                        |
| [Aave Safety Module](/funds/infrastructure/onchain-accounting/balance-adapters/aave-safety-module.md)           | Aave     | Staked (locked + claimable) · Rewards                   |
| [Aave Umbrella](/funds/infrastructure/onchain-accounting/balance-adapters/aave-umbrella.md)                     | Aave     | Staked (locked + claimable) · Rewards                   |
| [Lido Withdrawal Queue](/funds/infrastructure/onchain-accounting/balance-adapters/lido-withdrawal-queue.md)     | Lido     | One ETH claim per open request (`isLocked` per request) |
| [Stader Withdrawal Queue](/funds/infrastructure/onchain-accounting/balance-adapters/stader-withdrawal-queue.md) | Stader   | One ETHx→ETH claim per open request                     |
| [Kelp Withdrawal Queue](/funds/infrastructure/onchain-accounting/balance-adapters/kelp-withdrawal-queue.md)     | Kelp     | One exit claim per open request                         |
| [Ethena Cooldown](/funds/infrastructure/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 on-chain — 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/onchain-accounting/assisted-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/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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
