Conversation
…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.
9816b47 to
c75eed0
Compare
salimtb
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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), | ||
| ]), | ||
| ]; | ||
|
|
There was a problem hiding this comment.
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]), | ||
| ], |
There was a problem hiding this comment.
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[] { |
There was a problem hiding this comment.
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) && |
There was a problem hiding this comment.
Two exclusions here:
isEvmAssetOnChainsalso 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-20balanceOfof 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( |
There was a problem hiding this comment.
Two subtleties encoded here:
0counts as empty. The Accounts API cannot distinguish "balance is zero" from "token not indexed", so a returned0is untrusted and triggers an RPC re-read. If the balance really is zero, RPC confirms it and state is corrected either way.- 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.
…ounts-api-balances
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>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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>
…ounts-api-balances
|
Personal comment: Does it goes through the subscribe flow? |

Explanation
The bug
The Accounts API omits tokens it does not index, and
AssetsControllercommits balance responses to state with amergeupdate — anything absent from the response keeps its previous amount. Two failure modes result:response.assetsBalanceentirely — the merge never even runs for it, with the same stale outcome.A returned
0is also indistinguishable from "not indexed", so it can't be trusted as a real zero either.The fix
RpcFallbackMiddleware(which already retries chains listed inresponse.errorson the RPC data source) now handles a second case: after the upstream sources respond, it scans controller state for tracked EVM assets (state.assetsBalanceorstate.customAssets) whose balance in the current response is empty — omitted, or reported as0. Those assets are handed toRpcDataSourceascustomAssets, so the balance fetcher includes them in its multicall and the on-chain amount overwrites the stale one (including a genuine0).Guardrails on what gets re-read:
StakedBalanceDataSource, and a plain ERC-20balanceOfof the share token would clobber them.RpcDataSourcefetches per account and silently drops anything else.The fix is contained entirely in
RpcFallbackMiddleware(plus tests and changelog): the middleware reads state directly viactx.getAssetsState(), soDetectionMiddleware,TokenDataSource, and the pipeline ordering inAssetsControllerare untouched. See the PR comment for how this was consolidated from an earlier 11-file approach.References
Checklist
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.RpcFallbackMiddlewarenow triggers RPC balance reads in two situations: chains already inresponse.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 ascustomAssetsfor 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.assetsMiddlewarenow copies per-chainfetcherrors ontocontext.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.