Skip to content

fix(stellar): report an unknown balance for never-synced accounts instead of zero - #524

Draft
rvelaz wants to merge 2 commits into
mainfrom
fix/stale-balances-06-xlm-no-fake-zero
Draft

rvelaz wants to merge 2 commits into
mainfrom
fix/stale-balances-06-xlm-no-fake-zero

Conversation

@rvelaz

@rvelaz rvelaz commented Oct 26, 2026 •

Copy link
Copy Markdown

Draft. Manifest shasum not yet regenerated, so snap checks may fail.
Updated on top of #331 by a merge from main (no rebase) and re-tested: stellar 94 suites / 1505 tests pass, tsc --noEmit clean.

  • Package: @metamask/stellar-wallet-snap

Explanation

Bug

A Stellar account the snap never managed to sync shows 0 XLM. The snap also reads an empty-body Horizon 404 (what the coalesced connection returns) as "account not found", and so confirms an unfunded, zero-balance account.

How to reproduce

  1. Build extension release/13.54.0 (acf000d) with yarn build:dev. For the "after" case, also build this branch and copy its dist/bundle.js, snap.manifest.json and preinstalled-snap.json into node_modules/@metamask/stellar-wallet-snap. To see the wallet-side "Balance unavailable" + Retry, add core PR 10 and extension PR 12.
  2. Load it unpacked in Chrome or Chromium with default settings (HTTP/2 on). This is profile A.
  3. Import a test wallet that holds BTC, TRX, XLM and SOL (we used a team test wallet). Onboarding creates the Bitcoin, Tron, Stellar and Solana accounts.
  4. Let the snaps' requests coalesce. In Chrome, a request to stellar-mainnet.infura.io can reuse the HTTP/2 connection already open to another *.infura.io host (in our runs the Solana snap usually opened it first). The reply is an empty 404 without CORS headers, so the snap sees Failed to fetch. We reproduced this on a test machine where every *.infura.io name resolved to the same IP. It depends on the network path and may not reproduce on every network. In our runs we moved the shared connection between hosts on purpose: we dropped the test browser's sockets while briefly blocking the other Infura hosts, which "heals" one chain and breaks the others.
  5. Open the home screen while the stellar requests fail, then click Retry on the Stellar row.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: while the account was never synced, the balance is unavailable (not 0), Retry starts a real sync, and the not-synced state is not sent to Sentry.
Actual (before this change): XLM shows 0 (unfixed A: 0 in all 17 snapshots over 15 min, and after a reload).

Without a browser: keyring.test.ts › getAccountBalances › throws AccountBalanceUnavailableException when the account was never synchronized, and utils.test.ts › isAccountNotFoundError › returns false for an empty-body 404 (""), which does not come from Horizon.

Summary

getAccountBalances no longer returns a confident native 0 for an account the snap has never synced. It now throws AccountBalanceUnavailableException, an expected state that is never sent to Sentry, and schedules a deduplicated sync, so the wallet's Retry triggers a real sync. An empty-body Horizon 404 is no longer treated as "account not found".

Why

In Chrome, HTTP/2 connection coalescing across *.infura.io can send the stellar snap's Horizon request over a connection opened for another Infura host. The provider router answers with an empty 404 and no CORS headers, which shows up as Failed to fetch or as an empty 404. Two code paths then turned that into a fake zero:

  1. getDefaultBalanceEntry returned native 0 when the account was "not activated or not yet synced", so a user whose first sync failed saw 0 XLM.
  2. isAccountNotFoundError treated any Horizon NotFoundError as "unfunded", including the router's empty 404.

