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

Assisted balance adapters

Adapters for positions that can't be discovered on-chain — the position owner feeds the coordinates itself, on-chain and permissionlessly, valued live.

A handful of positions cannot be discovered or located purely from on-chain state — they live behind a non-enumerable NFT (an ether.fi withdrawal-request WithdrawRequestNFT, a Nexus StakingNFT) or an opaque exit-queue ticket (StakeWise V3) with no owner→positions view. For these, an assisted adapter keeps a small cache of position coordinates; everything else — the amounts, the pricing — is still read live on every NAV query.

The coordinates are fed permissionlessly and on-chain by the position owner itself: the mutators are keyed on msg.sender, so a caller can only ever touch its own set. The canonical feeder is a stateless assistant contract the owner Safe delegatecalls — a thin wrapper you use in place of the protocol's own deposit/withdraw call. It performs that same protocol operation and, in the same transaction, records (or removes) the resulting position's coordinate on the adapter, so the otherwise-invisible position is tracked by NAV. See The delegatecall assistant.

Permissionless — no roles. The family has no roles and no AccessControl. Because the invariant is upheld by live re-verification alone (below), a role would add nothing: a caller can only ever touch its own coordinates, and a faulty feeder can only under-report. Each account feeds its own coordinates.


Trust model

The family invariant makes the feeder minimally trusted: it caches only coordinates, never amounts.

  • Every read re-values the position live from on-chain state and re-verifies ownership.

  • So a faulty, stale, or hostile feeder can only under-report (omit a position) — never over-report or fabricate value.

  • Self-scoped by msg.sender. The submit / remove / clearAccount mutators take no account argument and derive it from msg.sender, so a caller can only ever add or remove its own coordinates. Nobody can touch another account's set — which is why the family needs no roles at all.

  • If nobody feeds an account's coordinates, its positions read as 0 until they're submitted; NAV under-counts but is never inflated.

Level
Property
Enforced by

Contract (this system)

cannot over-report

the coordinate cache plus each adapter's live valuation and ownership re-check

