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

# Attested balance adapters

The fourth adapter family — for positions the chain can verify but not value, where every write carries a proof checked on-chain.

**The position is real, funded and enforceable on-chain. What is missing on-chain is the information needed to value it.**

The canonical shape is an off-chain allocation. The protocol custodies the assets and will honour the claim; how much each account is owed is computed off-chain from data the chain does not hold, and the contract stores only a **cryptographic commitment** to the resulting dataset — typically a merkle root — alongside how much each account has already taken.

So the chain can *verify* a value but cannot *derive* one. Handed a candidate figure and a witness for it, the contract answers yes or no. Handed only an account, it cannot produce that account's figure, or even enumerate which accounts hold one. Nothing is hidden and nothing is missing — the information is simply not in a form a `view` function can reach.

* **Base class:** `AttestedValueCache` — the single cache base for the whole family
* **Marker interface:** `IAttestedBalanceAdapter`
* **Source:** [`AttestedValueCache.sol`](https://github.com/karpatkey/onchain-accounting/blob/main/src/balances/AttestedBalanceAdapters/AttestedValueCache.sol) · [`IAttestedBalanceAdapter.sol`](https://github.com/karpatkey/onchain-accounting/blob/main/src/balances/IAttestedBalanceAdapter.sol)

***

## What each family is missing

Every adapter family exists because something it needs is absent from on-chain state. Which thing is absent decides the whole design:

| Family                                                                                                                                            | Absent on-chain                                                    | Consequence                                                            |
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| [Plain](/funds/infrastructure/onchain-accounting/balance-adapters.md) / [meta](/funds/infrastructure/onchain-accounting/meta-balance-adapters.md) | nothing                                                            | read the value live, every time                                        |
| [Assisted](/funds/infrastructure/onchain-accounting/assisted-balance-adapters.md)                                                                 | the position's **location** — a non-enumerable ticket or NFT id    | cache the coordinate; value it live; re-verify ownership on every read |
| **Attested**                                                                                                                                      | the position's **value** — the chain holds only a commitment to it | cache the value with a proof; net off whatever has already settled     |

The difference is not one of degree. An assisted adapter is missing an *address*, and a wrong one is harmless: the coordinate is re-checked against live ownership and values to nothing if it does not hold up. An attested adapter is missing a *number*, and the number **is** the report — there is no live state to check it against later. That is why this family cannot borrow the assisted rule of *cache coordinates, never amounts*, and why the amount it caches has to be proven at the moment it is written.

***

## Why the value has to be cached

Three constraints compose, and the family's entire shape follows from them:

1. **A value the chain can only verify must arrive from outside.** Someone has to bring both the figure and its witness.
2. **There is nowhere to put a witness at read time.** The NAV read's signature is fixed — `getAccountNav(account, quoteAsset)` reaches each adapter through `getAdapterPositions(account, assetFilter)` — and neither has a parameter that could carry proof data.
3. **So the value is stored, and its proof is checked when it is written** rather than when it is read.

That is the trade the family makes, and it is worth naming: it converts a **trust** problem into a **freshness** problem. Because every write is verified against the protocol's live commitment, the feeder is never trusted for the figure — only for keeping it current. What it can cost you is staleness, never a fabricated number.

{% hint style="info" %}
**"Merkle root" is the common case, not the family's assumption.** The base only requires that a member can answer *"is this cached element still valid?"* against some on-chain **validity anchor** — a tree root, a signature's TTL, a per-asset epoch. That is why the family exposes `isAttestationLive` as a predicate rather than publishing one global epoch: the first member's scheme is not baked into the family contract.
{% endhint %}

***

## An attested figure is an entitlement, so report it net

An attested position usually does not vanish when it settles — it **converts** into a form NAV already sees. Merkl's claim transfers the reward tokens into the account's wallet, where the default ERC-20 adapter reports them like any other balance.

So a member must report the entitlement **net of whatever has already been converted**, by reading the settled portion live from the protocol on every read. Reporting the gross attested figure would double-count the position for the entire window between settlement and the keeper's next submission — and in the one direction the family forbids.

***

## Trust model

Three properties, each doing distinct work:

* **Proof-verified.** A write lands only if its proof validates against the protocol's live commitment, so a feeder can cache only an account's *real* figure — it can **under-report, never over-report**.
* **No suppression.** A submit is an **upsert**: it refreshes the elements it carries and leaves the rest untouched, so a partial or empty submission cannot wipe a balance. Removal is only via the clears, and those are **stale-gated** — `clearAttestation` reverts while the element still contributes to NAV, and `clearAccount` reverts if *any* of the account's elements is still live. A live entry can be refreshed but never silenced.
* **Stale reads as zero.** When a cached element's epoch no longer matches the live anchor, the read returns `0` rather than extrapolate across a rotation.

The family reports **credits only**. Every leg has `isDebt == false`, and the base enforces it as it collects rather than trusting members to remember — because the whole safety argument rests on it. A dropped credit leg shrinks NAV, the direction this family already tolerates; a dropped debt leg would inflate it, the one direction it forbids.

{% hint style="info" %}
**What `SUBMITTER_ROLE` buys, and what it does not.** Writes are role-gated (granted and revoked by `DEFAULT_ADMIN_ROLE`); the clears are deliberately **not**. The role does not protect the reported *amount* — the per-write proof already does that, and no caller could ever write an unattested figure. It protects the space the proof does not cover: **which** elements occupy an account's cache, and **how** a proven total is labelled.

Removal is permissionless precisely *because* it is stale-gated. Role-gating it would mean a compromised role-holder could delete valid, live allocations — a targeted under-report caused by the mechanism meant to prevent one.
{% endhint %}

***

## The universal interface

The family is defined by one interface, `IAttestedBalanceAdapter`, whose calls are implemented **once for the whole family**. Only the proof data is protocol-specific: each member `abi.decode`s the opaque `bytes` into its own shape.

```solidity
// Writes — gated on SUBMITTER_ROLE
function submit(address account, bytes calldata data) external;
function submitBatch(address[] calldata accounts, bytes[] calldata data) external;

// Garbage collection — permissionless, stale-gated
function clearAttestation(address account, address asset) external;
function clearAccount(address account) external;

// Cache-free read — view, trustless
function previewPositions(address account, bytes calldata data)
    external view returns (PositionBalance[] memory positions);

// Introspection — the consumer surface
function attestations(address account, address asset)
    external view returns (uint256 value, bytes32 epoch, uint64 attestedAt);
function assetsOf(address account) external view returns (address[] memory);
function isAttestationLive(address account, address asset) external view returns (bool);
function maxAssetsPerAccount() external view returns (uint256);
```

`submit` takes an explicit account — the family is **submit-on-behalf-of**, so one keeper feeds many tracked accounts, unlike the assisted family where the owner feeds its own.

**Both writes are atomic.** Any invalid element reverts the whole `submit`, leaving the prior set intact. `submitBatch` is atomic **across accounts**: one bad element reverts the entire batch and no account in the call is updated. A multi-account feeder should therefore pre-filter its data, or fall back to per-account `submit`, so that one account cannot block every other account's refresh.

`submit` and `previewPositions` take the **same payload** and share the member's single decode-and-verify hook. That is deliberate: it is what stops the write path and the preview from ever disagreeing about what a valid attestation is.

***

## What a zero means

**Three different states all read as a balance of `0`, and each calls for a different response.** `isAttestationLive` is what tells them apart:

| State                     | How to detect it                                    | What it means                                                                                     |
| ------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Nothing cached            | `attestations(account, asset)` returns `epoch == 0` | The account has no element for this asset. Nothing to do                                          |
| Cached, no longer current | `epoch != 0` but `isAttestationLive` is `false`     | **The feeder is behind.** The anchor rotated and the keeper has not resubmitted — page the feeder |
| Live but fully consumed   | `isAttestationLive` is `true`, balance `0`          | The entitlement was claimed. Garbage-collect the element with `clearAttestation`                  |

A non-zero `value` from `attestations` is **not** a claim that the element contributes to NAV — it is the cached figure, whatever its epoch. For the amount actually reported, read `getAdapterBalanceForAsset`. `attestedAt` is a freshness timestamp for monitoring, not an input to the balance.

The consequence is a deliberate bias: reported balances depend on **keeper freshness**, and after a rotation an account reads `0` until the keeper resubmits. That is an intentional under-count, not a fault — the alternative is reporting a figure against a dataset the chain no longer commits to.

{% hint style="warning" %}
**`isAttestationLive` can flap, and live is not the same as counted.** Where a member's validity anchor is non-monotonic — one that can return to a value it held before — the predicate can return `false` and then `true` again for the same element. See the member's page for whether its anchor behaves that way. And a live but fully-consumed element reads `0` while remaining live, which is why the table above needs all three rows.
{% endhint %}

***

## How a read works

```
assets = assetsOf(account)              // the per-account cache drives the read
for each asset:
    if the cached epoch != the live epoch:  value it as 0     // stale, never extrapolated
    else:  value it from the cached amount, netted off live protocol state
    member fans that value into one or more legs
    base checks every leg as it collects   // credit, right asset, right adapter, sum within value
```

Each asset is valued behind its own stipended self-`staticcall`, so one reverting asset drops only itself rather than the account's whole set.

{% hint style="info" %}
**`underlyingAssetsSupported()` returns an empty array for this entire family** — and that is not a bug. The family cannot enumerate its universe ahead of time, because which assets an account holds is discovered from proofs, not declared up front. Use the per-account `assetsOf(account)` instead; a consumer that enumerates supported assets to decide what to read will read nothing.
{% endhint %}

***

## Reading without the cache

`previewPositions(account, data)` values an account from **caller-supplied** proof data, with no prior submit and without touching the cache. It is the family's answer for a portal, frontend or third party that computes proofs off-chain and wants a figure now.

It is a `view`: it reads no cached element and writes none, so it neither feeds the cache nor affects what the NAV returns — `getAdapterPositions` keeps reading the keeper-fed cache regardless. Integrity is identical to the write path, because every proof in `data` is verified against the live commitment, so it can only surface the account's real figure.

{% hint style="info" %}
**It fails loud, unlike the NAV read.** An invalid proof reverts rather than degrading to zero. That is the right direction for an explicit query — a caller supplying proof data wants to know the data was rejected — and the opposite of the NAV path, which must never let one bad element take down an account's whole reading. Empty `data`, or a zero account, returns an empty array; the same asset twice in one payload is rejected with `DuplicateAsset`.
{% endhint %}

***

## What the proof does and does not cover

{% hint style="danger" %}
**The attestation covers the proof-bound value, and nothing else a member returns.**

A member may attach auxiliary sub-position metadata — an identifier splitting one proven total into several reported legs. Where the proof commits only to the total and the contract checks only that the parts sum to it, **that metadata is submitter-controlled and is not attested.**

So a consumer must not treat returned position **identity** as proof-backed. The proof-backed figure is the **sum**. Each member's page states exactly which of its fields is attested and which is presentation, and how its legs must be aggregated.
{% endhint %}

***

## Error taxonomy

Declared on `IAttestedBalanceAdapter` itself, so a consumer can decode a revert without importing any member:

| Error                                  | Raised when                                                                |
| -------------------------------------- | -------------------------------------------------------------------------- |
| `NAVCalculatorTooOld(navCalculator)`   | the NAV Calculator given at construction cannot serve `getRegisteredAsset` |
| `UninitializedEpoch()`                 | the member's validity anchor read back as zero                             |
| `TooManyAttestations(account, count)`  | admitting an element would exceed the per-account cache cap                |
| `AttestationStillLive(account, asset)` | a clear targeted an element still contributing to NAV                      |
| `NothingToClear()`                     | a clear targeted an `(account, asset)` with nothing cached                 |
| `DuplicateAsset(asset)`                | the same asset appeared twice in one `previewPositions` payload            |

Four more guard the legs a member builds, because the leg builder is the one part of the read a member overrides:

| Guard                        | What it pins                                                                                                                         |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `DebtLegRejected(asset)`     | **direction** — a leg with `isDebt == true`, which the family's credits-only charter forbids                                         |
| `LegAssetMismatch(expected)` | **unit** — `balanceAsset.asset` is the pricing key, so a leg denominated in another asset would inflate quote-NAV by the price ratio |
| `LegSumExceedsValue(asset)`  | **magnitude** — legs must *partition* the attested value, never each carry all of it                                                 |
| `LegAdapterMismatch(asset)`  | **attribution** — `balanceAdapter` must be the adapter itself, the invariant reconciliation keys on                                  |

{% hint style="warning" %}
**These four fire only when a member is buggy — but their effect is consumer-visible.** They revert outside the per-asset isolation, and `PriceFeedLib` fails open on a plain revert, so the adapter's **entire** leg set silently leaves NAV for that account, not just the offending asset. The trigger can be as small as a one-wei rounding error in a member's split.

Diagnostic: if this adapter's contribution vanishes from an account's NAV with no error, call `getAdapterPositions(account, address(0))` directly — the revert NAV swallowed surfaces there. It is a diagnostic, not a data path; NAV reads add the pricing and health layer that a direct call skips. Near frame exhaustion the same fault can instead surface as `AdapterGasExhausted`, which fails the whole account's `getAccountNav` loudly — see [how an adapter is built](/funds/infrastructure/onchain-accounting/balance-adapters.md#how-an-adapter-is-built).
{% endhint %}

***

## Monitoring

| Event                | Emitted on                                                                                                               |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `AttestationCached`  | every `(account, asset)` upsert. Carries the `submitter` for **attribution** — authorization is the role, not this field |
| `AttestationCleared` | one element garbage-collected                                                                                            |
| `AccountCleared`     | an account's whole set garbage-collected                                                                                 |

**Watch the cache cap.** Compare `assetsOf(account).length` against `maxAssetsPerAccount()` to spot an account approaching its cap, whether through ordinary accumulation or through a third party occupying slots. The cap is constant per deployment — no admin can loosen it — so an account at the cap needs garbage collection, not a configuration change.

***

## Registration

From the NAV Calculator's perspective an attested adapter is an ordinary `IBalanceAdapter`, so it is registered like a [plain adapter](/funds/infrastructure/onchain-accounting/balance-adapters.md):

| Action     | Call                                         |
| ---------- | -------------------------------------------- |
| **Add**    | `addBalanceAdapters([adapter])` (MANAGER)    |
| **Remove** | `removeBalanceAdapters([adapter])` (MANAGER) |

One family-specific prerequisite: the keeper must hold `SUBMITTER_ROLE` on the adapter before it can write. A registered adapter with no submitter reports nothing — every account reads `0`, which is the *"nothing cached"* row of the table above.

***

## Members

| Adapter                                                                                      | Protocol | Positions                                                           |
| -------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- |
| [Merkl Rewards](/funds/infrastructure/onchain-accounting/attested-balance-adapters/merkl.md) | Merkl    | Unclaimed rewards per token (`Rewards`), one leg per campaign share |

{% content-ref url="/pages/OIH6BILsiRxKiXkTGlKK" %}
[Merkl Rewards](/funds/infrastructure/onchain-accounting/attested-balance-adapters/merkl.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/attested-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.