What changed

  • handlers/keyring/keyring.ts, exceptions.ts: when there is no snapshot and the account is not confirmed unfunded, throw AccountBalanceUnavailableException.
    • The exception is flagged isExpectedState.
    • Before throwing, it schedules a SynchronizeAccounts background event for the account, at most once per 30 s per account. A Retry therefore causes a real sync instead of re-reading the empty state.
  • services/on-chain-account/*, services/sync/SynchronizeService.ts: sync persists a confirmed-unfunded marker when Horizon confirms the account does not exist (AccountNotActivatedException), and emits a native 0 once. Only a confirmed-unfunded account reports 0.
  • services/network/utils.ts: new isEmptyHttpBody. isAccountNotFoundError returns false for a NotFoundError with an empty body.
  • utils/errors.ts: shouldTrackError skips expected states and user rejections. The expected-state check survives the keyring SDK's re-serialisation, which turns the error into a plain Error. index.test.ts covers the end-to-end snap_trackError path.
  • Tests: keyring, repository, service, sync, utils, errors and index.

Validation

Checked locally: the test files changed in this PR were run with the parent's source (tests from this branch, every other file from the parent), then on this branch.

Unit tests that fail before and pass after (parent: 80b21f6)

  • KeyringHandler › getAccountBalances › throws AccountBalanceUnavailableException when the account was never synchronized, returns zero native balance when the account is confirmed unfunded on-chain, and the 4 tests under on-demand sync for never-synchronized accounts (Retry schedules one deduplicated sync)
  • isAccountNotFoundError › returns false for an empty-body 404 ("") and (undefined), which does not come from Horizon; the 7 isEmptyHttpBody cases
  • OnChainAccountRepository (8), OnChainAccountService (3) and SynchronizeService (3): the confirmed-unfunded marker, so that a real unfunded account still shows 0
  • errors › shouldTrackError › 4 tests and entrypoints › ... › is re-created by handleKeyringRequest as a plain Error, yet not tracked
  • In total, 34 tests fail on the parent, and 108 pass.

Full run on this branch

  • jest --runInBand in packages/stellar-wallet-snap: 93 suites, 1476 tests pass.
  • tsc --noEmit (package): clean.

Manual A/B

A = Chrome with default HTTP/2 (reproduces the coalescing); B = Chrome with --disable-http2 (control, no coalescing). Every run used the same test wallet and a local yarn build:dev of extension 13.54.0 (acf000d), with the snaps and assets-controller wired into node_modules by hand. "Unfixed 13.54.0" is that build with no fixes. The fixes-vN builds are cumulative: each one has every fix up to that pass, so an "after" number shows this PR together with the others in that build. Run names (A-v6, B-v7, ...) are our local test passes on those builds; times are CEST.

Check Before After
A: XLM shown while stellar requests fail Unfixed 13.54.0, A: 0 in all 17 snapshots and after a reload fixes-v6 build, A-v6: never 0. The state was "has not been synchronized yet" (Balance unavailable + Retry) from 22:47 to 22:52, then XLM 18.4010064 at 22:52:53 with no click
A: Retry sends a real sync fixes-v1 build, A: the click sent no requests fixes-v2 build, A-v2: click 10:33:51 → stellar horizon/accounts 10:33:54 → XLM 18.4010064 by 10:34:09
A+B: 'not been synchronized' snap_trackError (stellar) fixes-v2 build (state existed but was still tracked): B-v2 8 unique, A-v2 20 unique fixes-v3 to v7 builds: 0 in every A and B run
B: XLM (control) Unfixed 13.54.0, B: 18.401 from the first snapshot B-v4 to B-v7: 18.4010064 at the first dump (≤ 9 s)

Risks

  • Throwing instead of 0 means a never-synced account shows "Balance unavailable" until its first sync succeeds. That is intended. It needs the client side (PR 10/12) to render it well; older clients keep the previous value or show nothing.
  • Send validation reads persisted balances. This PR does not change persisted data for synced accounts, and a never-synced account has none to begin with.
  • The 30 s per-account sync dedupe is held in memory.

Changelog (packages/stellar-wallet-snap/CHANGELOG.md, Unreleased → Fixed)

  • Report an unknown balance (an untracked "balance unavailable" error) for accounts that have never been synced, instead of 0 XLM, and start a sync when the balance is requested. An empty-body Horizon 404 is no longer treated as an unfunded account (#524)

References

Dependencies

  • None in internal-snaps. PR 07 (stellar requests + staleness) stacks on this branch.
  • Wallet side: PR 10 (core) and PR 12 (extension) for the unavailable state + Retry.
  • Follow-up: regenerate the snap.manifest.json shasum with yarn workspace @metamask/stellar-wallet-snap build. It is not included here.

Stale non-EVM balances series (Chrome HTTP/2 coalescing across *.infura.io):

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

…tead of zero

- getAccountBalances throws AccountBalanceUnavailableException (an expected state, not sent to snap_trackError) when the account has no snapshot and is not confirmed unfunded, and schedules a deduplicated SynchronizeAccounts (at most once per 30 s per account), so Retry triggers a real sync.
- Sync persists a confirmed-unfunded marker (AccountNotActivatedException) and emits a native 0 once.
- An empty-body Horizon 404 is no longer read as 'account not found': it is a transport failure (e.g. a provider router rejecting a mis-routed, HTTP/2-coalesced request), not a confirmed unfunded account.
…ke-zero

# Conflicts:
#	packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts
#	packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts
#	packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts

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.

1 participant