Skip to content

fix: read balances from RPC when Accounts API returns none - #10061

Merged
salimtb merged 7 commits into
mainfrom
fix/assets-controller-rpc-fallback-empty-accounts-api-balances
Sep 2, 2026
Merged

salimtb merged 7 commits into
mainfrom
fix/assets-controller-rpc-fallback-empty-accounts-api-balances

Conversation

@salimtb

@salimtb salimtb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Explanation

The bug

The Accounts API omits tokens it does not index, and AssetsController commits balance responses to state with a merge update — anything absent from the response keeps its previous amount. Two failure modes result:

  1. Omitted token: the API answers for a chain but leaves out a token it stopped indexing. The merge keeps the old amount in state indefinitely, and no other data source re-reads it because the API already claimed that chain as handled.
  2. Empty result: the API returns no balances for an account at all, so the account is missing from response.assetsBalance entirely — the merge never even runs for it, with the same stale outcome.

A returned 0 is also indistinguishable from "not indexed", so it can't be trusted as a real zero either.

The fix

RpcFallbackMiddleware (which already retries chains listed in response.errors on the RPC data source) now handles a second case: after the upstream sources respond, it scans controller state for tracked EVM assets (state.assetsBalance or state.customAssets) whose balance in the current response is empty — omitted, or reported as 0. Those assets are handed to RpcDataSource as customAssets, so the balance fetcher includes them in its multicall and the on-chain amount overwrites the stale one (including a genuine 0).

Guardrails on what gets re-read:

  • Staking vault assets are excluded — their balances belong to StakedBalanceDataSource, and a plain ERC-20 balanceOf of the share token would clobber them.
  • Non-EVM assets are excluded (RPC can't read them).
  • Only chains that are both in the request and supported by the owning accountRpcDataSource fetches per account and silently drops anything else.
  • Asset IDs are matched case-insensitively against the response, since state keys ERC-20s by checksummed address while some sources return them lower-cased.

The fix is contained entirely in RpcFallbackMiddleware (plus tests and changelog): the middleware reads state directly via ctx.getAssetsState(), so DetectionMiddleware, TokenDataSource, and the pipeline ordering in AssetsController are untouched. See the PR comment for how this was consolidated from an earlier 11-file approach.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Changes balance merge behavior in the fast pipeline; incorrect filtering could leave stale balances or drop valid RPC recovery, but scope is limited to RpcFallbackMiddleware and RpcDataSource error propagation with extensive tests.

Overview
Fixes stale token balances when the Accounts API omits unindexed assets or returns an untrusted 0, because merge updates previously kept the old amount forever.

RpcFallbackMiddleware now triggers RPC balance reads in two situations: chains already in response.errors, and tracked EVM assets in controller state (assetsBalance / customAssets) whose entry in the current response is empty (missing, zero, or case-mismatched). Those assets are sent as customAssets for on-chain multicall, with exclusions for staking vault tokens, non-EVM assets, and chains outside the request or account support.

When merging RPC results, balances for chains where RPC itself failed are stripped before merge so failure stubs (native 0) cannot overwrite good upstream balances or falsely clear errors. RPC errors are only kept for chains that were already errored upstream.

RpcDataSource.assetsMiddleware now copies per-chain fetch errors onto context.response.errors, which the fallback middleware uses to detect failed RPC chains.

Reviewed by Cursor Bugbot for commit 4ddcebe. Bugbot is set up for automated code reviews on this repo. Configure here.

…rns none

The Accounts API omits tokens it does not index, and a `merge` update keeps
the previous amount for anything absent from the response, so those holdings
held a stale balance in state indefinitely. When the API returns an empty
result the account is missing from `assetsBalance` entirely, so the merge
never runs, while the API has already claimed those chains as handled.

DetectionMiddleware now lists tracked EVM assets whose balance is empty in
the current response, and RpcFallbackMiddleware reads them back on chain.
…eware

RpcFallbackMiddleware already has access to controller state, so it can find
tracked EVM assets the upstream response left empty by itself instead of
having DetectionMiddleware relay them through response.detectedAssets. This
removes the pipeline reorder in AssetsController, the spam-filter heal
workaround in TokenDataSource, and the shared upstream-balances util, and
restores the accidentally removed accountTreeInitialized gate.
@salimtb
salimtb force-pushed the fix/assets-controller-rpc-fallback-empty-accounts-api-balances branch from 9816b47 to c75eed0 Compare September 1, 2026 23:09

@salimtb salimtb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline walkthrough of the fix — each comment explains one piece of the change. TL;DR: the Accounts API omits tokens it does not index (and can report an untrusted 0), while state is committed with a merge update, so anything absent from the response silently kept its previous amount forever. This middleware now detects those tracked-but-empty assets and re-reads them on chain.

Object.keys(ctx.response.errors ?? {}) as ChainId[],
);
if (erroredChains.size === 0) {
const staleAssets = collectStaleTrackedAssets(ctx);

@salimtb salimtb Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core of the fix. Previously this middleware only retried chains listed in response.errors. That never covered the stale-balance bug: when the Accounts API answers for a chain but omits a token it stopped indexing (or returns an empty result for the account), there is no error entry , the chain looks handled, the merge state update keeps the old amount, and nothing ever re-reads it.

collectStaleTrackedAssets (below) closes that gap by comparing the response against what state already tracks, instead of trusting the response to be complete.

...staleAssets.map((assetId) => assetId.split('/')[0] as ChainId),
]),
];

