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

# Assisted balance adapters

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 `delegatecall`s — 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](#the-delegatecall-assistant).

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

{% hint style="info" %}
**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.
{% endhint %}

***

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

{% hint style="success" %}
**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.
{% endhint %}

| 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](/funds/infrastructure/onchain-accounting/balance-adapters.md#how-an-adapter-is-built).

{% hint style="info" %}
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](/funds/infrastructure/onchain-accounting/balance-adapters.md) or [meta](/funds/infrastructure/onchain-accounting/meta-balance-adapters.md)) 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.
{% endhint %}

***

## 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.encode`s 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 **`delegatecall`s**, so it runs in the Safe's own context:

```
Manager Safe ──execTransactionWithRole──▶ Portfolio Safe ──DELEGATECALL──▶ Assistant
                                                                          │  (runs in the Safe's context:
                                                                          │   address(this) == Portfolio Safe)
                                                                          ├──▶ Protocol   (msg.sender = Safe → enters/exits, owns the position)
                                                                          └──▶ Adapter    (msg.sender = Safe → caches the coordinate for the Safe)
```

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.

{% hint style="warning" %}
**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.
{% endhint %}

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`](https://github.com/karpatkey/onchain-accounting/blob/main/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 `delegatecall`ed   |
| `AssistantAccountMismatch(expected, provided)` | an assistant was told to act for an account other than the executing Safe |

***

## How a read works

```
for each cached coordinate (submitted by the account itself):
    re-verify the coordinate still belongs to the account   // live on-chain check; discard if not
    value it from current protocol state                    // live amount, never cached
    emit its position leg(s)                                 // per-item; positionInstanceId carries the coordinate id
```

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](/funds/infrastructure/onchain-accounting/balance-adapters.md):

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

There is no NAV-Calculator-side instance set (that is the [meta-adapter](/funds/infrastructure/onchain-accounting/meta-balance-adapters.md) 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](/funds/infrastructure/onchain-accounting/assisted-balance-adapters/etherfi-withdrawal-queue.md) | Withdrawal `WithdrawRequestNFT`s are not owner-enumerable                         | The request `tokenId` (`submit(uint256)`)          | `EtherFiWithdrawalQueueAssistant` (`requestWithdraw` / `claimWithdraw`)  |
| [Nexus Mutual Staking](/funds/infrastructure/onchain-accounting/assisted-balance-adapters/nexus-mutual-staking.md)          | `StakingNFT`s are not owner-enumerable                                            | The `StakingNFT` `tokenId` (`submit(uint256)`)     | `NexusMutualStakingPoolsAssistant` (`depositTo` / `withdraw`)            |
| [StakeWise V3 Exit Queue](/funds/infrastructure/onchain-accounting/assisted-balance-adapters/stakewise-v3-exit-queue.md)    | Exit positions are opaque `(vault, ticket, timestamp)` tuples with no enumeration | The exit tuple (`submit(address,uint256,uint256)`) | `StakeWiseV3ExitQueueAssistant` (`enterExitQueue` / `claimExitedAssets`) |

{% hint style="info" %}
**Related, non-assisted.** Some queue/LP positions don't need assisting because they *are* discoverable on-chain: [Stader's withdrawal queue](/funds/infrastructure/onchain-accounting/balance-adapters/stader-withdrawal-queue.md) is a [plain adapter](/funds/infrastructure/onchain-accounting/balance-adapters.md) (its per-account request list is enumerable via `getRequestIdsByUser`), and [Uniswap V3](/funds/infrastructure/onchain-accounting/meta-balance-adapters/uniswap-v3.md) / [PancakeSwap V3](/funds/infrastructure/onchain-accounting/meta-balance-adapters/pancakeswap-v3.md) are [meta-adapters](/funds/infrastructure/onchain-accounting/meta-balance-adapters.md) (the NonfungiblePositionManager is `ERC721Enumerable`).
{% endhint %}


---

# 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/assisted-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.