Roles (the Safe's permissions)

cannot under-report either

the roles-modifier allow-list

  • Each cached coordinate is valued in isolation: a single position that reverts while being priced (e.g. Nexus's revert-prone StakingViewer.getTokens) discards only that coordinate, never the account's whole set for the adapter — the base wraps each coordinate's valuation in a per-item self-staticcall + try/catch (fail-open). A coordinate that instead runs out of gas is not absorbed: the fan-out raises CoordinateGasExhausted(adapter, coordId, stipend, consumed), because a starved read is not evidence of an empty position and silently dropping it would understate the account. See a starved read is not an empty read.

This is the one sanctioned exception to the "adapters are immutable, view-only" rule — an assisted adapter holds a per-account coordinate cache and exposes submit/remove mutators. Every other adapter (plain or meta) is a pure view reader the NAV Calculator invokes with staticcall. The read path here is still pure view; only the coordinate cache is written.


The coordinate cache — AssistedCoordCache

One base serves every member. A position coordinate is an opaque bytes blob (abi.encode(...)), so the same cache handles both shapes the family uses today:

Coordinate shape

abi.encode(...)

Members

A bare uint256 (NFT tokenId / withdrawal requestId)

abi.encode(id)

ether.fi, Nexus

A tuple

abi.encode(vault, positionTicket, timestamp)

StakeWise V3 exit

The base provides the msg.sender-keyed CRUD (blobs deduped by keccak256, with an O(1) swap-pop removal), the single-underlying-asset IBalanceAdapter getter surface, and the per-coordinate fail-open valuation fan-out. Each concrete adapter adds a typed submit / remove that abi.encodes its coordinate and implements the protocol-specific hooks: _valueCoord (decode + value live), _validateCoord (write-time hygiene), _removable (liveness proxy), _coordId, _maxCoords, _maxLegsPerCoord.

Write semantics — fail-loud

Writes are fail-loud and atomic; correctness never depends on them (reads re-verify regardless), but they keep the cache clean:

On submit(coord)

Reverts

Coordinate not currently owned by the caller

CoordinateNotOwned(account, id)

Coordinate fails the adapter's protocol validation (e.g. unregistered target)

InvalidCoordinate(account, id)

Cache already at the per-account cap

CoordinateCacheFull(account, maxCoordinates)

Coordinate already cached

(no-op — idempotent)

Ownership is re-validated on every submit, before the idempotent early-return, so resubmitting a coordinate the caller has since transferred away is rejected loud rather than silently kept.

On remove(coord)

Reverts

Coordinate not in the caller's cache

CoordinateNotCached(account, id)

Coordinate still resolves to a live position

CoordinateStillLive(account, id)

remove is deliberately refused while a position is live — only a claimed/consumed coordinate can be dropped, so a live position is never silently un-reported. (This is what the assistants rely on: they claim/withdraw first, then remove.)

clearAccount() wipes all of the caller's coordinates unconditionally — it does not honour the CoordinateStillLive guard, so any still-live positions stop being reported until re-submitted. Like everything else in the family it only ever under-reports the caller's own NAV; it is self-service GC, not a NAV-neutral operation.


The delegatecall assistant

An assistant is a thin wrapper around the protocol's own entry/exit functions — the channel you use to open or close one of these positions. Normally you would interact with the protocol directly: stake into a Nexus Mutual pool via the pool's depositTo, enter ether.fi's withdrawal queue via the LiquidityPool's requestWithdraw, enter StakeWise's exit queue via the vault's enterExitQueue. The catch is that the resulting position — a non-enumerable NFT, an opaque queue ticket — is then invisible to NAV: nothing on-chain links it back to the account, so an assisted adapter has no way to find it.

The assistant closes that gap. You run the exact same operation through the assistant instead of the protocol contract: it forwards the identical call to the protocol and, in the same transaction, records the resulting position's coordinate on the assisted adapter — so the position is tracked and priced by NAV from the moment it opens, and untracked when it closes. Same protocol action, plus automatic bookkeeping. This pairing — a wrapper that performs the action and an adapter that reports it — is the fundamental use case of the assistant/assisted family.

Mechanically, the adapter's cache is keyed on msg.sender, so whoever calls submit/remove is the account being tracked. The assistant is a stateless module (BalanceAdapterAssistant base) the owner Safe delegatecalls, so it runs in the Safe's own context:

Under delegatecall, address(this) is the Safe, so the protocol sees msg.sender = Safe (the position owner) and the adapter's msg.sender-keyed cache records against that same Safe — no roles, no custody, no asset-forwarding. The deposit/withdrawal and the cache update happen atomically in one transaction, so there is no staleness window.

Each assistant contributes these pieces from its base:

  • SELF / ADAPTER — its own deployed address (pinned at construction) and the adapter it feeds, both immutable (readable even under delegatecall, since they live in the assistant's bytecode).

  • onlyDelegateCall — a direct (non-delegatecall) call has address(this) == SELF and is rejected fail-loud with the canonical NotDelegateCall, rather than letting the protocol revert obscurely.

  • _requireSelf(account) — for assistants whose forwarded protocol call carries an explicit receiver/owner, asserts it equals address(this) (the Safe); reverts AssistantAccountMismatch otherwise.

  • Best-effort untrack — a withdraw/claim entrypoint forwards to the protocol first, then tries to remove the coordinate. Because removal must never block the underlying operation, the assistant swallows the two expected cache-state reverts — CoordinateStillLive (a partial op left the position live → keep it tracked) and CoordinateNotCached (nothing to untrack) — and re-throws anything else. This is safe: a coordinate left cached only ever under-reports, since reads re-verify and re-value live.

Anyone may also call submit/remove directly (e.g. to register a pre-existing position, or from a bespoke integration), since the mutators are permissionless — the assistant is just the ergonomic, atomic path.


Error taxonomy

Shared across the family, defined in src/errors.sol:

Error
Raised when

CoordinateNotOwned(account, id)

submit given a coordinate the caller doesn't own

CoordinateCacheFull(account, max)

submit would grow the caller's cache past its cap

CoordinateNotCached(account, id)

remove targeted a coordinate not in the caller's cache

CoordinateStillLive(account, id)

remove targeted a coordinate that's still a live position

InvalidCoordinate(account, id)

submit failed the adapter's write-time protocol validation

NotDelegateCall()

an assistant entrypoint was called directly instead of delegatecalled

AssistantAccountMismatch(expected, provided)

an assistant was told to act for an account other than the executing Safe


How a read works

The cache only answers "which positions might this account have?" — a question that has no on-chain view function for these protocols. The value of each position is always derived fresh, and each coordinate is valued behind its own self-staticcall so one reverting position never drops the rest.


Registration

From the NAV Calculator's perspective an assisted adapter is an ordinary IBalanceAdapter, so it is registered like a plain adapter:

Action
Call

Add

addBalanceAdapters([adapter]) (MANAGER)

Remove

removeBalanceAdapters([adapter]) (MANAGER)

There is no NAV-Calculator-side instance set (that is the meta-adapter model). The coordinate cache is kept current by the position owner's own transactions — typically via the paired assistant, delegatecalled inside the same deposit/withdraw operation.


Catalogue

Adapter
Why it needs assisting
Coordinate the owner submits
Paired assistant

ether.fi Withdrawal Queue

Withdrawal WithdrawRequestNFTs are not owner-enumerable

The request tokenId (submit(uint256))

EtherFiWithdrawalQueueAssistant (requestWithdraw / claimWithdraw)

Nexus Mutual Staking

StakingNFTs are not owner-enumerable

The StakingNFT tokenId (submit(uint256))

NexusMutualStakingPoolsAssistant (depositTo / withdraw)

StakeWise V3 Exit Queue

Exit positions are opaque (vault, ticket, timestamp) tuples with no enumeration

The exit tuple (submit(address,uint256,uint256))

StakeWiseV3ExitQueueAssistant (enterExitQueue / claimExitedAssets)

Related, non-assisted. Some queue/LP positions don't need assisting because they are discoverable on-chain: Stader's withdrawal queue is a plain adapter (its per-account request list is enumerable via getRequestIdsByUser), and Uniswap V3 / PancakeSwap V3 are meta-adapters (the NonfungiblePositionManager is ERC721Enumerable).

Last updated