@salimtb salimtb Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stale assets' chains are added to the RPC fetch set independently of erroredChains , this is deliberate. A chain can be answered successfully by the Accounts API (so it never appears in response.errors) while still missing a token the API does not index. The set union also dedupes when a chain is both errored and hosts a stale asset.

chainIds: chainsToFetch,
customAssets: [
...new Set([...(ctx.request.customAssets ?? []), ...staleAssets]),
],

@salimtb salimtb Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delivery mechanism: stale assets ride on request.customAssets, which RpcDataSource already includes in its per-chain multicall alongside the native asset (it filters to ERC-20s on the matching chain itself). The on-chain amount then overwrites the stale one via the normal response merge , including a genuine 0, which is exactly the value the Accounts API could not be trusted to report.

Existing customAssets on the request are preserved and deduped. Because this middleware sits after CustomAssetGraduationMiddleware in the fast pipeline, the assets injected here can never trigger graduation.

* @param ctx - Pipeline context.
* @returns Asset IDs to hand to the RPC data source.
*/
function collectStaleTrackedAssets(ctx: Context): Caip19AssetId[] {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads controller state directly via ctx.getAssetsState(), which is what keeps the fix contained to this one file. "Tracked" means the union of state.assetsBalance (assets we hold a balance for) and state.customAssets (user-imported, possibly balance-less) per account.

The chain restriction (supportedChains ∩ request.chainIds) matters: RpcDataSource fetches per account and silently skips chains outside the account's supported set, so anything broader would be queued and then dropped without effect.

isEvmAssetOnChains(assetId, chainsForAccount) &&
// Staked vault balances belong to StakedBalanceDataSource; an RPC
// ERC-20 read of the share token would clobber them.
!isStakingContractAssetId(assetId) &&

@salimtb salimtb Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two exclusions here:

  • isEvmAssetOnChains also filters out non-EVM assets (Solana, Bitcoin, …) , RPC cannot read them, so an empty upstream balance for those is not actionable.
  • Staking vault assets are skipped because their balances are owned by StakedBalanceDataSource, which reads shares via the staking contract. A plain ERC-20 balanceOf of the share token through the fallback would clobber that value.

* @param assetId - Asset ID to check.
* @returns True when the response holds no positive amount for the asset.
*/
function isBalanceEmpty(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two subtleties encoded here:

  1. 0 counts as empty. The Accounts API cannot distinguish "balance is zero" from "token not indexed", so a returned 0 is untrusted and triggers an RPC re-read. If the balance really is zero, RPC confirms it and state is corrected either way.
  2. Case-insensitive matching. State keys ERC-20 assets by checksummed address while some data sources return them lower-cased; an exact-key lookup would false-positive a "missing" balance and cause needless RPC reads. Exact match is tried first so the linear scan only runs on a case mismatch.

@salimtb salimtb changed the title fix(assets-controller): read balances from RPC when Accounts API returns none fix: read balances from RPC when Accounts API returns none Sep 1, 2026
Keep a Changelog requires Changed before Fixed in Unreleased; the merge from
main also left trailing whitespace in the changelog and prettier wants the
for-of destructuring on fewer lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@salimtb
salimtb marked this pull request as ready for review September 2, 2026 08:35
@salimtb
salimtb requested review from a team as code owners September 2, 2026 08:35
Comment thread packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts
@cursor
cursor Bot requested review from Kriys94 and Prithpal-Sooriya September 2, 2026 08:43
juanmigdr
juanmigdr previously approved these changes Sep 2, 2026
RpcDataSource writes a native-0 stub for chains it fails on. Since the
fallback now also fetches chains the upstream source succeeded on (to re-read
stale tracked assets), merging that stub overwrote the correct upstream
native amount and, with replaceCoveredChainBalances, wiped the chain's token
slice from state whenever RPC had a transient failure.

Balances from RPC-failed chains are now dropped before the merge, which also
stops the stub from falsely marking an errored chain as recovered. RPC errors
are kept only for chains that were already errored upstream; a failed
stale-asset re-read leaves the authoritative upstream response untouched and
retries on the next pass.

Reported by cursor bot on #10061.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6d0d431. Configure here.

Comment thread packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts
salimtb and others added 2 commits September 2, 2026 11:16
RpcDataSource.assetsMiddleware read the fetch errors only internally (to
compute successfully handled chains) and never copied them onto
context.response. The failed-chain filter added in 6d0d431 keyed off
rpcResult.response.errors, so it never triggered in production and failure
stubs still merged over good balances.

Reported by cursor bot on #10061.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@salimtb
salimtb added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 869a703 Sep 2, 2026
59 checks passed
@salimtb
salimtb deleted the fix/assets-controller-rpc-fallback-empty-accounts-api-balances branch September 2, 2026 11:06
@Kriys94

Kriys94 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Personal comment: Does it goes through the subscribe flow?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants