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

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.


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 / meta

nothing

read the value live, every time

Assisted

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.

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


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

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.


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.decodes the opaque bytes into its own shape.

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.


How a read works

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

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.


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.

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.


What the proof does and does not cover


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)

unitbalanceAsset.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)

attributionbalanceAdapter must be the adapter itself, the invariant reconciliation keys on


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:

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

Merkl

Unclaimed rewards per token (Rewards), one leg per campaign share

Merkl Rewards

Last updated