Skip to content

fix(bitcoin): guard Esplora reads with the shared fetch policy and timeouts - #520

Closed
rvelaz wants to merge 1 commit into
fix/stale-balances-01-btc-no-fake-zerofrom
fix/stale-balances-02-btc-requests
Closed

rvelaz wants to merge 1 commit into
fix/stale-balances-01-btc-no-fake-zerofrom
fix/stale-balances-02-btc-requests

Conversation

@rvelaz

@rvelaz rvelaz commented Oct 26, 2026 •

Copy link
Copy Markdown

Draft. Manifest shasum not yet regenerated, so snap checks may fail.
Built and tested on 80b21f6; main is one commit ahead (#331, stellar only) and git merge-tree shows no conflicts.

  • Package: @metamask/bitcoin-wallet-snap

Explanation

Bug

Esplora reads in the bitcoin snap have no bounded retry and no overall timeout. A transient failure fails the whole scan or sync at once, and a request that never settles can keep the account's sync guard busy, so its balance stops refreshing. This PR is groundwork. On its own it changes no visible number; it makes the scan logic in PR 03 dependable.

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/bitcoin-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 bitcoin-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. Watch the bitcoin snap's Esplora requests (DevTools on the snap's offscreen document, or CDP). Before: a failed read is not retried, and nothing bounds how long a full scan or sync can take. After: reads go through the fetch policy (one retry for scans, bounded backoff for REST), a sync is capped at 60 s and a full scan at 120 s, and an open circuit short-circuits further reads.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: transient failures are retried with backoff, and a stalled scan or sync fails after its timeout so the next one can run.
Actual (before this change): there is no retry beyond the WASM client's own, and no operation timeout.

Without a browser: EsploraClientAdapter.test.ts › operation timeouts › fails a full scan whose requests never settle after FULL_SCAN_TIMEOUT_MS reproduces a stalled request with fake timers.

Summary

Every Esplora read (full scan, sync, REST sender lookups, fee estimates) now runs under the shared createFetchPolicy from @metamask/snap-networks-utils (#462): bounded retries with backoff on transient failures, and a circuit breaker. A broadcast is never retried. A whole sync is capped at 60 s and a full scan at 120 s.

Why

In Chrome, HTTP/2 connection coalescing across *.infura.io can send the snap's Esplora request over a connection opened for another Infura host. The provider router answers with an empty 404 and no CORS headers, so the request fails with Failed to fetch. Before this PR:

  • a single transient failure failed the whole scan or sync, with no retry;
  • the WASM EsploraClient sets no timeout. A request stalled by a suspended or broken connection could stay pending forever. The cron coalesces syncs, so every later cron run joined the stuck sync and the account never refreshed.

What changed

  • infra/EsploraClientAdapter.ts:
    • one endpoint record per network (WASM client, REST base URL, scanPolicy with maxRetries: 1 because the WASM client already retries each request, and restPolicy);
    • #read() runs every read under its policy;
    • withOperationTimeout() rejects with a TimeoutError, which the policies treat as transient. SYNC_TIMEOUT_MS = 60_000, FULL_SCAN_TIMEOUT_MS = 120_000. A timed-out result is discarded, never applied to the wallet;
    • broadcast goes straight to the client: no retry, which could broadcast twice.
  • src/index.ts: passes the logger to the adapter (policy warnings).
  • snap.config.ts, package.json, yarn.lock: a ProvidePlugin maps performance to @metamask/snap-networks-utils/performance-shim. cockatiel times calls with performance.now(), which snaps don't have. Adds the webpack: 5.107.2 devDependency for the plugin import.
  • Tests: scan retry, give-up, and broadcast-never-retried behaviour; withOperationTimeout and sync/full-scan timeouts with fake timers (a stalled scan settles after 120 s, not 60 s, and applies nothing); REST sender lookups retried under the policy (timeouts, transient vs non-retryable non-2xx, cache eviction).

Validation

Checked locally: the test files changed in this PR were run with the parent branch'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 01)

  • EsploraClientAdapter › operation timeouts › fails a full scan whose requests never settle after FULL_SCAN_TIMEOUT_MS, fails a sync whose request never settles after SYNC_TIMEOUT_MS, without applying anything, plus the 2 withOperationTimeout tests
  • EsploraClientAdapter › read retries › retries a scan that fails with a network error once, fails once the retry also fails with a network error
  • EsploraClientAdapter › getTransactionSenders › retries a timed-out request under the fetch policy, then rejects and evicts the cache, retries a transient non-2xx response and returns the recovered result
  • In total, 8 tests fail on the parent, and the other 8 in the file pass.

Full run on this branch

  • jest --runInBand --testPathIgnorePatterns integration-test in packages/bitcoin-wallet-snap: 26 suites, 679 tests pass.
  • tsc --noEmit (package): clean.
  • eslint on the changed files only reports the existing process/__dirname hits 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.

No A/B number isolates this PR. Its retries and timeouts were in every build from fixes-v3 onwards, together with PR 03. Check that it causes no regression on the control:

Check Before After
B: first BTC balance Unfixed 13.54.0, B: correct from the first snapshot fixes-v3 to v7 builds, B: 0.00031918 at the first dump, ≤ 9 s after onboarding, in every run
B: bitcoin snap_trackError Unfixed 13.54.0, B: 0 B-v3 to B-v7: 0

Risks

  • The retries add requests on a failing endpoint. They are bounded (1 policy retry on scans, on top of the WASM client's own maxRetries), and the breaker stops hammering a dead endpoint.
  • A healthy but very slow full scan over 120 s now fails and is retried later instead of finishing. The fewer requests of PR 04's gap-5 first scan keep scans well under it (137 bitcoin requests in the first 2 min of the v5–v7 runs).
  • The new webpack devDependency is only used by snap.config.ts at build time.

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

  • Retry transient Esplora read failures with backoff behind a circuit breaker, never retry broadcasts, and time out syncs (60 s) and full scans (120 s) so a stalled request no longer blocks later syncs (#520)

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

…meouts

Scans, syncs, REST lookups and fee estimates run under createFetchPolicy
from @metamask/snap-networks-utils (bounded retries with backoff on
transient failures, circuit breaker); a broadcast is never retried. A whole
sync is capped at 60 s and a full scan at 120 s, so a request stalled by a
broken connection can no longer keep the coalesced sync stuck. performance
is provided to the bundle through a ProvidePlugin shim.
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