Skip to content

fix(stellar): run account reads under the fetch policy and report stale balances as unavailable - #525

Closed
rvelaz wants to merge 2 commits into
fix/stale-balances-06-xlm-no-fake-zerofrom
fix/stale-balances-07-xlm-requests-staleness
Closed

rvelaz wants to merge 2 commits into
fix/stale-balances-06-xlm-no-fake-zerofrom
fix/stale-balances-07-xlm-requests-staleness

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 (via the updated 06 branch, no rebase) and re-tested: stellar 94 suites / 1529 tests pass, tsc --noEmit clean.

  • Package: @metamask/stellar-wallet-snap

Explanation

Bug

When stellar syncs start failing after a successful one, XLM keeps showing the last amount as if it were current. Horizon and Soroban reads also have no bounded retry, so one transient failure fails the whole sync.

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. Wait until XLM is correct, then break the stellar connection (move the shared connection to another Infura host), and watch the Stellar row for 3–4 minutes.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: about 90 s after the last successful sync (plus the next poll), XLM shows "Balance unavailable" + Retry, then recovers within seconds of the heal. There is no Sentry event for the stale state.
Actual (before this change): XLM keeps the old amount. In fixes-v5 A-v5 it was still shown 3.5 min after its requests started failing.

Without a browser: SynchronizeService.test.ts › sync health for stale-balance detection (v6) › marks the balance stale after ~90 s of failed syncs and clears it on the next success (real service), and NetworkService.test.ts › read retries › treats an empty-body 404 as a network failure, not a missing account.

Summary

Two changes that make XLM fail in a visible way instead of going quietly stale:

  1. Requests: Horizon loadAccount and Soroban RPC getAccount now run under the shared createFetchPolicy (feat(snap-networks-utils): add shared createFetchPolicy in networks utils #462): retry with backoff on transient failures, plus a circuit breaker. A confirmed missing account is never retried. An empty-body 404 counts as a transport failure.
  2. Staleness: when syncs have been failing for more than 90 s since the last success, getAccountBalances throws AccountBalanceStaleException (expected state, untracked) and asks for a sync. The wallet then shows "Balance unavailable" + Retry instead of an old amount that looks current. The error clears on the next successful sync.

Why

Chrome's HTTP/2 connection coalescing across *.infura.io can route the stellar snap's requests over a connection that belongs to another Infura host. The replies are empty 404s without CORS headers, so the snap sees Failed to fetch. Before this PR:

  • one failed read failed the whole sync, with no retry;
  • after a successful sync, every later failure kept the last snapshot. getAccountBalances kept returning it, and the wallet showed the old amount with no sign that it was stale. In A-v5, BTC/XLM still showed balances 3.5 min after every request started failing.

What changed

  • services/network/NetworkService.ts: #read() runs each account read under a per-scope fetch policy (stellar_horizon, stellar_rpc) and rethrows the original SDK error, via unwrapFetchPolicyError, so the existing classification (AccountNotActivatedException, NetworkServiceException) is unchanged.
  • services/network/utils.ts: toStellarFetchPolicyError maps errors to policy decisions:
    • confirmed missing account → 404, not retried;
    • Horizon error with a numeric status → that status, so 5xx and 429 are retried;
    • anything else, including an empty 404 → network failure.
  • snap.config.ts + package.json (webpack devDependency) + yarn.lock: ProvidePlugin supplies @metamask/snap-networks-utils/performance-shim, because the policy uses performance.now() and snaps don't have it.
  • Staleness:
    • OnChainAccountRepository stores syncFailingSince per keyring account, written only on transitions.
    • OnChainAccountService.recordSyncOutcome/isBalanceStale keeps the last success in memory per id:scope.
    • SynchronizeService records the outcome of each account sync.
    • The keyring throws AccountBalanceStaleException and requests an on-demand sync through PR 06's 30 s dedupe.
    • utils/errors.ts keeps it untracked.
  • Tests: NetworkService retry tests (transient retry; a confirmed 404 is not retried; an empty 404 is a network failure), utils, repository, service, sync, keyring and errors.

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: PR 06)

  • KeyringHandler › getAccountBalances › stale balance after failing syncs (v6) › throws the untracked AccountBalanceStaleException and schedules a sync when the balance is stale, answers from the snapshot when the balance is not stale, also fails for a confirmed-unfunded account whose syncs keep failing
  • SynchronizeService › sync health for stale-balance detection (v6) › 4 tests, including marks the balance stale after ~90 s of failed syncs and clears it on the next success (real service)
  • OnChainAccountService › recordSyncOutcome / isBalanceStale (v6) › 3; OnChainAccountRepository › markSyncFailing / ... 3
  • NetworkService › read retries › retries a transient Horizon failure on the primary endpoint, treats an empty-body 404 as a network failure, not a missing account
  • toStellarFetchPolicyError (4) and unwrapFetchPolicyError (2)
  • errors › shouldTrackError › does not track the stale-balance state, even re-created or wrapped (v6)
  • In total, 22 tests fail on the parent, and 201 pass.

Full run on this branch

  • jest --runInBand in packages/stellar-wallet-snap: 93 suites, 1499 tests pass.
  • tsc --noEmit (package): clean.
  • eslint on the hand-edited files only reports the pre-existing, suppressed path import in snap.config.ts.

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 after its connection breaks fixes-v5 build, A-v5: still showing the balance 3.5 min after 22:05:27, with all requests failing fixes-v6 build, A-v6: "synchronization has been failing since the last successful sync" at 22:55:16, 124 s after the last success (UI: Balance unavailable + Retry); fixes-v7, A-v7: 123 s
A: recovery after the heal — A-v6: heal 22:57:42 → correct by 22:57:53; A-v7: within 11 s; no click
A: stale-state trackErrors — A-v6: 0 AccountBalanceStaleException
B: stellar snap_trackError (control) B-v5: 0 B-v6, B-v7: 0; XLM correct at the first dump

Risks

  • Retries add a few requests during an outage. The circuit breaker bounds them, and its open state rejects locally.
  • The 90 s threshold is a constant. A slow but healthy endpoint never trips it, because only failed syncs count.
  • The last-success time is held in memory: after a snap restart, an account counts as stale only once a new success-then-failure sequence happens. That is conservative.

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

  • Changed: Retry Horizon and Soroban RPC account reads with backoff and a circuit breaker using the shared fetch policy from @metamask/snap-networks-utils (#525)
  • Fixed: Report a balance as unavailable (untracked) when syncs have kept failing for more than 90 s since the last successful sync, instead of returning the stale balance (#525)

References

Dependencies

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

…le balances as unavailable

- loadOnChainAccount (Horizon) and getAccount (Soroban RPC) run under createFetchPolicy from #462: retries with backoff on transient failures and a circuit breaker. A confirmed missing account is a non-retryable 404; an empty-body 404 or a bare 'Failed to fetch' is a network failure. The original SDK error is rethrown so callers keep classifying it. The snap bundle provides the performance shim the policy needs.
- A balance is stale when the last successful sync is older than 90 s and the syncs since then have failed. getAccountBalances then throws AccountBalanceStaleException (expected state, untracked) and requests an on-demand sync through the existing 30 s dedupe. The failure-streak start is persisted per account only on transitions; the error clears on the next successful sync.
…ests-staleness

# 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
@rvelaz rvelaz closed this Oct 28, 2026
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