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.
Base class:
AssistedCoordCache— the single cache base for the whole familyMarker interface:
IAssistedBalanceAdapter
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. Thesubmit/remove/clearAccountmutators take noaccountargument and derive it frommsg.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
0until they're submitted; NAV under-counts but is never inflated.
For the portfolio Safes the cache is complete by construction, not merely conservative — so the under-report above is the worst case in the abstract, not the operating reality. Two guarantees compose, at different levels:
The Safes' roles-modifier permits these protocol entries only through the assistant contracts — the protocol's own entrypoint is not in the allow-list. A position of one of these types therefore cannot be opened without its coordinate being fed to the adapter in the same transaction. Note the second guarantee lives in the Safe's permission configuration, not in these contracts: it holds for the governed portfolio Safes, and an arbitrary account feeding its own coordinates has only the contract-level guarantee.
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 raisesCoordinateGasExhausted(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 hasaddress(this) == SELFand is rejected fail-loud with the canonicalNotDelegateCall, rather than letting the protocol revert obscurely._requireSelf(account)— for assistants whose forwarded protocol call carries an explicitreceiver/owner, asserts it equalsaddress(this)(the Safe); revertsAssistantAccountMismatchotherwise.Best-effort untrack — a withdraw/claim entrypoint forwards to the protocol first, then tries to
removethe 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) andCoordinateNotCached(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.
EOAs cannot use an assistant — an EOA cannot delegatecall. The intended feeders are Safe accounts, and which Safe may run an assistant is governed at the Safe/Zodiac Roles layer (a role permitting a delegatecall to the assistant), not by the adapter. The adapter itself stays permissionless.
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:
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:
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
Withdrawal WithdrawRequestNFTs are not owner-enumerable
The request tokenId (submit(uint256))
EtherFiWithdrawalQueueAssistant (requestWithdraw / claimWithdraw)
StakingNFTs are not owner-enumerable
The StakingNFT tokenId (submit(uint256))
NexusMutualStakingPoolsAssistant (depositTo / withdraw)
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