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 familyMarker interface:
IAttestedBalanceAdapter
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:
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:
A value the chain can only verify must arrive from outside. Someone has to bring both the figure and its witness.
There is nowhere to put a witness at read time. The NAV read's signature is fixed —
getAccountNav(account, quoteAsset)reaches each adapter throughgetAdapterPositions(account, assetFilter)— and neither has a parameter that could carry proof data.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.
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 —
clearAttestationreverts while the element still contributes to NAV, andclearAccountreverts 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
0rather 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.
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:
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.
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.
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.
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.
What the proof does and does not cover
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.
Error taxonomy
Declared on IAttestedBalanceAdapter itself, so a consumer can decode a revert without importing any member:
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:
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
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.
Monitoring
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:
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
Last updated