Skip to content

fix(assets-controllers): report failed native/staked balance reads as unsuccessful in RpcBalanceFetcher - #10671

Open
Kshot3000 wants to merge 2 commits into
MetaMask:mainfrom
Kshot3000:fix/10043-rpc-balance-fetcher-failed-read-as-zero
Open

Kshot3000 wants to merge 2 commits into
MetaMask:mainfrom
Kshot3000:fix/10043-rpc-balance-fetcher-failed-read-as-zero

Conversation

@Kshot3000

@Kshot3000 Kshot3000 commented Oct 5, 2026 •

Copy link
Copy Markdown

Explanation

Current state: RpcBalanceFetcher (packages/assets-controllers) builds native and staked ProcessedBalance entries for every account it was asked about, hard-coding success: true and substituting new BN('0') whenever the multicall result has no entry for that account/token.

That conflates two different situations. As #10043 documents, the multicall layer records a native entry only for a successful read — a genuine on-chain zero comes back as an explicit BN(0) — and the fallback paths behave the same way. A missing entry can therefore only mean the read failed. Reporting it as a successful zero makes TokenBalancesController forward it to AccountTrackerController.updateNativeBalances, which persists balance = '0x0': one failed RPC read erases the user's previously known balance. The staked branch has the same flaw, with an extra twist: getStakedBalancesForAddresses dropped successful zero-share reads entirely, so "zero shares" and "read failed" were indistinguishable at that layer too.

Solution:

  • rpc-balance-fetcher.ts: a native or staked entry with no recorded balance is now reported as { success: false } with no value — the same shape the ERC-20 branch in the same function already produces for a null balance, and the same shape AccountsApiBalanceFetcher uses for failed per-account fetches. TokenBalancesController already skips unsuccessful entries in all three of its consumers (token balances state, native updates, staked updates), so a failed read now leaves the previously known balance untouched, while a genuine zero (an explicit BN(0) entry) still flows through as a successful zero.
  • multicall.ts: getStakedBalancesForAddresses now records an explicit BN(0) for every address whose shares call succeeded with zero shares. Only failed reads (failed shares call, failed convertToAssets, or a thrown error) leave an address out, which is what makes the fetcher's rule sound. The staked fallback (getStakedBalancesFallback) already recorded an entry per successful read, so both paths now share one contract: entry present ⟺ read succeeded.

Test-suite changes (please read before judging the diff size): TokenBalancesController tests drive the real RpcBalanceFetcher with mocked getTokenBalancesForMultipleAddresses results that omitted native/staked entries while expecting the synthesized zero entries in state. Those mocks now return explicit zero entries where the scenario is a healthy read of a zero balance (their expectations are unchanged). Three tests that simulate failed reads instead had their expectations updated to assert that no zero entries are fabricated: "does not update balances when … multi-account contract failed", "sets balance to 0 … when balance fetcher fails completely" (tracked ERC-20 tokens are still seeded to zero by the controller; native/staking entries are no longer added), and "should handle missing staked balances gracefully". One event test was renamed to match its new premise (a returned zero staked balance).

Verification (all local, per AGENTS.md TDD):

  • New/updated fetcher + multicall tests failed before the fix (5 failures: native absence, native partial failure, staked absence, both multicall zero-share shapes) and pass after.
  • Full @metamask/assets-controllers suite: 45/45 suites, 1810/1810 tests pass.
  • tsc --build packages/assets-controllers/tsconfig.build.json: clean.
  • oxlint on all touched files: 0 errors. The new fetcher tests use the same patterns as the existing ones in that file (e.g. calls on jest.requireMock results), so the file's counts in oxlint-suppressions.json were refreshed (17→24 id-length, 13→16 no-unsafe-call, 21→24 no-unsafe-member-access).
  • oxfmt --check on touched files: clean. Changelog entry added under [Unreleased] → Fixed.

This PR was prepared with AI assistance; all changes were verified by running the test suites, type build, and linters above.

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 — code comments explain the new contract at both change sites; no public API or exported type changed
  • I've communicated my changes to consumers by updating changelogs for packages I've changed — @metamask/assets-controllers CHANGELOG [Unreleased] → Fixed
  • I've introduced breaking changes in this PR — none: no exported signatures change; the only behavioral change is the bug fix itself (failed reads no longer masquerade as zero balances)

Note

Medium Risk
Changes balance-fetch semantics for native and staked assets, which affects what users see when RPC reads fail; behavior is intentionally stricter and should preserve prior balances instead of clearing them.

Overview
Fixes a bug where failed native or staked RPC/multicall reads were treated as successful zero balances, which could wipe a user’s last known balance in the wallet.

RpcBalanceFetcher now reports missing native or staked entries as { success: false } (no value), aligned with ERC-20 handling and explicit BN(0) for real zeros. getStakedBalancesForAddresses now includes an explicit zero for each address whose shares read succeeded with zero shares, so “no entry” means failure only.

Tests and changelog are updated: mocks that meant “healthy zero” return explicit zeros; scenarios that simulate failed reads no longer expect fabricated native/staking zeros in TokenBalancesController state.

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

… unsuccessful in RpcBalanceFetcher

RpcBalanceFetcher hard-coded success: true with a zero value for native
and staked entries whose balance was missing from the multicall result.
A missing entry can only mean the read failed (successful reads record
an entry, including an explicit BN(0) for a genuine zero), so a failed
read was indistinguishable from a confirmed zero balance and overwrote
the previously known balance with 0x0 via TokenBalancesController and
AccountTrackerController.

- Native and staked entries with no recorded balance are now reported
  with success: false and no value, matching the ERC-20 branch and the
  AccountsApiBalanceFetcher failure shape.
- getStakedBalancesForAddresses now records an explicit BN(0) for
  addresses whose shares were read successfully and are zero, so a
  genuine zero staked balance stays distinguishable from a failed read.
- Tests: new/updated regression coverage in the fetcher and multicall
  suites; TokenBalancesController mocks updated to the explicit-zero
  contract (three failure-simulation tests now assert no fabricated
  zero entries); oxlint suppression counts refreshed for the fetcher
  test file.

Fixes MetaMask#10043
@Kshot3000
Kshot3000 requested review from a team as code owners October 5, 2026 06:16

This branch has not been deployed

No deployments
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.

fix: RpcBalanceFetcher reports a failed native balance read as success:true, value:0 (zeroes real balances)

1 participant