diff --git a/packages/bitcoin-wallet-snap/.env.example b/packages/bitcoin-wallet-snap/.env.example index a20daa72d..853ebfa53 100644 --- a/packages/bitcoin-wallet-snap/.env.example +++ b/packages/bitcoin-wallet-snap/.env.example @@ -35,3 +35,9 @@ ESPLORA_REGTEST= # Default: https://price.api.cx.metamask.io # Required: false PRICE_API_URL= + +# Optional fallback Esplora endpoint for bitcoin mainnet reads (stale-balances fix). Used only when +# the primary endpoint keeps failing at the transport level (e.g. the Infura empty-404/no-CORS +# failure under Chrome HTTP/2 connection coalescing). Unset by default: sending wallet addresses +# to a third-party endpoint has privacy implications. +# ESPLORA_BITCOIN_FALLBACK=https://blockstream.info/api diff --git a/packages/bitcoin-wallet-snap/package.json b/packages/bitcoin-wallet-snap/package.json index 366de4da3..07468943c 100644 --- a/packages/bitcoin-wallet-snap/package.json +++ b/packages/bitcoin-wallet-snap/package.json @@ -73,6 +73,7 @@ "ts-jest": "^29.4.14", "typescript": "~5.8.3", "uuid": "^14.0.0", + "webpack": "5.107.2", "wif": "^5.0.0" }, "engines": { diff --git a/packages/bitcoin-wallet-snap/snap.config.ts b/packages/bitcoin-wallet-snap/snap.config.ts index 7828286f4..3e0016d83 100644 --- a/packages/bitcoin-wallet-snap/snap.config.ts +++ b/packages/bitcoin-wallet-snap/snap.config.ts @@ -1,6 +1,9 @@ +/* eslint-disable import-x/no-nodejs-modules -- Snap configuration executes in Node.js. */ import type { SnapConfig } from '@metamask/snaps-cli'; +import { merge } from '@metamask/snaps-cli'; import { config as dotenv } from 'dotenv'; import { resolve } from 'path'; +import { ProvidePlugin } from 'webpack'; dotenv(); @@ -16,6 +19,7 @@ const config: SnapConfig = { LOG_LEVEL: process.env.LOG_LEVEL, DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, + ESPLORA_BITCOIN_FALLBACK: process.env.ESPLORA_BITCOIN_FALLBACK, ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, @@ -29,6 +33,16 @@ const config: SnapConfig = { }, polyfills: true, experimental: { wasm: true }, + // `createFetchPolicy` (cockatiel) times calls with `performance.now()`, + // which snaps don't have. + customizeWebpackConfig: (webpackConfig) => + merge(webpackConfig, { + plugins: [ + new ProvidePlugin({ + performance: '@metamask/snap-networks-utils/performance-shim', + }), + ], + }), preinstalled: { removable: false, hidden: true, diff --git a/packages/bitcoin-wallet-snap/src/config.ts b/packages/bitcoin-wallet-snap/src/config.ts index 8a8162c92..c5c4855b4 100644 --- a/packages/bitcoin-wallet-snap/src/config.ts +++ b/packages/bitcoin-wallet-snap/src/config.ts @@ -14,6 +14,7 @@ import { enums, number, object, + optional, string, } from '@metamask/superstruct'; @@ -42,6 +43,17 @@ export const ConfigStruct = object({ signet: defaultedUrlStruct('https://mutinynet.com/api'), regtest: defaultedUrlStruct('http://localhost:8094/regtest/api'), }), + /** + * Optional fallback Esplora endpoints, used only when the primary fails + * at the transport level (see `withEndpointFallback`). Empty disables it. + */ + fallbackUrl: optional( + object({ + bitcoin: coerce(optional(string()), string(), (value: string) => + value === '' ? undefined : value, + ), + }), + ), explorerUrl: object({ bitcoin: defaultedUrlStruct('https://mempool.space'), testnet: defaultedUrlStruct('https://mempool.space/testnet'), @@ -82,6 +94,10 @@ export const ENVIRONMENT = { signet: process.env.ESPLORA_SIGNET, regtest: process.env.ESPLORA_REGTEST, }, + // Only set when configured, so the default config is unchanged. + ...(process.env.ESPLORA_BITCOIN_FALLBACK + ? { fallbackUrl: { bitcoin: process.env.ESPLORA_BITCOIN_FALLBACK } } + : {}), explorerUrl: { bitcoin: process.env.BITCOIN_EXPLORER, testnet: process.env.TESTNET_EXPLORER, diff --git a/packages/bitcoin-wallet-snap/src/entities/account.ts b/packages/bitcoin-wallet-snap/src/entities/account.ts index 067a3be3b..9ee4104e2 100644 --- a/packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/packages/bitcoin-wallet-snap/src/entities/account.ts @@ -20,6 +20,9 @@ import type { Inscription } from './meta-protocols'; import type { AccountStateSnapshot } from './snap'; import type { TransactionBuilder } from './transaction'; +/** A BIP-32 keychain of an account: receive or change addresses. */ +export type Keychain = 'external' | 'internal'; + /** * A Bitcoin account. */ @@ -82,6 +85,32 @@ export type BitcoinAccount = { */ peekAddress(index: number): AddressInfo; + /** + * Get the address at a given index of a keychain, without revealing it. + * + * @param keychain - 'external' (receive) or 'internal' (change). + * @param index - derivation index. + * @returns the address + */ + peekKeychainAddress(keychain: Keychain, index: number): AddressInfo; + + /** + * Highest derivation index of the keychain that received an output the + * wallet knows of (the keychain's last active index for a gap scan). + * + * @param keychain - 'external' (receive) or 'internal' (change). + * @returns the index, or undefined if the keychain has no known output + */ + lastUsedIndex(keychain: Keychain): number | undefined; + + /** + * Highest revealed derivation index of the keychain. + * + * @param keychain - 'external' (receive) or 'internal' (change). + * @returns the index, or undefined if no address is revealed + */ + lastRevealedIndex(keychain: Keychain): number | undefined; + /** * Get the next unused address. This will reveal a new address if there is no unused address. * Note that the account needs to be persisted for this operation to be idempotent. @@ -126,6 +155,15 @@ export type BitcoinAccount = { */ applyUpdate(update: Update): void; + /** + * Whether the account's wallet has applied at least one chain update (sync + * or full scan). A wallet that never synced still sits on the genesis + * checkpoint (height 0), and its zero balance means "unknown", not "empty". + * + * @returns true if the wallet has synchronized with the chain at least once + */ + hasSynchronized(): boolean; + /** * Extract the change set if it exists. * diff --git a/packages/bitcoin-wallet-snap/src/entities/chain.ts b/packages/bitcoin-wallet-snap/src/entities/chain.ts index c6a9e63ca..aac520a6d 100644 --- a/packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -25,6 +25,18 @@ export type BlockchainClient = { */ fullScan(account: BitcoinAccount, mode?: 'discovery' | 'scan'): Promise; + /** + * Complete a discovery-gap full scan that found activity to the standard + * gap. Only the addresses the standard gap adds beyond what the discovery + * scan already fetched are checked; the standard-gap full scan runs only if + * one of them has history or the check fails, so a used account keeps its + * full address coverage without refetching the addresses already scanned. + * Note that this operation may modify the account in place. + * + * @param account - the account the discovery-gap scan was applied to. + */ + extendDiscoveryScan(account: BitcoinAccount): Promise; + /** * Perform a sync operation on the account. * Note that this operation modifies the account in place. diff --git a/packages/bitcoin-wallet-snap/src/entities/config.ts b/packages/bitcoin-wallet-snap/src/entities/config.ts index 1e5e7271a..ea69a4e9b 100644 --- a/packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/packages/bitcoin-wallet-snap/src/entities/config.ts @@ -19,6 +19,9 @@ export type ChainConfig = { url: { [network in Network]: string; }; + fallbackUrl?: { + [network in Network]?: string | undefined; + }; explorerUrl: { [network in Network]: string; }; diff --git a/packages/bitcoin-wallet-snap/src/entities/error.ts b/packages/bitcoin-wallet-snap/src/entities/error.ts index e8c73d1d4..2c08ce9bd 100644 --- a/packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/packages/bitcoin-wallet-snap/src/entities/error.ts @@ -85,6 +85,124 @@ export class ExternalServiceError extends BaseError { } } +/** + * Message of {@link BalanceNotSynchronizedError}. It is the stable marker of + * that expected state: the keyring SDK's `handleKeyringRequest` re-creates + * every non-`SnapError` as a plain `Error` carrying only the message, so the + * class, name and data are lost before the handler middleware sees it. + */ +export const BALANCE_NOT_SYNCHRONIZED_MESSAGE = + 'Account balance unavailable: the account has not been synchronized yet'; + +/** + * Thrown when an account's balance is requested before the account completed + * its first synchronization (full scan). This is an expected, transient state + * (e.g. right after onboarding, or while the network is unreachable), not a + * failure: it reaches the client as a `DisconnectedError` so the balance is + * shown as unavailable, but it is never reported to Sentry. + */ +export class BalanceNotSynchronizedError extends ExternalServiceError { + constructor(data?: Record) { + super(BALANCE_NOT_SYNCHRONIZED_MESSAGE, data); + this.name = 'BalanceNotSynchronizedError'; + } +} + +/** + * Message of {@link BalanceStaleError}, its stable marker (see + * {@link BALANCE_NOT_SYNCHRONIZED_MESSAGE}). + */ +export const BALANCE_STALE_MESSAGE = + 'Account balance unavailable: synchronization has been failing since the last successful sync'; + +/** + * Thrown when an account's balance is requested while its synchronization has + * been failing for a while (see `AccountUseCases.isBalanceStale`): the stored + * balance may be outdated, so it is not reported as current. Like + * {@link BalanceNotSynchronizedError}, an expected, transient state: the + * client shows the balance as unavailable, it is never reported to Sentry, + * and the next successful sync clears it. + */ +export class BalanceStaleError extends ExternalServiceError { + constructor(data?: Record) { + super(BALANCE_STALE_MESSAGE, data); + this.name = 'BalanceStaleError'; + } +} + +/** + * Whether an error is (or wraps) one of the given expected-state errors, even + * after it was re-created, wrapped or serialized along the way: matches the + * class, then the stable message marker on the error, its `cause` chain or a + * serialized error object. + * + * @param error - The error to check. + * @param classes - The error classes. + * @param markers - The stable message markers. + * @returns `true` if the error matches. + */ +function matchesExpectedState( + error: unknown, + classes: (abstract new (...args: never[]) => Error)[], + markers: string[], +): boolean { + const hasMarker = (text: string): boolean => + markers.some((marker) => text.includes(marker)); + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + const candidate = current; + if (classes.some((errorClass) => candidate instanceof errorClass)) { + return true; + } + if (typeof current === 'string') { + return hasMarker(current); + } + if (typeof current !== 'object') { + return false; + } + const { message, cause } = current as { + message?: unknown; + cause?: unknown; + }; + if (typeof message === 'string' && hasMarker(message)) { + return true; + } + current = cause; + } + return false; +} + +/** + * Whether an error is (or wraps) a {@link BalanceNotSynchronizedError}, even + * after it was re-created, wrapped or serialized along the way. + * + * @param error - The error to check. + * @returns `true` for the expected "not synchronized yet" state. + */ +export function isBalanceNotSynchronizedError(error: unknown): boolean { + return matchesExpectedState( + error, + [BalanceNotSynchronizedError], + [BALANCE_NOT_SYNCHRONIZED_MESSAGE], + ); +} + +/** + * Whether an error is (or wraps) an expected "balance unavailable" state: + * {@link BalanceNotSynchronizedError} or {@link BalanceStaleError}. These are + * returned to the client but never reported to Sentry. + * + * @param error - The error to check. + * @returns `true` for an expected balance-unavailable state. + */ +export function isBalanceUnavailableError(error: unknown): boolean { + return matchesExpectedState( + error, + [BalanceNotSynchronizedError, BalanceStaleError], + [BALANCE_NOT_SYNCHRONIZED_MESSAGE, BALANCE_STALE_MESSAGE], + ); +} + /** * Error thrown for wallet-specific failures. * Useful for signaling wallet operation errors. diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 2a0d16cbb..84bbcb045 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,5 +1,4 @@ import type { Amount, WalletTx } from '@metamask/bitcoindevkit'; -import { TransactionType } from '@metamask/keyring-api'; import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; import { SynchronizationError } from '@metamask/snap-networks-utils'; import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk'; @@ -12,7 +11,7 @@ import type { SyncResult, } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; -import { CronHandler, CronMethod } from './CronHandler'; +import { CronHandler, CronMethod, MAX_FULL_SCANS_PER_RUN } from './CronHandler'; jest.mock('@metamask/keyring-snap-sdk', () => ({ getSelectedAccounts: jest.fn(), @@ -41,9 +40,12 @@ describe('CronHandler', () => { clientVersion: '1.0.0', platformVersion: '1.0.0', }); - // Default the repair rescan to nothing pending so existing tests don't - // trigger a repair scan. + // Default to no queued full scan and known balances so existing tests + // don't trigger a full scan. mockSnapClient.getState.mockResolvedValue({ pending: [] }); + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(false); + mockAccountUseCases.isFullScanInFlight.mockReturnValue(false); }); describe('synchronizeAccounts', () => { @@ -271,7 +273,7 @@ describe('CronHandler', () => { ); }); - describe('repair rescan', () => { + describe('queued full scans (first scan and repair)', () => { const buildTx = (txid: string): WalletTx => mock({ txid: mock({ toString: () => txid }), @@ -295,156 +297,127 @@ describe('CronHandler', () => { mockAccount2.listTransactions.mockReturnValue([]); }); - it('on first run, persists the full pending list, scans only the first account, and emits for missed transactions', async () => { - mockSnapClient.getState.mockResolvedValue(null); + it('asks for at most MAX_FULL_SCANS_PER_RUN due accounts and does nothing when none is due', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); + + await handler.route(request); + + expect(mockAccountUseCases.listDueFullScans).toHaveBeenCalledWith( + mockAccounts, + MAX_FULL_SCANS_PER_RUN, + ); + expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + expect(mockSnapClient.emitTrackingError).not.toHaveBeenCalled(); + // The regular sync still runs. + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + }); + + it('full-scans every due account, emits balances and missed transactions', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([ + mockAccount1, + mockAccount2, + ]); const txBefore = buildTx('txid-existing'); - const txNew = buildTx('txid-new'); + const txMissed = buildTx('txid-missed'); mockAccount1.listTransactions .mockReturnValueOnce([txBefore]) - .mockReturnValueOnce([txBefore, txNew]); - mockAccountUseCases.fullScan.mockResolvedValue({ - account: mockAccount1, + .mockReturnValueOnce([txBefore, txMissed]); + mockAccountUseCases.fullScan.mockImplementation(async (account) => ({ + account, transactionsToNotify: [], - }); + })); await handler.route(request); - expect(mockSnapClient.getState).toHaveBeenCalledWith('rescanV1'); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(1); expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount1); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount2); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount1, mockAccount2]); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( - 'Missed Transactions Discovered', + expect.anything(), mockAccount1, - txNew, + txMissed, 'cron', - TransactionType.Receive, + expect.anything(), ); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith([mockAccount1]); - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: ['account-1', 'account-2'], - }); - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: ['account-2'], - }); - - // The initial full list is persisted before the first scan, and the - // shortened list is persisted only after the scan succeeds. - expect(mockSnapClient.setState).toHaveBeenCalledTimes(2); - const [initialPersistOrder, shortenedPersistOrder] = - mockSnapClient.setState.mock.invocationCallOrder; - const scanOrder = - mockAccountUseCases.fullScan.mock.invocationCallOrder[0]; - expect(initialPersistOrder).toBeLessThan(scanOrder as number); - expect(scanOrder).toBeLessThan(shortenedPersistOrder as number); - - // The normal sync flow still runs afterwards. - expect(mockAccountUseCases.synchronize).toHaveBeenCalled(); + expect(mockSnapClient.emitTrackingError).not.toHaveBeenCalled(); }); - it('on a subsequent run, scans only the next pending account', async () => { - mockSnapClient.getState.mockResolvedValue({ pending: ['account-2'] }); - mockAccountUseCases.fullScan.mockResolvedValue({ - account: mockAccount2, - transactionsToNotify: [], - }); - - await handler.route(request); - - expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(1); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount2); - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: [], - }); - }); - - it('prunes deleted accounts from the pending list before scanning', async () => { - mockSnapClient.getState.mockResolvedValue({ - pending: ['gone', 'account-2'], - }); - mockAccountUseCases.fullScan.mockResolvedValue({ - account: mockAccount2, - transactionsToNotify: [], + it('does not let a failing account block the next one (no head-of-line blocking)', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([ + mockAccount1, + mockAccount2, + ]); + const scanError = new Error('Failed to perform initial full scan'); + mockAccountUseCases.fullScan.mockImplementation(async (account) => { + if (account === mockAccount1) { + throw scanError; + } + return { account, transactionsToNotify: [] }; }); await handler.route(request); - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: ['account-2'], - }); expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount2); - - // The pruned list is persisted before the scan runs. - const pruneOrder = mockSnapClient.setState.mock.invocationCallOrder[0]; - const scanOrder = - mockAccountUseCases.fullScan.mock.invocationCallOrder[0]; - expect(pruneOrder).toBeLessThan(scanOrder as number); - }); - - it('does not scan when every pending account has been deleted', async () => { - mockSnapClient.getState.mockResolvedValue({ pending: ['gone'] }); - - await handler.route(request); - - // The pending list is pruned to nothing, and the repair pass ends - // without scanning anything. - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: [], - }); - expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount2]); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(1); + const [error] = mockSnapClient.emitTrackingError.mock.calls[0] as [ + Error, + ]; + expect(error.message).toBe('Account repair scan failed'); + expect(error.cause).toBe(scanError); + // The regular sync still runs. + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); }); - it('does nothing when nothing is pending, short-circuiting before list()', async () => { - mockSnapClient.getState.mockResolvedValue({ pending: [] }); + it('reports a failure of the queue itself and still runs the regular sync', async () => { + mockAccountUseCases.listDueFullScans.mockRejectedValue( + new Error('state unavailable'), + ); await handler.route(request); - expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); - expect(mockSnapClient.setState).not.toHaveBeenCalled(); - // The regular sync flow is the only caller of `list()` here — the - // repair path short-circuits on the empty pending list before - // reaching it. - expect(mockAccountUseCases.list).toHaveBeenCalledTimes(1); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Account repair scan failed' }), + ); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); }); - it('reports a scan failure without shortening the pending list, and still runs the regular sync', async () => { - mockSnapClient.getState.mockResolvedValue({ - pending: ['account-1', 'account-2'], - }); - const scanError = new Error('scan failed'); - mockAccountUseCases.fullScan.mockRejectedValue(scanError); + it('holds back the balance of a synced account whose balance is still unknown', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); + mockAccountUseCases.synchronize + .mockResolvedValueOnce({ + account: mockAccount1, + transactionsToNotify: [], + }) + .mockResolvedValueOnce({ + account: mockAccount2, + transactionsToNotify: [], + }); + mockAccountUseCases.isBalanceUnknown.mockImplementation( + async (account) => account === mockAccount1, + ); await handler.route(request); - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Account repair scan failed', - cause: scanError, - }), - ); - // The failure is contained: the pending list is not shortened and - // the regular sync still runs. - expect(mockSnapClient.setState).not.toHaveBeenCalledWith('rescanV1', { - pending: ['account-2'], - }); - expect(mockAccountUseCases.synchronize).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount2]); }); - it('treats malformed stored state as unset and reinitializes pending from the account list', async () => { - mockSnapClient.getState.mockResolvedValue(true); - mockAccountUseCases.fullScan.mockResolvedValue({ - account: mockAccount1, - transactionsToNotify: [], - }); + it('emits no balance event when every synced balance is unknown', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(true); await handler.route(request); - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: ['account-1', 'account-2'], - }); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount1); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).not.toHaveBeenCalled(); }); }); }); @@ -752,6 +725,10 @@ describe('CronHandler', () => { transactionsToNotify: mockTxs, }; mockAccountUseCases.get.mockResolvedValue(mockAccount); + // Unknown before the scan, known after it. + mockAccountUseCases.isBalanceUnknown + .mockResolvedValueOnce(true) + .mockResolvedValue(false); mockAccountUseCases.fullScan.mockResolvedValue(mockResult); await handler.route(request); @@ -789,10 +766,39 @@ describe('CronHandler', () => { it('propagates errors from fullScan', async () => { const error = new Error('fullScan failed'); mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(true); mockAccountUseCases.fullScan.mockRejectedValue(error); await expect(handler.route(request)).rejects.toThrow(error); }); + + it('skips an on-demand scan whose purpose was already fulfilled (balance known)', async () => { + mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.isFullScanInFlight.mockReturnValue(false); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(false); + + await handler.route(request); + + expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).not.toHaveBeenCalled(); + }); + + it('joins a scan that is already in flight', async () => { + const mockResult: SyncResult = { + account: mockAccount, + transactionsToNotify: [], + }; + mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.isFullScanInFlight.mockReturnValue(true); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(false); + mockAccountUseCases.fullScan.mockResolvedValue(mockResult); + + await handler.route(request); + + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount); + }); }); describe('route', () => { diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 8214d50c5..18cd94b50 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -6,14 +6,19 @@ import { getSyncFailuresFromSettledResult, } from '@metamask/snap-networks-utils'; import type { JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk'; -import { array, assert, is, object, string } from 'superstruct'; +import { array, assert, object, string } from 'superstruct'; import { InexistentMethodError, mapToTransactionType, TrackingSnapEvent, } from '../entities'; -import type { Logger, SnapClient, SyncResult } from '../entities'; +import type { + BitcoinAccount, + Logger, + SnapClient, + SyncResult, +} from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; export const CronMethod = { @@ -37,9 +42,10 @@ export const FullScanAccountRequest = object({ accountId: string(), }); -const RescanState = object({ - pending: array(string()), -}); +/** + * Maximum number of queued full scans run per sync run. + */ +export const MAX_FULL_SCANS_PER_RUN = 2; export class CronHandler { readonly #accountsUseCases: AccountUseCases; @@ -137,8 +143,10 @@ export class CronHandler { // that coalesced callers share the run's outcome, including the reported // synchronization failures. await this.#syncCoalescer.run('synchronizeAccounts', async () => { + const allAccounts = await this.#accountsUseCases.list(); + try { - await this.#repairNextAccount(); + await this.#runDueFullScans(allAccounts); } catch (error) { await this.#snapClient.emitTrackingError( new Error('Account repair scan failed', { cause: error }), @@ -149,11 +157,9 @@ export class CronHandler { await getSelectedAccounts(this.#snap), ); - const accounts = (await this.#accountsUseCases.list()).filter( - (account) => { - return selectedAccounts.has(account.id); - }, - ); + const accounts = allAccounts.filter((account) => { + return selectedAccounts.has(account.id); + }); const results = await Promise.allSettled( accounts.map(async (account) => @@ -239,10 +245,22 @@ export class CronHandler { return; } - // Emit one batched balance event for all accounts - await this.#snapClient.emitAccountBalancesUpdatedEvent( - results.map((syncResult) => syncResult.account), - ); + // Emit one batched balance event for the accounts whose balance is + // known. A sync of an account whose first full scan has not succeeded + // only covers its revealed addresses: pushing that partial balance would + // show a confident (possibly zero) amount, so it is held back until the + // full scan succeeds. + const knownBalanceAccounts: BitcoinAccount[] = []; + for (const { account } of results) { + if (!(await this.#accountsUseCases.isBalanceUnknown(account))) { + knownBalanceAccounts.push(account); + } + } + if (knownBalanceAccounts.length > 0) { + await this.#snapClient.emitAccountBalancesUpdatedEvent( + knownBalanceAccounts, + ); + } // Emit transaction events per account for (const { @@ -262,56 +280,70 @@ export class CronHandler { async fullScanAccount(accountId: string): Promise { const account = await this.#accountsUseCases.get(accountId); + // On-demand scans (new account, balance request, network recovery) can + // be requested more than once; skip the ones whose purpose was already + // fulfilled by another scan in the meantime. + if ( + !this.#accountsUseCases.isFullScanInFlight(accountId) && + !(await this.#accountsUseCases.isBalanceUnknown(account)) + ) { + this.#logger.debug('Full scan no longer needed: %s', accountId); + return; + } const result = await this.#accountsUseCases.fullScan(account); await this.#emitSyncEvents([result]); } /** - * Repair one previously-unwatched account per sync run. Advances the - * pending list only after the scan succeeds, so a scan that never runs - * (client locked/inactive) or fails leaves the account pending for the - * next sync run instead of being marked done prematurely. + * Runs the full scans that are due: the first full scan of new accounts, + * of accounts that never synchronized, and the one-off repair scan of + * previously-unwatched accounts. + * + * @param accounts - All live accounts. + * + * Up to {@link MAX_FULL_SCANS_PER_RUN} accounts are scanned concurrently per + * sync run. A failed scan moves the account to the tail of the queue and + * backs it off exponentially (see `FullScanQueue`), so a single failing + * account can no longer block every account queued behind it. */ - async #repairNextAccount(): Promise { - const stored = await this.#snapClient.getState('rescanV1'); - if (is(stored, RescanState) && stored.pending.length === 0) { + async #runDueFullScans(accounts: BitcoinAccount[]): Promise { + const due = await this.#accountsUseCases.listDueFullScans( + accounts, + MAX_FULL_SCANS_PER_RUN, + ); + if (due.length === 0) { return; } - const accounts = await this.#accountsUseCases.list(); - const liveIds = new Set(accounts.map((account) => account.id)); - - const pending = is(stored, RescanState) - ? stored.pending.filter((id) => liveIds.has(id)) - : accounts.map((account) => account.id); - - if (!is(stored, RescanState) || pending.length !== stored.pending.length) { - await this.#snapClient.setState('rescanV1', { pending }); - } + const results = await Promise.allSettled( + due.map(async (account) => { + const before = new Set( + account.listTransactions().map((tx) => tx.txid.toString()), + ); + const result = await this.#accountsUseCases.fullScan(account); + + for (const tx of account.listTransactions()) { + if (!before.has(tx.txid.toString())) { + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.MissedTransactionsDiscovered, + account, + tx, + 'cron', + mapToTransactionType(account, tx.tx), + ); + } + } + return result; + }), + ); - const account = accounts.find(({ id }) => id === pending[0]); - if (!account) { - return; - } + await this.#finishSync(results); - const before = new Set( - account.listTransactions().map((tx) => tx.txid.toString()), + const firstFailure = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', ); - const result = await this.#accountsUseCases.fullScan(account); - - for (const tx of account.listTransactions()) { - if (!before.has(tx.txid.toString())) { - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.MissedTransactionsDiscovered, - account, - tx, - 'cron', - mapToTransactionType(account, tx.tx), - ); - } + if (firstFailure) { + throw firstFailure.reason; } - - await this.#emitSyncEvents([result]); - await this.#snapClient.setState('rescanV1', { pending: pending.slice(1) }); } } diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 76553a0b7..ed22b46de 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -1,7 +1,13 @@ +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import { BaseError, ExternalServiceError } from '../entities'; +import { + BalanceNotSynchronizedError, + BalanceStaleError, + BaseError, + ExternalServiceError, +} from '../entities'; import type { Logger, SnapClient, Translator } from '../entities'; import { trackError } from '../utils/errors'; import { HandlerMiddleware } from './HandlerMiddleware'; @@ -131,5 +137,111 @@ describe('HandlerMiddleware', () => { ); expect(mockTrackError).toHaveBeenCalledWith(error); }); + + it('does not track or log as an error the expected "not synchronized yet" state', async () => { + const error = new BalanceNotSynchronizedError({ account: 'account-1' }); + const mockFn = jest.fn().mockRejectedValue(error); + mockTranslator.load.mockResolvedValue({ + 'error.3000': { message: 'Connection error' }, + }); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + 'Connection error: Account balance unavailable: the account has not been synchronized yet', + ); + expect(mockTrackError).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith(error.message, error.data); + }); + + describe('through the real keyring request path (v3: the SDK re-creates the error)', () => { + const balancesRequest = { + jsonrpc: '2.0' as const, + id: '1', + method: 'keyring_getAccountBalances', + params: { + id: '9b1c3a4e-2f6d-4b8a-9c1e-5d7f3a2b1c0d', + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + }; + + it('handleKeyringRequest turns the error into a plain Error with only the message', async () => { + const keyring = { + getAccountBalances: jest + .fn() + .mockRejectedValue(new BalanceNotSynchronizedError()), + }; + + const error = await handleKeyringRequest( + keyring as never, + balancesRequest, + ).catch((caught: unknown) => caught); + + expect(error).not.toBeInstanceOf(BalanceNotSynchronizedError); + expect((error as Error).name).toBe('Error'); + expect((error as Error).message).toBe( + 'Account balance unavailable: the account has not been synchronized yet', + ); + }); + + it('does not track the re-created "not synchronized yet" error (the onKeyringRequest path)', async () => { + const keyring = { + getAccountBalances: jest + .fn() + .mockRejectedValue( + new BalanceNotSynchronizedError({ account: 'account-1' }), + ), + }; + + // Same composition as `onKeyringRequest` in src/index.ts. + await expect( + middleware.handle( + async () => + (await handleKeyringRequest(keyring as never, balancesRequest)) ?? + null, + ), + ).rejects.toThrow('not been synchronized yet'); + + expect(keyring.getAccountBalances).toHaveBeenCalled(); + expect(mockTrackError).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('does not track the re-created stale-balance error (v6)', async () => { + const keyring = { + getAccountBalances: jest + .fn() + .mockRejectedValue(new BalanceStaleError({ account: 'account-1' })), + }; + + await expect( + middleware.handle( + async () => + (await handleKeyringRequest(keyring as never, balancesRequest)) ?? + null, + ), + ).rejects.toThrow('synchronization has been failing'); + + expect(mockTrackError).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('still tracks other errors re-created by the SDK', async () => { + const keyring = { + getAccountBalances: jest + .fn() + .mockRejectedValue( + new ExternalServiceError('Failed to synchronize account'), + ), + }; + + await expect( + middleware.handle(async () => + handleKeyringRequest(keyring as never, balancesRequest), + ), + ).rejects.toThrow('Failed to synchronize account'); + + expect(mockTrackError).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index 2b7a7c55e..caad9661d 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -25,6 +25,7 @@ import { ValidationError, WalletError, AssertionError, + isBalanceUnavailableError, } from '../entities'; import { trackError } from '../utils/errors'; @@ -45,13 +46,25 @@ export class HandlerMiddleware { try { return await fn(); } catch (error) { - await trackError(error); + // An unavailable balance (the account has not synchronized yet, or its + // syncs have been failing since the last success) is an expected state, + // not a failure: it is still returned to the caller, but not reported. Match + // the stable message marker, not the class: `handleKeyringRequest` + // re-creates the error as a plain `Error` before it gets here. + const isExpectedState = isBalanceUnavailableError(error); + if (!isExpectedState) { + await trackError(error); + } const { locale } = await this.#snapClient.getPreferences(); const messages = await this.#translator.load(locale); if (error instanceof BaseError) { - this.#logger.error(error, error.data); + if (isExpectedState) { + this.#logger.debug(error.message, error.data); + } else { + this.#logger.error(error, error.data); + } const errMsg = messages[`error.${error.code}`]?.message ?? @@ -119,7 +132,11 @@ export class HandlerMiddleware { // branch replaced everything with a generic "Unexpected error" // string, making cross-boundary errors like KeyringControllerError // opaque in Sentry). - this.#logger.error(error); + if (isExpectedState) { + this.#logger.debug(getErrorMessage(error)); + } else { + this.#logger.error(error); + } throw new SnapError( error instanceof Error ? error : getErrorMessage(error), ); diff --git a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index c27159d9e..8b594997f 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -24,7 +24,13 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { BitcoinAccount, Logger, SnapClient } from '../entities'; -import { AccountCapability, CurrencyUnit } from '../entities'; +import { + AccountCapability, + BALANCE_STALE_MESSAGE, + BalanceNotSynchronizedError, + BalanceStaleError, + CurrencyUnit, +} from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { Caip19Asset } from './caip'; import { KeyringHandler } from './KeyringHandler'; @@ -679,6 +685,76 @@ describe('KeyringHandler', () => { ); expect(mockAccounts.get).toHaveBeenCalled(); }); + + it('fails instead of reporting 0 when the balance is still unknown', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(true); + + await expect(handler.getAccountBalances(mockAccount.id)).rejects.toThrow( + 'Account balance unavailable: the account has not been synchronized yet', + ); + expect(mockAccounts.isBalanceUnknown).toHaveBeenCalledWith(mockAccount); + }); + + it('throws the untracked BalanceNotSynchronizedError and kicks off a full scan when the balance is unknown', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(true); + mockAccounts.requestFullScan.mockResolvedValue(true); + + await expect( + handler.getAccountBalances(mockAccount.id), + ).rejects.toBeInstanceOf(BalanceNotSynchronizedError); + expect(mockAccounts.requestFullScan).toHaveBeenCalledWith( + mockAccount, + 'balance-request', + ); + }); + + it('does not request a full scan when the balance is known', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(false); + + await handler.getAccountBalances(mockAccount.id); + + expect(mockAccounts.requestFullScan).not.toHaveBeenCalled(); + }); + + it('throws the untracked BalanceStaleError and requests a sync when the syncs keep failing (v6)', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(false); + mockAccounts.isBalanceStale.mockResolvedValue(true); + mockAccounts.scheduleStaleRefresh.mockResolvedValue(true); + + const promise = handler.getAccountBalances(mockAccount.id); + await expect(promise).rejects.toBeInstanceOf(BalanceStaleError); + await expect(promise).rejects.toThrow(BALANCE_STALE_MESSAGE); + expect(mockAccounts.isBalanceStale).toHaveBeenCalledWith(mockAccount.id); + expect(mockAccounts.scheduleStaleRefresh).toHaveBeenCalledWith( + mockAccount.id, + ); + }); + + it('answers from the stored balance when it is not stale (v6)', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(false); + mockAccounts.isBalanceStale.mockResolvedValue(false); + + expect(await handler.getAccountBalances(mockAccount.id)).toStrictEqual({ + [Caip19Asset.Bitcoin]: { amount: '1', unit: 'BTC' }, + }); + expect(mockAccounts.scheduleStaleRefresh).not.toHaveBeenCalled(); + }); + + it('reports the balance once it is known', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(false); + + const result = await handler.getAccountBalances(mockAccount.id); + + expect(result).toStrictEqual({ + [Caip19Asset.Bitcoin]: { amount: '1', unit: 'BTC' }, + }); + }); }); describe('getAccount', () => { diff --git a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 084f80ecc..cebf47f6d 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -31,6 +31,8 @@ import { encode } from 'wif'; import snapManifest from '../../snap.manifest.json'; import type { Logger, SnapClient } from '../entities'; import { + BalanceNotSynchronizedError, + BalanceStaleError, computeDisplayBalanceSats, FormatError, networkToCurrencyUnit, @@ -277,6 +279,30 @@ export class KeyringHandler implements KeyringSnapRpc { id: string, ): Promise> { const account = await this.#accountsUseCases.get(id); + + // An account that never synchronized, or never completed its first full + // scan, has an unknown balance: reporting the zero of an empty wallet + // would show a confident 0 to the user. Fail instead so the client keeps + // it as unavailable and retries. Answering from local state alone would + // never recover, so the request also kicks off a full scan (deduplicated + // and single-flight per account, bounded backoff); its balances-updated + // event, or the client's next retry, then delivers the real balance. + if (await this.#accountsUseCases.isBalanceUnknown(account)) { + await this.#accountsUseCases.requestFullScan(account, 'balance-request'); + throw new BalanceNotSynchronizedError({ account: id }); + } + + // The stored balance is answered without a request, so a broken + // connection after the account synced would keep confirming an outdated + // balance to the client. Once the syncs have been failing for a while, + // fail instead (not reported to Sentry) so the client shows it as + // unavailable; the request schedules a sync (rate limited), whose + // success clears the state and emits the balances-updated event. + if (await this.#accountsUseCases.isBalanceStale(account.id)) { + await this.#accountsUseCases.scheduleStaleRefresh(account.id); + throw new BalanceStaleError({ account: id }); + } + const balance = Amount.from_sat(computeDisplayBalanceSats(account)) .to_btc() .toString(); diff --git a/packages/bitcoin-wallet-snap/src/index.ts b/packages/bitcoin-wallet-snap/src/index.ts index df0b97e1b..9e6fb5eeb 100644 --- a/packages/bitcoin-wallet-snap/src/index.ts +++ b/packages/bitcoin-wallet-snap/src/index.ts @@ -40,7 +40,7 @@ const { config } = configProvider; // Infra layer const snapClient = new SnapClientAdapter(logger, config.encrypt); -const chainClient = new EsploraClientAdapter(config.chain); +const chainClient = new EsploraClientAdapter(config.chain, logger); const assetRatesClient = new PriceApiClientAdapter(config.priceApi); const translator = new LocalTranslatorAdapter(); const middleware = new HandlerMiddleware(logger, snapClient, translator); diff --git a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts index a582bb799..25d5aaa74 100644 --- a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts @@ -3,6 +3,7 @@ import type { ChangeSet, DescriptorPair, KeychainKind, + LocalOutput, Network, ScriptBuf, SpkIndexed, @@ -205,6 +206,41 @@ describe('BdkAccountAdapter', () => { mockNetwork, ); + describe('gap scan indexes (v5)', () => { + const output = (keychain: KeychainKind, index: number): LocalOutput => + ({ keychain, derivation_index: index }) as unknown as LocalOutput; + + it('returns the highest index of a keychain that received an output', () => { + mockWallet.list_output.mockReturnValue([ + output('external', 2), + output('internal', 7), + output('external', 0), + output('external', 4), + ]); + + expect(adapter().lastUsedIndex('external')).toBe(4); + expect(adapter().lastUsedIndex('internal')).toBe(7); + }); + + it('returns undefined for a keychain without outputs', () => { + mockWallet.list_output.mockReturnValue([output('external', 0)]); + + expect(adapter().lastUsedIndex('internal')).toBeUndefined(); + }); + + it('returns the last revealed index and peeks keychain addresses without revealing them', () => { + const info = mock(); + mockWallet.derivation_index.mockReturnValue(3); + mockWallet.peek_address.mockReturnValue(info); + + expect(adapter().lastRevealedIndex('internal')).toBe(3); + expect(mockWallet.derivation_index).toHaveBeenCalledWith('internal'); + expect(adapter().peekKeychainAddress('internal', 9)).toBe(info); + expect(mockWallet.peek_address).toHaveBeenCalledWith('internal', 9); + expect(mockWallet.reveal_addresses_to).not.toHaveBeenCalled(); + }); + }); + describe('isChange', () => { it('returns true when the script belongs to the internal keychain', () => { mockWallet.derivation_of_spk.mockReturnValue(indexed('internal', 3)); diff --git a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 2171e1b94..59c434851 100644 --- a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -26,7 +26,7 @@ import { } from '@metamask/bitcoindevkit'; import { AccountCapability, ValidationError, WalletError } from '../entities'; -import type { BitcoinAccount, TransactionBuilder } from '../entities'; +import type { BitcoinAccount, Keychain, TransactionBuilder } from '../entities'; import { toFeeRateSatsPerVb } from '../utils/fee-rate'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; @@ -149,6 +149,27 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.peek_address('external', index); } + peekKeychainAddress(keychain: Keychain, index: number): AddressInfo { + return this.#wallet.peek_address(keychain, index); + } + + lastUsedIndex(keychain: Keychain): number | undefined { + let last: number | undefined; + for (const output of this.#wallet.list_output()) { + if ( + output.keychain === keychain && + (last === undefined || output.derivation_index > last) + ) { + last = output.derivation_index; + } + } + return last; + } + + lastRevealedIndex(keychain: Keychain): number | undefined { + return this.#wallet.derivation_index(keychain); + } + nextUnusedAddress(): AddressInfo { return this.#wallet.next_unused_address('external'); } @@ -183,6 +204,12 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.apply_update(update); } + hasSynchronized(): boolean { + // A freshly created wallet only holds the genesis checkpoint (height 0); + // every successful sync or full scan applies a chain update to the tip. + return this.#wallet.latest_checkpoint.height > 0; + } + takeStaged(): ChangeSet | undefined { const staged = this.#collectStaged(); this.#staged = undefined; diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts index 3c31994fa..f5e3499d4 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts @@ -1,11 +1,16 @@ -import type { FullScanRequest } from '@metamask/bitcoindevkit'; +import type { AddressInfo, FullScanRequest } from '@metamask/bitcoindevkit'; import { EsploraClient } from '@metamask/bitcoindevkit'; import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount, ChainConfig } from '../entities'; +import type { BitcoinAccount, ChainConfig, Keychain } from '../entities'; import { EsploraClientAdapter, + FULL_SCAN_TIMEOUT_MS, SENDER_REQUEST_TIMEOUT_MS, + GAP_CHECK_REQUEST_TIMEOUT_MS, + SYNC_TIMEOUT_MS, + toScripthash, + withOperationTimeout, } from './EsploraClientAdapter'; jest.mock('@metamask/bitcoindevkit', () => ({ @@ -69,6 +74,178 @@ describe('EsploraClientAdapter', () => { }); }); + describe('operation timeouts (v3: a stalled request must not keep the sync guard stuck)', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('withOperationTimeout resolves with the result and clears its timer', async () => { + jest.useFakeTimers(); + + expect(await withOperationTimeout(Promise.resolve(42), 1000, 'Op')).toBe( + 42, + ); + expect(jest.getTimerCount()).toBe(0); + }); + + it('withOperationTimeout rejects with a TimeoutError when the operation stalls', async () => { + jest.useFakeTimers(); + const stalled = new Promise(() => undefined); + + const result = withOperationTimeout(stalled, 1000, 'Op'); + const caught = result.catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(1000); + expect(await caught).toMatchObject({ + name: 'TimeoutError', + message: 'Op timed out after 1000 ms', + }); + expect(jest.getTimerCount()).toBe(0); + }); + + it('fails a sync whose request never settles after SYNC_TIMEOUT_MS, without applying anything', async () => { + jest.useFakeTimers(); + const { adapter, mockEsploraClient, account } = setupTest(); + mockEsploraClient.sync.mockReturnValue(new Promise(() => undefined)); + + const result = adapter.sync(account); + const caught = result.catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(SYNC_TIMEOUT_MS); + expect(await caught).toMatchObject({ + message: 'Failed to synchronize account', + cause: expect.objectContaining({ name: 'TimeoutError' }), + }); + expect(account.applyUpdate).not.toHaveBeenCalled(); + }); + + it('fails a full scan whose requests never settle after FULL_SCAN_TIMEOUT_MS', async () => { + jest.useFakeTimers(); + const { adapter, mockEsploraClient, account } = setupTest(); + mockEsploraClient.full_scan.mockReturnValue(new Promise(() => undefined)); + + const result = adapter.fullScan(account); + let settled = false; + result.catch(() => undefined).finally(() => (settled = true)); + await jest.advanceTimersByTimeAsync(SYNC_TIMEOUT_MS); + expect(settled).toBe(false); + + const caught = result.catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync( + FULL_SCAN_TIMEOUT_MS - SYNC_TIMEOUT_MS, + ); + expect(await caught).toMatchObject({ + message: 'Failed to perform initial full scan', + cause: expect.objectContaining({ name: 'TimeoutError' }), + }); + expect(account.applyUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('fallback endpoint', () => { + const setupFallbackTest = ( + fallbackUrl?: string, + ): { + adapter: EsploraClientAdapter; + primary: ReturnType>; + fallback: ReturnType>; + account: BitcoinAccount; + } => { + const primary = mock(); + const fallback = mock(); + jest + .mocked(EsploraClient) + .mockImplementation((url: string) => + url === 'https://fallback.example' ? fallback : primary, + ); + // A plain object: a deep mock would make every network's fallback + // URL truthy. + const config = { + parallelRequests: 5, + maxRetries: 3, + stopGap: { discovery: 5, scan: 20 }, + url: { + bitcoin: 'https://bitcoin.example', + testnet: 'https://testnet.example', + testnet4: 'https://testnet4.example', + signet: 'https://signet.example', + regtest: 'https://regtest.example', + }, + ...(fallbackUrl ? { fallbackUrl: { bitcoin: fallbackUrl } } : {}), + } as unknown as ChainConfig; + const account = mock({ network: 'bitcoin' }); + account.startFullScan.mockReturnValue(mock()); + return { + adapter: new EsploraClientAdapter(config), + primary, + fallback, + account, + }; + }; + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('scans against the fallback endpoint when the primary keeps failing with a network error', async () => { + const { adapter, primary, fallback, account } = setupFallbackTest( + 'https://fallback.example', + ); + primary.full_scan.mockRejectedValue(new TypeError('Failed to fetch')); + const update = { update: true }; + fallback.full_scan.mockResolvedValue(update as never); + + const pending = adapter.fullScan(account); + await jest.runAllTimersAsync(); + await pending; + + // 1 attempt + 1 retry on the primary, then the fallback. + expect(primary.full_scan).toHaveBeenCalledTimes(2); + expect(fallback.full_scan).toHaveBeenCalledTimes(1); + expect(account.applyUpdate).toHaveBeenCalledWith(update); + }); + + it('fails with the primary error when no fallback is configured', async () => { + const { adapter, primary, fallback, account } = setupFallbackTest(); + primary.full_scan.mockRejectedValue(new TypeError('Failed to fetch')); + + const pending = adapter + .fullScan(account) + .catch((error: unknown) => error); + await jest.runAllTimersAsync(); + expect(await pending).toMatchObject({ + message: 'Failed to perform initial full scan', + }); + + expect(fallback.full_scan).not.toHaveBeenCalled(); + }); + + it('never retries nor falls back a broadcast', async () => { + const { adapter, primary, fallback } = setupFallbackTest( + 'https://fallback.example', + ); + primary.broadcast.mockRejectedValue(new TypeError('Failed to fetch')); + const transaction = + mock[1]>(); + transaction.compute_txid.mockReturnValue({ + toString: () => 'txid', + } as never); + + const pending = adapter + .broadcast('bitcoin', transaction) + .catch((error: unknown) => error); + await jest.runAllTimersAsync(); + expect(await pending).toMatchObject({ + message: 'Failed to broadcast transaction', + }); + + expect(primary.broadcast).toHaveBeenCalledTimes(1); + expect(fallback.broadcast).not.toHaveBeenCalled(); + }); + }); + describe('getTransactionSenders', () => { const mockFetch = jest.fn(); let abortController: AbortController; @@ -124,28 +301,44 @@ describe('EsploraClientAdapter', () => { ); }); - it('rejects and evicts the cache when the request times out', async () => { - const { adapter } = setupTest(); - mockFetch.mockImplementation( - (_url: unknown, init?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => - reject(new Error('TimeoutError')), - ); - }), - ); - - const pending = adapter.getTransactionSenders('bitcoin', 'txid'); - abortController.abort(); - - await expect(pending).rejects.toThrow('TimeoutError'); - - // The failure is not cached, so the next lookup retries. - mockFetch.mockRejectedValue(new Error('TimeoutError')); - await expect( - adapter.getTransactionSenders('bitcoin', 'txid'), - ).rejects.toThrow('TimeoutError'); - expect(txCallCount()).toBe(2); + it('retries a timed-out request under the fetch policy, then rejects and evicts the cache', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const { adapter } = setupTest(); + // The signal is already aborted, so every attempt times out. + abortController.abort(); + mockFetch.mockImplementation( + async (_url: unknown, init?: { signal?: AbortSignal }) => { + if (init?.signal?.aborted) { + throw new Error('TimeoutError'); + } + return okResponse({ vin: [] }); + }, + ); + + const first = adapter + .getTransactionSenders('bitcoin', 'txid') + .catch((error: unknown) => error); + await jest.runAllTimersAsync(); + expect(await first).toMatchObject({ + message: 'Failed to fetch transaction', + }); + // 1 attempt + 3 policy retries. + const attempts = txCallCount(); + expect(attempts).toBe(4); + + // The failure is not cached, so the next lookup fetches again. + const second = adapter + .getTransactionSenders('bitcoin', 'txid') + .catch((error: unknown) => error); + await jest.runAllTimersAsync(); + expect(await second).toMatchObject({ + message: 'Failed to fetch transaction', + }); + expect(txCallCount()).toBeGreaterThan(attempts); + } finally { + jest.useRealTimers(); + } }); it('skips inputs without a resolved prevout (e.g. coinbase)', async () => { @@ -201,9 +394,9 @@ describe('EsploraClientAdapter', () => { expect(txCallCount()).toBe(1); }); - it('throws and does not cache the failure for a non-2xx response', async () => { + it('throws without retrying and does not cache the failure for a non-retryable non-2xx response', async () => { const { adapter } = setupTest(); - mockFetch.mockResolvedValue({ ok: false, status: 429 }); + mockFetch.mockResolvedValue({ ok: false, status: 404 }); await expect( adapter.getTransactionSenders('bitcoin', 'txid'), @@ -213,5 +406,222 @@ describe('EsploraClientAdapter', () => { ).rejects.toThrow('Failed to fetch transaction'); expect(txCallCount()).toBe(2); }); + + it('retries a transient non-2xx response and returns the recovered result', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const { adapter } = setupTest(); + mockFetch + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce( + okResponse({ + vin: [{ prevout: { scriptpubkey_address: 'bc1qsender1' } }], + }), + ); + + const pending = adapter.getTransactionSenders('bitcoin', 'txid'); + await jest.runAllTimersAsync(); + + expect(await pending).toStrictEqual(['bc1qsender1']); + expect(txCallCount()).toBe(2); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('extendDiscoveryScan (v5: complete a discovery-gap scan without refetching it)', () => { + const mockFetch = jest.fn(); + let abortController: AbortController; + + beforeEach(() => { + global.fetch = mockFetch; + abortController = new AbortController(); + jest + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(abortController.signal); + }); + + afterEach(() => { + mockFetch.mockReset(); + }); + + const okResponse = ( + body: unknown, + ): { ok: boolean; json: () => Promise } => ({ + ok: true, + json: async (): Promise => body, + }); + + // Script bytes encode the keychain and index, so each address has a + // distinct script hash that the tests can map back. + const scriptBytes = (keychain: Keychain, index: number): Uint8Array => + new Uint8Array([keychain === 'external' ? 0 : 1, index]); + + const setupAccount = ( + account: BitcoinAccount, + indexes: Record, + ): void => { + jest + .mocked(account.lastUsedIndex) + .mockImplementation((keychain) => indexes[keychain].used); + jest + .mocked(account.lastRevealedIndex) + .mockImplementation((keychain) => indexes[keychain].revealed); + jest.mocked(account.peekKeychainAddress).mockImplementation( + (keychain, index) => + ({ + address: { + // eslint-disable-next-line @typescript-eslint/naming-convention + script_pubkey: { as_bytes: () => scriptBytes(keychain, index) }, + }, + }) as unknown as AddressInfo, + ); + }; + + const checkedIndexes = (account: BitcoinAccount): [Keychain, number][] => + jest + .mocked(account.peekKeychainAddress) + .mock.calls.map(([keychain, index]) => [keychain, index]); + + const range = ( + keychain: Keychain, + first: number, + last: number, + ): [Keychain, number][] => + Array.from({ length: last - first + 1 }, (_unused, offset) => [ + keychain, + first + offset, + ]); + + it('computes the Esplora script hash (reversed SHA-256)', async () => { + expect(await toScripthash(new Uint8Array())).toBe( + '55b852781b9995a44c939b64e441ae2724b96f99c8f4fb9a141cfc9842c4b0e3', + ); + }); + + it('checks only the addresses the standard gap adds and skips the full scan when they are unused (run B-v4 account 0)', async () => { + const { adapter, mockEsploraClient, account } = setupTest(); + // Account 0 of run B-v4: activity at index 0 of both keychains (the + // receive keychain has one more revealed address). The discovery scan + // fetched up to index 5 of each keychain (last active + 5). + setupAccount(account, { + external: { used: 0, revealed: 1 }, + internal: { used: 0, revealed: 0 }, + }); + mockFetch.mockResolvedValue(okResponse([])); + + await adapter.extendDiscoveryScan(account); + + expect(checkedIndexes(account)).toStrictEqual([ + ...range('external', 6, 21), + ...range('internal', 6, 20), + ]); + expect(mockFetch).toHaveBeenCalledTimes(31); + // The standard-gap full scan refetched both keychains from index 0 + // (50 requests in run B-v4). + expect(mockEsploraClient.full_scan).not.toHaveBeenCalled(); + const hash = await toScripthash(scriptBytes('external', 6)); + expect(mockFetch).toHaveBeenCalledWith( + `https://bitcoin.example/scripthash/${hash}/txs`, + { signal: abortController.signal }, + ); + expect(AbortSignal.timeout).toHaveBeenCalledWith( + GAP_CHECK_REQUEST_TIMEOUT_MS, + ); + expect(account.applyUpdate).not.toHaveBeenCalled(); + }); + + it('covers indexes discovery..scan-1 of a keychain without activity', async () => { + const { adapter, account } = setupTest(); + setupAccount(account, { + external: { used: 3 }, + internal: {}, + }); + mockFetch.mockResolvedValue(okResponse([])); + + await adapter.extendDiscoveryScan(account); + + expect(checkedIndexes(account)).toStrictEqual([ + ...range('external', 9, 23), + ...range('internal', 5, 19), + ]); + }); + + it('runs the standard-gap full scan as soon as a checked address has history', async () => { + const { adapter, mockEsploraClient, account, mockRequest } = setupTest(); + setupAccount(account, { + external: { used: 0, revealed: 0 }, + internal: { used: 0, revealed: 0 }, + }); + const usedHash = await toScripthash(scriptBytes('external', 8)); + mockFetch.mockImplementation(async (url: string) => + okResponse(url.includes(usedHash) ? [{ txid: 'tx' }] : []), + ); + + await adapter.extendDiscoveryScan(account); + + // Batches of `parallelRequests`: 6..10 holds the used index 8, so the + // following batches are not requested. + expect(mockFetch).toHaveBeenCalledTimes(5); + expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1); + expect(mockEsploraClient.full_scan).toHaveBeenCalledWith( + mockRequest, + 20, + 5, + ); + }); + + it('falls back to the standard-gap full scan when the check fails', async () => { + const { adapter, mockEsploraClient, account } = setupTest(); + setupAccount(account, { + external: { used: 0 }, + internal: {}, + }); + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + + await adapter.extendDiscoveryScan(account); + + expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1); + }); + + it('falls back to the standard-gap full scan when the addresses cannot be derived', async () => { + const { adapter, mockEsploraClient, account } = setupTest(); + jest.mocked(account.lastUsedIndex).mockImplementation(() => { + throw new Error('unsupported'); + }); + + await adapter.extendDiscoveryScan(account); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1); + }); + + it('treats an unexpected response body as history', async () => { + const { adapter, mockEsploraClient, account } = setupTest(); + setupAccount(account, { + external: { used: 0 }, + internal: {}, + }); + mockFetch.mockResolvedValue(okResponse({ error: 'unexpected' })); + + await adapter.extendDiscoveryScan(account); + + expect(mockEsploraClient.full_scan).toHaveBeenCalledTimes(1); + }); + + it('propagates a failure of the fallback full scan', async () => { + const { adapter, mockEsploraClient, account } = setupTest(); + setupAccount(account, { + external: { used: 0 }, + internal: {}, + }); + mockFetch.mockResolvedValue(okResponse([{ txid: 'tx' }])); + mockEsploraClient.full_scan.mockRejectedValue(new Error('boom')); + + await expect(adapter.extendDiscoveryScan(account)).rejects.toThrow( + 'Failed to perform initial full scan', + ); + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 3ae7b6ad4..debf11d91 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -1,15 +1,25 @@ import type { FeeEstimates, Network, + ScriptBuf, Transaction, } from '@metamask/bitcoindevkit'; import { EsploraClient } from '@metamask/bitcoindevkit'; +import { BtcScope } from '@metamask/keyring-api'; +import type { FetchPolicy, Logger } from '@metamask/snap-networks-utils'; +import { + createFetchPolicy, + createFetchPolicyError, + executeWithFetchPolicy, + withEndpointFallback, +} from '@metamask/snap-networks-utils'; import { ExternalServiceError } from '../entities'; import type { BitcoinAccount, ChainConfig, BlockchainClient, + Keychain, } from '../entities'; /** @@ -51,37 +61,206 @@ function toEsploraRestUrl(url: string): string { */ export const SENDER_REQUEST_TIMEOUT_MS = 10_000; +/** + * Upper bound on a single address history request of the standard-gap check + * (see `extendDiscoveryScan`), for the same reason as + * {@link SENDER_REQUEST_TIMEOUT_MS}. + */ +export const GAP_CHECK_REQUEST_TIMEOUT_MS = 10_000; + +const KEYCHAINS: Keychain[] = ['external', 'internal']; + +/** + * Computes the Electrum/Esplora script hash of an output script: the SHA-256 + * of the script, byte-reversed, hex-encoded (the key of `/scripthash/:hash`). + * + * @param script - The output script bytes. + * @returns The script hash. + */ +export async function toScripthash(script: Uint8Array): Promise { + const digest = new Uint8Array( + await globalThis.crypto.subtle.digest('SHA-256', script), + ); + return Array.from(digest.reverse(), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} + +/** + * Upper bound on a whole sync (all its requests, retries and the fallback). + * The WASM client sets no timeout on its requests: a request stalled by a + * suspended or broken connection could otherwise never settle, keeping the + * coalesced cron sync (and every later cron run joining it) stuck. + */ +export const SYNC_TIMEOUT_MS = 60_000; + +/** + * Upper bound on a whole full scan, for the same reason as + * {@link SYNC_TIMEOUT_MS}: it also releases the single-flight scan guard. + */ +export const FULL_SCAN_TIMEOUT_MS = 120_000; + +/** + * Rejects with a `TimeoutError` (a transient failure for the fetch policies) + * if the operation does not settle in time. The abandoned operation's result + * is discarded: it is never applied to the wallet. + * + * @param operation - The operation. + * @param timeoutMs - The timeout, in milliseconds. + * @param label - Name of the operation, for the error message. + * @returns The operation result. + */ +export async function withOperationTimeout( + operation: Promise, + timeoutMs: number, + label: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error(`${label} timed out after ${timeoutMs} ms`); + error.name = 'TimeoutError'; + reject(error); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + clearTimeout(timer); + } +} + +/** + * One Esplora endpoint: the WASM client, the REST base URL, and the fetch + * policies guarding them. + */ +type EsploraEndpoint = { + label: 'primary' | 'fallback'; + client: EsploraClient; + restUrl: string; + /** Guards `full_scan` / `sync` (the WASM client already retries each request). */ + scanPolicy: FetchPolicy; + /** Guards REST lookups and fee estimates. */ + restPolicy: FetchPolicy; +}; + +const NETWORKS: Network[] = [ + 'bitcoin', + 'testnet', + 'testnet4', + 'signet', + 'regtest', +]; + +const CHAIN_IDS: Record = { + bitcoin: BtcScope.Mainnet, + testnet: BtcScope.Testnet, + testnet4: BtcScope.Testnet4, + signet: BtcScope.Signet, + regtest: BtcScope.Regtest, +}; + +const noopLogger: Pick = { + warn: () => undefined, +}; + export class EsploraClientAdapter implements BlockchainClient { // Should be a Repository but we don't support custom networks so we can save in memory from config values - readonly #clients: Record; + readonly #endpoints: Record; + + readonly #fallbackEndpoints: Partial>; readonly #config: ChainConfig; - readonly #restUrls: Record; + readonly #logger: Pick; // Funding addresses are resolved while mapping transactions, which runs on // every sync/event emission. Cache by network + txid so a receive is only // looked up once instead of on each notification. readonly #sendersCache = new Map>(); - constructor(config: ChainConfig) { - this.#clients = { - bitcoin: new EsploraClient(config.url.bitcoin, config.maxRetries), - testnet: new EsploraClient(config.url.testnet, config.maxRetries), - testnet4: new EsploraClient(config.url.testnet4, config.maxRetries), - signet: new EsploraClient(config.url.signet, config.maxRetries), - regtest: new EsploraClient(config.url.regtest, config.maxRetries), - }; + constructor(config: ChainConfig, logger: Pick = noopLogger) { + this.#config = config; + this.#logger = logger; + + const endpoints = {} as Record; + const fallbackEndpoints: Partial> = {}; + for (const network of NETWORKS) { + endpoints[network] = this.#createEndpoint( + network, + config.url[network], + 'primary', + ); + const fallbackUrl = config.fallbackUrl?.[network]; + if (fallbackUrl && fallbackUrl !== config.url[network]) { + fallbackEndpoints[network] = this.#createEndpoint( + network, + fallbackUrl, + 'fallback', + ); + } + } + this.#endpoints = endpoints; + this.#fallbackEndpoints = fallbackEndpoints; + } - this.#restUrls = { - bitcoin: toEsploraRestUrl(config.url.bitcoin), - testnet: toEsploraRestUrl(config.url.testnet), - testnet4: toEsploraRestUrl(config.url.testnet4), - signet: toEsploraRestUrl(config.url.signet), - regtest: toEsploraRestUrl(config.url.regtest), + #createEndpoint( + network: Network, + url: string, + label: EsploraEndpoint['label'], + ): EsploraEndpoint { + const chainIdCaip = CHAIN_IDS[network]; + return { + label, + client: new EsploraClient(url, this.#config.maxRetries), + restUrl: toEsploraRestUrl(url), + scanPolicy: createFetchPolicy({ + service: `esplora_${label}_scan`, + chainIdCaip, + logger: this.#logger, + maxRetries: 1, + }), + restPolicy: createFetchPolicy({ + service: `esplora_${label}_rest`, + chainIdCaip, + logger: this.#logger, + }), }; + } - this.#config = config; + /** + * Runs a read against the primary endpoint under its fetch policy and, on a + * transport-level failure, against the configured fallback endpoint. + * + * @param network - The network. + * @param policy - Which of the endpoint's policies guards the call. + * @param operation - The read, given the endpoint to use. + * @returns The read result. + */ + async #read( + network: Network, + policy: 'scanPolicy' | 'restPolicy', + operation: (endpoint: EsploraEndpoint) => Promise, + ): Promise { + const primary = this.#endpoints[network]; + const fallback = this.#fallbackEndpoints[network]; + return withEndpointFallback( + async (): Promise => + executeWithFetchPolicy(primary[policy], async () => operation(primary)), + fallback + ? async (): Promise => + executeWithFetchPolicy(fallback[policy], async () => + operation(fallback), + ) + : undefined, + { + onFallback: (error): void => + this.#logger.warn( + `[esplora ${network}] primary endpoint failed, using fallback`, + error, + ), + }, + ); } async fullScan( @@ -93,11 +272,17 @@ export class EsploraClientAdapter implements BlockchainClient { mode === 'discovery' ? this.#config.stopGap.discovery : this.#config.stopGap.scan; - const request = account.startFullScan(); - const update = await this.#clients[account.network].full_scan( - request, - stopGap, - this.#config.parallelRequests, + // The WASM client consumes the request, so each attempt builds its own. + const update = await withOperationTimeout( + this.#read(account.network, 'scanPolicy', async ({ client }) => + client.full_scan( + account.startFullScan(), + stopGap, + this.#config.parallelRequests, + ), + ), + FULL_SCAN_TIMEOUT_MS, + 'Full scan', ); account.applyUpdate(update); } catch (error) { @@ -109,12 +294,106 @@ export class EsploraClientAdapter implements BlockchainClient { } } + async extendDiscoveryScan(account: BitcoinAccount): Promise { + let hasHistory: boolean; + try { + hasHistory = await this.#hasHistoryInGapExtension(account); + } catch (error) { + // Never leave the account with the discovery gap only: fall back to the + // standard-gap full scan (which surfaces its own failure). + this.#logger.warn( + `[esplora ${account.network}] standard-gap check failed, running the standard-gap scan`, + error, + ); + hasHistory = true; + } + if (hasHistory) { + await this.fullScan(account, 'scan'); + } + } + + /** + * Lists, per keychain, the scripts a standard-gap full scan covers beyond + * the discovery-gap scan just applied to the account. A gap scan covers up + * to `last active index + stop gap` (indexes `0..stopGap-1` when nothing is + * active), so the discovery scan already fetched up to + * `lastUsed + discovery`. The upper bound also accounts for revealed + * addresses, so the check never covers less than the standard-gap scan. + * + * @param account - The account. + * @returns The scripts to check. + */ + #gapExtensionScripts(account: BitcoinAccount): ScriptBuf[] { + const { discovery, scan } = this.#config.stopGap; + const scripts: ScriptBuf[] = []; + for (const keychain of KEYCHAINS) { + const lastUsed = account.lastUsedIndex(keychain) ?? -1; + const lastRevealed = account.lastRevealedIndex(keychain) ?? -1; + const first = lastUsed + discovery + 1; + const last = Math.max(lastUsed, lastRevealed) + scan; + for (let index = first; index <= last; index++) { + scripts.push( + account.peekKeychainAddress(keychain, index).address.script_pubkey, + ); + } + } + return scripts; + } + + async #hasHistoryInGapExtension(account: BitcoinAccount): Promise { + const scripts = this.#gapExtensionScripts(account); + const batchSize = Math.max(1, this.#config.parallelRequests); + for (let start = 0; start < scripts.length; start += batchSize) { + const results = await Promise.all( + scripts + .slice(start, start + batchSize) + .map(async (script) => + this.#scriptHasHistory(account.network, script), + ), + ); + if (results.some(Boolean)) { + return true; + } + } + return false; + } + + async #scriptHasHistory( + network: Network, + script: ScriptBuf, + ): Promise { + const scripthash = await toScripthash(script.as_bytes()); + const history = await this.#read( + network, + 'restPolicy', + async ({ restUrl }) => { + const response = await fetch( + `${restUrl}/scripthash/${scripthash}/txs`, + { + signal: AbortSignal.timeout(GAP_CHECK_REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw createFetchPolicyError(`HTTP ${response.status}`, { + httpStatus: response.status, + }); + } + return (await response.json()) as unknown; + }, + ); + // Anything but an empty list counts as history: the caller then runs the + // standard-gap full scan. + return !Array.isArray(history) || history.length > 0; + } + async sync(account: BitcoinAccount): Promise { try { - const request = account.startSync(); - const update = await this.#clients[account.network].sync( - request, - this.#config.parallelRequests, + const update = await withOperationTimeout( + this.#read(account.network, 'scanPolicy', async ({ client }) => + client.sync(account.startSync(), this.#config.parallelRequests), + ), + SYNC_TIMEOUT_MS, + 'Sync', ); account.applyUpdate(update); } catch (error) { @@ -128,7 +407,9 @@ export class EsploraClientAdapter implements BlockchainClient { async broadcast(network: Network, transaction: Transaction): Promise { try { - await this.#clients[network].broadcast(transaction); + // Never retried nor sent to a fallback: a retry could broadcast twice + // and leak the transaction to a third party. + await this.#endpoints[network].client.broadcast(transaction); } catch (error) { throw new ExternalServiceError( `Failed to broadcast transaction`, @@ -140,7 +421,9 @@ export class EsploraClientAdapter implements BlockchainClient { async getFeeEstimates(network: Network): Promise { try { - return await this.#clients[network].get_fee_estimates(); + return await this.#read(network, 'restPolicy', async ({ client }) => + client.get_fee_estimates(), + ); } catch (error) { throw new ExternalServiceError( `Failed to fetch fee estimates`, @@ -193,19 +476,32 @@ export class EsploraClientAdapter implements BlockchainClient { network: Network, txid: string, ): Promise { - const response = await fetch(`${this.#restUrls[network]}/tx/${txid}`, { - signal: AbortSignal.timeout(SENDER_REQUEST_TIMEOUT_MS), - }); - - if (!response.ok) { - throw new ExternalServiceError(`Failed to fetch transaction`, { + let transaction: EsploraTx; + try { + transaction = await this.#read( network, - txid, - status: response.status, - }); + 'restPolicy', + async ({ restUrl }) => { + const response = await fetch(`${restUrl}/tx/${txid}`, { + signal: AbortSignal.timeout(SENDER_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw createFetchPolicyError(`HTTP ${response.status}`, { + httpStatus: response.status, + }); + } + return (await response.json()) as EsploraTx; + }, + ); + } catch (error) { + const status = (error as { httpStatus?: number }).httpStatus; + throw new ExternalServiceError( + `Failed to fetch transaction`, + status === undefined ? { network, txid } : { network, txid, status }, + error, + ); } - const transaction = (await response.json()) as EsploraTx; // Self-transfers and consolidations can repeat the same funding address; // dedupe while preserving input order. const senders = [ diff --git a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.test.ts index bf6343c2b..9b7701c65 100644 --- a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.test.ts @@ -69,4 +69,16 @@ describe('StoredAccountAdapter', () => { expect(() => adapter().isMine(mockScript)).toThrow(WalletError); }); }); + + describe('gap scan indexes', () => { + it('throws a WalletError since stored metadata cannot derive addresses', () => { + expect(() => adapter().peekKeychainAddress('internal', 0)).toThrow( + WalletError, + ); + expect(() => adapter().lastUsedIndex('external')).toThrow(WalletError); + expect(() => adapter().lastRevealedIndex('external')).toThrow( + WalletError, + ); + }); + }); }); diff --git a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts index edb5164c6..d2982b27d 100644 --- a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts @@ -21,6 +21,7 @@ import type { AccountMetadata, AccountState, BitcoinAccount, + Keychain, TransactionBuilder, } from '../entities'; @@ -103,6 +104,18 @@ export class StoredAccountAdapter implements BitcoinAccount { return this.#unsupported(); } + peekKeychainAddress(_keychain: Keychain, _index: number): AddressInfo { + return this.#unsupported(); + } + + lastUsedIndex(_keychain: Keychain): number | undefined { + return this.#unsupported(); + } + + lastRevealedIndex(_keychain: Keychain): number | undefined { + return this.#unsupported(); + } + nextUnusedAddress(): AddressInfo { return this.#unsupported(); } @@ -123,6 +136,13 @@ export class StoredAccountAdapter implements BitcoinAccount { this.#unsupported(); } + hasSynchronized(): boolean { + // Metadata-only view: the wallet (and its checkpoint) is not loaded. + // Callers that need the sync state load the full account; never report + // this view as unsynchronized, which would hide a known balance. + return true; + } + takeStaged(): ChangeSet | undefined { return this.#unsupported(); } diff --git a/packages/bitcoin-wallet-snap/src/store/FullScanQueue.test.ts b/packages/bitcoin-wallet-snap/src/store/FullScanQueue.test.ts new file mode 100644 index 000000000..94e516d0b --- /dev/null +++ b/packages/bitcoin-wallet-snap/src/store/FullScanQueue.test.ts @@ -0,0 +1,231 @@ +import type { SnapClient } from '../entities'; +import { + FULL_SCAN_QUEUE_KEY, + FULL_SCAN_RETRY_BASE_MS, + FULL_SCAN_RETRY_KEY, + FULL_SCAN_RETRY_MAX_MS, + FullScanQueue, + getFullScanRetryDelayMs, +} from './FullScanQueue'; + +const clone = (value: Value): Value => + JSON.parse(JSON.stringify(value)) as Value; + +const createStateClient = ( + initial: Record = {}, +): { client: SnapClient; state: Record } => { + const state: Record = clone(initial); + const client = { + getState: jest.fn(async (key: string) => + Object.hasOwn(state, key) ? clone(state[key]) : null, + ), + setState: jest.fn(async (key: string, value: unknown) => { + state[key] = clone(value); + }), + } as unknown as SnapClient; + return { client, state }; +}; + +describe('getFullScanRetryDelayMs', () => { + it('grows exponentially from the base delay and caps it', () => { + expect(getFullScanRetryDelayMs(1)).toBe(FULL_SCAN_RETRY_BASE_MS); + expect(getFullScanRetryDelayMs(2)).toBe(FULL_SCAN_RETRY_BASE_MS * 2); + expect(getFullScanRetryDelayMs(3)).toBe(FULL_SCAN_RETRY_BASE_MS * 4); + expect(getFullScanRetryDelayMs(50)).toBe(FULL_SCAN_RETRY_MAX_MS); + // Bounded to minutes, not half an hour: an account whose first scan + // failed must recover soon after the network comes back. + expect(FULL_SCAN_RETRY_MAX_MS).toBe(5 * 60_000); + expect(getFullScanRetryDelayMs(5)).toBe(FULL_SCAN_RETRY_MAX_MS); + }); +}); + +describe('FullScanQueue', () => { + let now: number; + const clock = (): number => now; + + beforeEach(() => { + now = 1_000_000; + }); + + it('initializes the queue with every live account when it does not exist (repair behaviour)', async () => { + const { client, state } = createStateClient(); + const queue = new FullScanQueue(client, clock); + + expect(await queue.due(['a', 'b', 'c'], 2)).toStrictEqual(['a', 'b']); + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ + pending: ['a', 'b', 'c'], + }); + }); + + it('prunes accounts that no longer exist', async () => { + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['a', 'gone'] }, + [FULL_SCAN_RETRY_KEY]: { + gone: { attempts: 1, nextAttemptAt: 0 }, + }, + }); + const queue = new FullScanQueue(client, clock); + + expect(await queue.due(['a'], 5)).toStrictEqual(['a']); + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ pending: ['a'] }); + expect(state[FULL_SCAN_RETRY_KEY]).toStrictEqual({}); + }); + + it('moves a failed account to the tail so it does not block the others', async () => { + const { client } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['a', 'b'] }, + }); + const queue = new FullScanQueue(client, clock); + + await queue.markFailed('a', new Error('unreachable')); + + expect(await queue.due(['a', 'b'], 2)).toStrictEqual(['b']); + }); + + it('retries a failed account once its backoff has elapsed, with growing delays', async () => { + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['a'] }, + }); + const queue = new FullScanQueue(client, clock); + + await queue.markFailed('a', new Error('boom')); + expect(await queue.due(['a'], 1)).toStrictEqual([]); + now += FULL_SCAN_RETRY_BASE_MS; + expect(await queue.due(['a'], 1)).toStrictEqual(['a']); + + await queue.markFailed('a', 'again'); + expect(state[FULL_SCAN_RETRY_KEY]).toStrictEqual({ + a: { + attempts: 2, + nextAttemptAt: now + FULL_SCAN_RETRY_BASE_MS * 2, + lastError: 'again', + }, + }); + }); + + it('removes an account and its retry metadata after a successful scan', async () => { + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['a', 'b'] }, + [FULL_SCAN_RETRY_KEY]: { + a: { attempts: 1, nextAttemptAt: 0, initial: true }, + }, + }); + const queue = new FullScanQueue(client, clock); + + await queue.markSucceeded('a'); + + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ pending: ['b'] }); + expect(state[FULL_SCAN_RETRY_KEY]).toStrictEqual({}); + expect(await queue.isInitialScanPending('a')).toBe(false); + }); + + it('flags new accounts as pending their initial scan until it succeeds, across failures', async () => { + const { client } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: [] }, + }); + const queue = new FullScanQueue(client, clock); + + await queue.enqueueInitial(['new']); + expect(await queue.isInitialScanPending('new')).toBe(true); + expect(await queue.due(['new'], 2)).toStrictEqual(['new']); + + await queue.markFailed('new', new Error('Failed to fetch')); + expect(await queue.isInitialScanPending('new')).toBe(true); + + await queue.markSucceeded('new'); + expect(await queue.isInitialScanPending('new')).toBe(false); + }); + + it('records initial accounts even before the queue exists, and due() then includes them', async () => { + const { client, state } = createStateClient(); + const queue = new FullScanQueue(client, clock); + + await queue.enqueueInitial(['new']); + + expect(state[FULL_SCAN_QUEUE_KEY]).toBeUndefined(); + expect(await queue.isInitialScanPending('new')).toBe(true); + expect(await queue.due(['old', 'new'], 5)).toStrictEqual(['old', 'new']); + }); + + it('does nothing when enqueueing or ensuring no account', async () => { + const { client } = createStateClient(); + const queue = new FullScanQueue(client, clock); + + await queue.enqueueInitial([]); + expect(await queue.ensureQueued([])).toStrictEqual([]); + expect(client.setState).not.toHaveBeenCalled(); + }); + + describe('ensureQueued', () => { + it('queues never-synchronized accounts and flags them as initial', async () => { + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: [] }, + }); + const queue = new FullScanQueue(client, clock); + + expect(await queue.ensureQueued(['legacy'])).toStrictEqual(['legacy']); + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ + pending: ['legacy'], + }); + expect(await queue.isInitialScanPending('legacy')).toBe(true); + }); + + it('keeps the position and backoff of an already-queued account', async () => { + const retryEntry = { attempts: 3, nextAttemptAt: now + 60_000 }; + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['legacy', 'other'] }, + [FULL_SCAN_RETRY_KEY]: { legacy: retryEntry }, + }); + const queue = new FullScanQueue(client, clock); + + expect(await queue.ensureQueued(['legacy'])).toStrictEqual([]); + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ + pending: ['legacy', 'other'], + }); + expect(state[FULL_SCAN_RETRY_KEY]).toStrictEqual({ + legacy: { ...retryEntry, initial: true }, + }); + expect(await queue.due(['legacy', 'other'], 2)).toStrictEqual(['other']); + }); + + it('does not write when nothing changes', async () => { + const { client } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['legacy'] }, + [FULL_SCAN_RETRY_KEY]: { + legacy: { attempts: 0, nextAttemptAt: 0, initial: true }, + }, + }); + const queue = new FullScanQueue(client, clock); + + await queue.ensureQueued(['legacy']); + + expect(client.setState).not.toHaveBeenCalled(); + }); + }); + + it('serializes concurrent operations so no update is lost', async () => { + const { client, state } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: ['a', 'b', 'c'] }, + }); + const queue = new FullScanQueue(client, clock); + + await Promise.all([ + queue.markSucceeded('a'), + queue.markSucceeded('b'), + queue.markFailed('c', new Error('x')), + ]); + + expect(state[FULL_SCAN_QUEUE_KEY]).toStrictEqual({ pending: ['c'] }); + }); + + it('ignores a corrupted stored state', async () => { + const { client } = createStateClient({ + [FULL_SCAN_QUEUE_KEY]: { pending: 'not-an-array' }, + [FULL_SCAN_RETRY_KEY]: 42, + }); + const queue = new FullScanQueue(client, clock); + + expect(await queue.due(['a'], 1)).toStrictEqual(['a']); + expect(await queue.isInitialScanPending('a')).toBe(false); + }); +}); diff --git a/packages/bitcoin-wallet-snap/src/store/FullScanQueue.ts b/packages/bitcoin-wallet-snap/src/store/FullScanQueue.ts new file mode 100644 index 000000000..3ae758f4c --- /dev/null +++ b/packages/bitcoin-wallet-snap/src/store/FullScanQueue.ts @@ -0,0 +1,302 @@ +import type { Json } from '@metamask/utils'; +import { + array, + boolean, + is, + number, + object, + optional, + record, + string, +} from 'superstruct'; + +import type { SnapClient } from '../entities'; + +/** + * State key of the full-scan queue. The name predates this class: it was the + * one-off "repair previously-unwatched accounts" list, whose shape + * (`{ pending: string[] }`) is kept so existing installs continue where they + * left off. + */ +export const FULL_SCAN_QUEUE_KEY = 'rescanV1'; + +/** + * State key of the per-account retry metadata of the full-scan queue. + */ +export const FULL_SCAN_RETRY_KEY = 'fullScanRetryV1'; + +/** First retry delay after a failed full scan. */ +export const FULL_SCAN_RETRY_BASE_MS = 30_000; + +/** + * Upper bound of the full scan retry delay. Kept short: an account whose + * first scan failed has an unknown balance until a scan succeeds, so after + * an outage it must be retried within minutes (on-demand scans triggered by + * balance requests and successful syncs usually recover it sooner). + */ +export const FULL_SCAN_RETRY_MAX_MS = 5 * 60_000; + +const QueueState = object({ + pending: array(string()), +}); + +const RetryEntryStruct = object({ + attempts: number(), + nextAttemptAt: number(), + initial: optional(boolean()), + lastError: optional(string()), +}); + +const RetryState = record(string(), RetryEntryStruct); + +export type FullScanRetryEntry = { + attempts: number; + nextAttemptAt: number; + /** + * Whether the account has never completed a full scan since it was created + * (as opposed to a legacy account queued for a repair scan). Its balance is + * unknown, not zero, until the scan succeeds. + */ + initial?: boolean; + lastError?: string; +}; + +/** + * Computes the delay before the next full scan attempt. + * + * @param attempts - Number of failed attempts so far (>= 1). + * @returns The delay in milliseconds. + */ +export function getFullScanRetryDelayMs(attempts: number): number { + const exponent = Math.max(0, attempts - 1); + return Math.min( + FULL_SCAN_RETRY_BASE_MS * 2 ** Math.min(exponent, 16), + FULL_SCAN_RETRY_MAX_MS, + ); +} + +/** + * Persistent queue of accounts that still need a full scan. + * + * - Accounts are processed in queue order, skipping those whose backoff has + * not elapsed, so one failing account never blocks the others (no + * head-of-line blocking). + * - A failed account is moved to the tail and retried with exponential + * backoff ({@link getFullScanRetryDelayMs}). + * - When the queue state does not exist yet, every existing account is + * queued (the original `rescanV1` repair behaviour). + * + * All operations are serialized so concurrent keyring requests and cron runs + * don't overwrite each other's state. + */ +export class FullScanQueue { + readonly #snapClient: SnapClient; + + readonly #now: () => number; + + #tail: Promise = Promise.resolve(); + + constructor(snapClient: SnapClient, now: () => number = Date.now) { + this.#snapClient = snapClient; + this.#now = now; + } + + async #serialize(task: () => Promise): Promise { + const run = this.#tail.then(task, task); + this.#tail = run.catch(() => undefined); + return run; + } + + async #readPending(): Promise { + const stored = await this.#snapClient.getState(FULL_SCAN_QUEUE_KEY); + return is(stored, QueueState) ? stored.pending : null; + } + + async #readRetry(): Promise> { + const stored = await this.#snapClient.getState(FULL_SCAN_RETRY_KEY); + return is(stored, RetryState) ? { ...stored } : {}; + } + + async #write( + pending: string[], + retry?: Record, + ): Promise { + await this.#snapClient.setState(FULL_SCAN_QUEUE_KEY, { pending }); + if (retry) { + await this.#snapClient.setState( + FULL_SCAN_RETRY_KEY, + retry as unknown as Json, + ); + } + } + + /** + * Queues newly created accounts for their first full scan. + * + * @param accountIds - The new account IDs. + */ + async enqueueInitial(accountIds: string[]): Promise { + if (accountIds.length === 0) { + return; + } + await this.#serialize(async () => { + const pending = await this.#readPending(); + const retry = await this.#readRetry(); + const now = this.#now(); + for (const id of accountIds) { + if (pending && !pending.includes(id)) { + pending.push(id); + } + retry[id] = { attempts: 0, nextAttemptAt: now, initial: true }; + } + if (pending) { + await this.#write(pending, retry); + } else { + // The queue doesn't exist yet: `due()` will initialize it with every + // live account, these included. + await this.#snapClient.setState( + FULL_SCAN_RETRY_KEY, + retry as unknown as Json, + ); + } + }); + } + + /** + * Queues accounts that never synchronized with the chain (e.g. created + * before this queue existed, whose first scan failed and was never + * retried). Accounts already queued keep their position and backoff. + * They are flagged as `initial`: their balance is unknown until the scan + * succeeds. + * + * @param accountIds - IDs of the never-synchronized accounts. + * @returns The IDs that were newly queued. + */ + async ensureQueued(accountIds: string[]): Promise { + if (accountIds.length === 0) { + return []; + } + return this.#serialize(async () => { + const pending = (await this.#readPending()) ?? []; + const retry = await this.#readRetry(); + const now = this.#now(); + const added: string[] = []; + let retryChanged = false; + for (const id of accountIds) { + if (!pending.includes(id)) { + pending.push(id); + added.push(id); + } + if (!retry[id]) { + retry[id] = { attempts: 0, nextAttemptAt: now, initial: true }; + retryChanged = true; + } else if (!retry[id].initial) { + retry[id] = { ...retry[id], initial: true }; + retryChanged = true; + } + } + if (added.length > 0 || retryChanged) { + await this.#write(pending, retryChanged ? retry : undefined); + } + return added; + }); + } + + /** + * @param accountId - The account ID. + * @returns Whether the account never completed its first full scan. + */ + async isInitialScanPending(accountId: string): Promise { + const retry = await this.#readRetry(); + return retry[accountId]?.initial === true; + } + + /** + * Returns the queued accounts that are due for a scan, pruning accounts + * that no longer exist. Initializes the queue with every live account when + * it doesn't exist yet. + * + * @param liveAccountIds - IDs of the accounts that currently exist. + * @param max - Maximum number of accounts to return. + * @returns The due account IDs, in queue order. + */ + async due(liveAccountIds: string[], max: number): Promise { + return this.#serialize(async () => { + const live = new Set(liveAccountIds); + const stored = await this.#readPending(); + const pending = stored + ? stored.filter((id) => live.has(id)) + : [...liveAccountIds]; + const retry = await this.#readRetry(); + const retryIds = Object.keys(retry); + const staleRetry = retryIds.filter((id) => !live.has(id)); + for (const id of staleRetry) { + delete retry[id]; + } + if (pending.length !== stored?.length || staleRetry.length > 0) { + await this.#write(pending, staleRetry.length > 0 ? retry : undefined); + } + + const now = this.#now(); + return pending + .filter((id) => (retry[id]?.nextAttemptAt ?? 0) <= now) + .slice(0, max); + }); + } + + /** + * Removes an account from the queue after a successful full scan. + * + * @param accountId - The account ID. + */ + async markSucceeded(accountId: string): Promise { + await this.#serialize(async () => { + const pending = await this.#readPending(); + const retry = await this.#readRetry(); + const hadRetry = Object.hasOwn(retry, accountId); + delete retry[accountId]; + if (pending === null) { + if (hadRetry) { + await this.#snapClient.setState( + FULL_SCAN_RETRY_KEY, + retry as unknown as Json, + ); + } + return; + } + await this.#write( + pending.filter((id) => id !== accountId), + hadRetry ? retry : undefined, + ); + }); + } + + /** + * Records a failed full scan: moves the account to the tail of the queue + * and schedules its next attempt with exponential backoff. + * + * @param accountId - The account ID. + * @param error - The failure. + */ + async markFailed(accountId: string, error: unknown): Promise { + await this.#serialize(async () => { + const pending = (await this.#readPending()) ?? []; + const retry = await this.#readRetry(); + const previous = retry[accountId]; + const attempts = (previous?.attempts ?? 0) + 1; + const entry: FullScanRetryEntry = { + attempts, + nextAttemptAt: this.#now() + getFullScanRetryDelayMs(attempts), + lastError: error instanceof Error ? error.message : String(error), + }; + if (previous?.initial) { + entry.initial = true; + } + retry[accountId] = entry; + await this.#write( + [...pending.filter((id) => id !== accountId), accountId], + retry, + ); + }); + } +} diff --git a/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.test.ts b/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.test.ts new file mode 100644 index 000000000..25f1dfaee --- /dev/null +++ b/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.test.ts @@ -0,0 +1,99 @@ +import { mock } from 'jest-mock-extended'; + +import type { SnapClient } from '../entities'; +import { + BALANCE_STALE_AFTER_MS, + SYNC_HEALTH_KEY, + SyncHealthStore, +} from './SyncHealthStore'; + +describe('SyncHealthStore', () => { + const build = (): { + store: SyncHealthStore; + snapClient: ReturnType>; + state: Record; + clock: { now: number }; + } => { + const state: Record = {}; + const clock = { now: 1_000_000 }; + const snapClient = mock(); + snapClient.getState.mockImplementation(async (key?: string) => + key !== undefined && Object.hasOwn(state, key) + ? (JSON.parse(JSON.stringify(state[key])) as never) + : (null as never), + ); + snapClient.setState.mockImplementation(async (key, value) => { + state[key as string] = JSON.parse(JSON.stringify(value)); + }); + return { + store: new SyncHealthStore(snapClient, () => clock.now), + snapClient, + state, + clock, + }; + }; + + it('is not stale for an account without failures', async () => { + const { store } = build(); + + expect(await store.isStale('a')).toBe(false); + await store.recordSuccess('a'); + expect(await store.isStale('a')).toBe(false); + }); + + it('counts the stale time from the last success, not the first failure', async () => { + const { store, clock } = build(); + + await store.recordSuccess('a'); + clock.now += 60_000; + await store.recordFailure('a'); + clock.now += BALANCE_STALE_AFTER_MS - 60_000 - 1; + expect(await store.isStale('a')).toBe(false); + clock.now += 1; + expect(await store.isStale('a')).toBe(true); + }); + + it('counts from the first failure when no success was seen', async () => { + const { store, clock } = build(); + + await store.recordFailure('a'); + clock.now += 30_000; + await store.recordFailure('a'); + clock.now += BALANCE_STALE_AFTER_MS - 30_000; + expect(await store.isStale('a')).toBe(true); + }); + + it('writes only on transitions and clears on success', async () => { + const { store, snapClient, state } = build(); + + await store.recordSuccess('a'); + expect(snapClient.setState).not.toHaveBeenCalled(); + await store.recordFailure('a'); + await store.recordFailure('a'); + expect(snapClient.setState).toHaveBeenCalledTimes(1); + expect(state[SYNC_HEALTH_KEY]).toStrictEqual({ + a: { staleSince: 1_000_000 }, + }); + await store.recordSuccess('a'); + await store.recordSuccess('a'); + expect(snapClient.setState).toHaveBeenCalledTimes(2); + expect(state[SYNC_HEALTH_KEY]).toStrictEqual({}); + }); + + it('forgets a deleted account', async () => { + const { store, clock } = build(); + + await store.recordFailure('a'); + clock.now += BALANCE_STALE_AFTER_MS; + await store.forget('a'); + expect(await store.isStale('a')).toBe(false); + }); + + it('ignores malformed state', async () => { + const { store, state, clock } = build(); + state[SYNC_HEALTH_KEY] = { a: { staleSince: 'x' } }; + + clock.now += BALANCE_STALE_AFTER_MS; + expect(await store.isStale('a')).toBe(false); + }); +}); diff --git a/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.ts b/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.ts new file mode 100644 index 000000000..4e3a771d8 --- /dev/null +++ b/packages/bitcoin-wallet-snap/src/store/SyncHealthStore.ts @@ -0,0 +1,135 @@ +import { is, number, object, record, string } from 'superstruct'; + +import type { SnapClient } from '../entities'; + +/** + * State key of the failing-sync streaks per account. + */ +export const SYNC_HEALTH_KEY = 'syncHealthV1'; + +/** + * How long an account's synchronization may keep failing after its last + * successful sync before its stored balance is no longer reported as current + * (`getAccountBalances` throws `BalanceStaleError`). Three missed 30 s syncs, + * matching the client's 90 s re-check of a balance it has not seen confirmed. + */ +export const BALANCE_STALE_AFTER_MS = 90_000; + +const SyncHealthState = record(string(), object({ staleSince: number() })); + +/** + * Tracks, per account, whether its synchronization is failing and since when + * its stored balance has not been confirmed, to tell a stale balance from a + * current one. + * + * A failure streak is persisted (only on transitions: its first failure and + * the success that ends it), because the snap may be restarted between two + * syncs and an in-memory streak would restart with it, never reaching + * {@link BALANCE_STALE_AFTER_MS}. `staleSince` is the last successful sync + * seen by this snap instance, or the streak's first failure when none was. + */ +export class SyncHealthStore { + readonly #snapClient: SnapClient; + + readonly #now: () => number; + + /** Last successful sync per account, for this snap instance. */ + readonly #lastSuccessAt = new Map(); + + #streaks: Record | undefined; + + #tail: Promise = Promise.resolve(); + + constructor(snapClient: SnapClient, now: () => number = Date.now) { + this.#snapClient = snapClient; + this.#now = now; + } + + async #serialize(task: () => Promise): Promise { + const run = this.#tail.then(task, task); + this.#tail = run.catch(() => undefined); + return run; + } + + async #load(): Promise> { + if (this.#streaks === undefined) { + const stored = await this.#snapClient.getState(SYNC_HEALTH_KEY); + this.#streaks = is(stored, SyncHealthState) ? { ...stored } : {}; + } + return this.#streaks; + } + + /** + * Records a successful sync (or full scan): ends the account's failure + * streak, if any. + * + * @param accountId - The account ID. + */ + async recordSuccess(accountId: string): Promise { + this.#lastSuccessAt.set(accountId, this.#now()); + await this.#serialize(async () => { + const streaks = await this.#load(); + if (streaks[accountId] === undefined) { + return; + } + delete streaks[accountId]; + await this.#snapClient.setState(SYNC_HEALTH_KEY, { ...streaks }); + }); + } + + /** + * Records a failed sync (or full scan): starts the account's failure streak + * if none is in progress. + * + * @param accountId - The account ID. + */ + async recordFailure(accountId: string): Promise { + await this.#serialize(async () => { + const streaks = await this.#load(); + if (streaks[accountId] !== undefined) { + return; + } + streaks[accountId] = { + staleSince: this.#lastSuccessAt.get(accountId) ?? this.#now(), + }; + await this.#snapClient.setState(SYNC_HEALTH_KEY, { ...streaks }); + }); + } + + /** + * Whether the account's stored balance may be outdated: its syncs have been + * failing, unconfirmed for at least {@link BALANCE_STALE_AFTER_MS}. An + * account that has not been synced yet, or whose latest sync succeeded, is + * not stale. + * + * @param accountId - The account ID. + * @returns `true` if the balance must not be reported as current. + */ + async isStale(accountId: string): Promise { + const streak = await this.#serialize(async () => { + const streaks = await this.#load(); + return streaks[accountId]; + }); + return ( + streak !== undefined && + this.#now() - streak.staleSince >= BALANCE_STALE_AFTER_MS + ); + } + + /** + * Forgets an account (deleted). + * + * @param accountId - The account ID. + */ + async forget(accountId: string): Promise { + this.#lastSuccessAt.delete(accountId); + await this.#serialize(async () => { + const streaks = await this.#load(); + if (streaks[accountId] === undefined) { + return; + } + delete streaks[accountId]; + await this.#snapClient.setState(SYNC_HEALTH_KEY, { ...streaks }); + }); + } +} diff --git a/packages/bitcoin-wallet-snap/src/store/index.ts b/packages/bitcoin-wallet-snap/src/store/index.ts index c23e9e145..ce0bf53b8 100644 --- a/packages/bitcoin-wallet-snap/src/store/index.ts +++ b/packages/bitcoin-wallet-snap/src/store/index.ts @@ -1,2 +1,4 @@ export * from './BdkAccountRepository'; export * from './JSXSendFlowRepository'; +export * from './FullScanQueue'; +export * from './SyncHealthStore'; diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 24911957a..0f0d77d17 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -43,9 +43,20 @@ import type { } from './AccountUseCases'; import { AccountUseCases, + DISCOVERY_SCAN_REUSE_TTL_MS, + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS, + FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS, + FULL_SCAN_RECOVERY_MIN_INTERVAL_MS, + getOnDemandFullScanIntervalMs, + MAX_IMMEDIATE_FULL_SCANS_ON_CREATE, SENDER_LOOKUP_CONCURRENCY, SENDER_RESOLUTION_LIMIT, + STALE_SYNC_REQUEST_MIN_INTERVAL_MS, } from './AccountUseCases'; +import { + BALANCE_STALE_AFTER_MS, + SYNC_HEALTH_KEY, +} from '../store/SyncHealthStore'; describe('AccountUseCases', () => { const mockLogger = mock(); @@ -823,6 +834,8 @@ describe('AccountUseCases', () => { receivedAmount, ]); mockAccount.listTransactions.mockReturnValue([]); + // A rescan of an account that already synchronized once. + mockAccount.hasSynchronized.mockReturnValue(true); }); it('performs a full scan', async () => { @@ -832,7 +845,8 @@ describe('AccountUseCases', () => { const result = await useCases.fullScan(mockAccount); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount, 'scan'); expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( mockAccount, ); @@ -847,6 +861,64 @@ describe('AccountUseCases', () => { }); }); + it('scans an account that never synchronized with the discovery gap only, when it finds no activity', async () => { + mockAccount.hasSynchronized.mockReturnValue(false); + mockChain.fullScan.mockResolvedValue(); + + await useCases.fullScan(mockAccount); + + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount, 'discovery'); + expect(mockRepository.update).toHaveBeenCalledTimes(1); + }); + + it('completes the initial scan to the standard gap when the discovery gap finds activity', async () => { + mockAccount.hasSynchronized.mockReturnValue(false); + mockChain.fullScan.mockImplementationOnce(async () => { + mockAccount.listTransactions.mockReturnValue(mockTransactions); + }); + mockChain.extendDiscoveryScan.mockResolvedValueOnce(); + mockChain.getTransactionSenders.mockResolvedValue([]); + + const result = await useCases.fullScan(mockAccount); + + // The discovery-gap scan is not repeated with the standard gap: only + // the addresses the standard gap adds are checked. + expect(mockChain.fullScan.mock.calls).toStrictEqual([ + [mockAccount, 'discovery'], + ]); + expect(mockChain.extendDiscoveryScan).toHaveBeenCalledTimes(1); + expect(mockChain.extendDiscoveryScan).toHaveBeenCalledWith(mockAccount); + expect(result.transactionsToNotify).toStrictEqual(mockTransactions); + }); + + it('does not extend an initial scan that found no activity, nor a rescan', async () => { + mockAccount.hasSynchronized.mockReturnValue(false); + await useCases.fullScan(mockAccount); + mockAccount.hasSynchronized.mockReturnValue(true); + mockAccount.listTransactions.mockReturnValue(mockTransactions); + mockChain.getTransactionSenders.mockResolvedValue([]); + await useCases.fullScan(mockAccount); + + expect(mockChain.extendDiscoveryScan).not.toHaveBeenCalled(); + }); + + it('fails the initial scan (to be retried) if the standard-gap completion fails', async () => { + mockAccount.hasSynchronized.mockReturnValue(false); + const error = new Error('Failed to fetch'); + mockChain.fullScan.mockImplementationOnce(async () => { + mockAccount.listTransactions.mockReturnValue(mockTransactions); + }); + mockChain.extendDiscoveryScan.mockRejectedValueOnce(error); + mockRepository.update.mockClear(); + + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); + + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + expect(mockChain.extendDiscoveryScan).toHaveBeenCalledTimes(1); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + it('propagates an error if the chain full scan fails', async () => { const error = new Error('Full scan failed'); mockChain.fullScan.mockRejectedValue(error); @@ -2711,4 +2783,1127 @@ describe('AccountUseCases', () => { expect(JSON.stringify(result)).not.toContain(sensitiveError); }); }); + describe('balance-unknown tracking and the full scan queue', () => { + const buildStatefulUseCases = (): { + useCases: AccountUseCases; + snapClient: ReturnType>; + state: Record; + } => { + const state: Record = {}; + const snapClient = mock(); + snapClient.getState.mockImplementation(async (key?: string) => + key !== undefined && Object.hasOwn(state, key) + ? (JSON.parse(JSON.stringify(state[key])) as never) + : (null as never), + ); + snapClient.setState.mockImplementation(async (key, value) => { + state[key as string] = JSON.parse(JSON.stringify(value)); + }); + return { + useCases: new AccountUseCases( + mockLogger, + snapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ), + snapClient, + state, + }; + }; + + const buildAccount = ( + id: string, + synchronized: boolean, + ): BitcoinAccount => { + const account = mock({ id, network: 'bitcoin' }); + account.hasSynchronized.mockReturnValue(synchronized); + account.listTransactions.mockReturnValue([]); + return account; + }; + + describe('stale balance after failing syncs (v6)', () => { + let now: number; + const advance = (ms: number): void => { + now += ms; + }; + const setNow = (value: number): void => { + now = value; + }; + let dateSpy: jest.SpyInstance; + + beforeEach(() => { + now = 10_000_000; + dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + mockChain.sync.mockResolvedValue(undefined); + }); + + afterEach(() => { + dateSpy.mockRestore(); + mockChain.sync.mockReset(); + }); + + it('is not stale before any sync, nor while the syncs succeed', async () => { + const { useCases: testUseCases } = buildStatefulUseCases(); + const account = buildAccount('a', true); + + expect(await testUseCases.isBalanceStale('a')).toBe(false); + await testUseCases.synchronize(account, 'test'); + advance(10 * 60_000); + expect(await testUseCases.isBalanceStale('a')).toBe(false); + }); + + it('becomes stale 90 s after the last success while the syncs fail, and clears on the next success', async () => { + const { useCases: testUseCases } = buildStatefulUseCases(); + const account = buildAccount('a', true); + + await testUseCases.synchronize(account, 'test'); + const lastSuccessAt = now; + + mockChain.sync.mockRejectedValue(new Error('Failed to fetch')); + advance(30_000); + await expect(testUseCases.synchronize(account, 'test')).rejects.toThrow( + 'Failed to fetch', + ); + advance(30_000); + await expect(testUseCases.synchronize(account, 'test')).rejects.toThrow( + 'Failed to fetch', + ); + setNow(lastSuccessAt + BALANCE_STALE_AFTER_MS - 1); + expect(await testUseCases.isBalanceStale('a')).toBe(false); + advance(1); + expect(await testUseCases.isBalanceStale('a')).toBe(true); + + mockChain.sync.mockResolvedValue(undefined); + await testUseCases.synchronize(account, 'test'); + expect(await testUseCases.isBalanceStale('a')).toBe(false); + }); + + it('survives a snap restart: the failure streak is persisted', async () => { + const { useCases: first, state } = buildStatefulUseCases(); + const account = buildAccount('a', true); + mockChain.sync.mockRejectedValue(new Error('Failed to fetch')); + await expect(first.synchronize(account, 'test')).rejects.toThrow( + 'Failed to fetch', + ); + expect(state[SYNC_HEALTH_KEY]).toStrictEqual({ + a: { staleSince: now }, + }); + mockChain.sync.mockResolvedValue(undefined); + + // A new instance (restarted snap) with the same state. + const snapClient = mock(); + snapClient.getState.mockImplementation(async (key?: string) => + key !== undefined && Object.hasOwn(state, key) + ? (JSON.parse(JSON.stringify(state[key])) as never) + : (null as never), + ); + const restarted = new AccountUseCases( + mockLogger, + snapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ); + advance(BALANCE_STALE_AFTER_MS); + expect(await restarted.isBalanceStale('a')).toBe(true); + }); + + it('requests a sync of a stale account at most once per interval', async () => { + const { useCases: testUseCases, snapClient } = buildStatefulUseCases(); + + expect(await testUseCases.scheduleStaleRefresh('a')).toBe(true); + expect(await testUseCases.scheduleStaleRefresh('a')).toBe(false); + advance(STALE_SYNC_REQUEST_MIN_INTERVAL_MS); + expect(await testUseCases.scheduleStaleRefresh('a')).toBe(true); + expect(snapClient.scheduleBackgroundEvent).toHaveBeenCalledTimes(2); + expect(snapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: ['a'] }, + }); + }); + }); + + it('reports the balance of a never-synchronized account as unknown', async () => { + const { useCases: testUseCases } = buildStatefulUseCases(); + + expect( + await testUseCases.isBalanceUnknown(buildAccount('a', false)), + ).toBe(true); + expect(await testUseCases.isBalanceUnknown(buildAccount('b', true))).toBe( + false, + ); + }); + + it('keeps a new account unknown until its first full scan succeeds, and retries a failed scan', async () => { + const { useCases: testUseCases } = buildStatefulUseCases(); + const account = buildAccount('new-id', true); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([account]); + mockRepository.update.mockResolvedValue(); + + await testUseCases.createMany([ + { + network: 'bitcoin', + entropySource: 'some-source', + index: 0, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + expect(await testUseCases.isBalanceUnknown(account)).toBe(true); + expect(await testUseCases.isInitialFullScanPending('new-id')).toBe(true); + + mockChain.fullScan.mockRejectedValueOnce(new Error('Failed to fetch')); + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + expect(await testUseCases.isBalanceUnknown(account)).toBe(true); + // Backoff: not due right after the failure. + expect(await testUseCases.listDueFullScans([account], 2)).toStrictEqual( + [], + ); + + mockChain.fullScan.mockResolvedValueOnce(); + await testUseCases.fullScan(account); + expect(await testUseCases.isBalanceUnknown(account)).toBe(false); + }); + + it('queues never-synchronized accounts and returns at most `max` due accounts', async () => { + const { useCases: testUseCases, state } = buildStatefulUseCases(); + // The queue already exists (e.g. the repair scan finished). + state.rescanV1 = { pending: [] }; + const synced = buildAccount('synced', true); + const legacy1 = buildAccount('legacy-1', false); + const legacy2 = buildAccount('legacy-2', false); + const legacy3 = buildAccount('legacy-3', false); + + const due = await testUseCases.listDueFullScans( + [synced, legacy1, legacy2, legacy3], + 2, + ); + + expect(due).toStrictEqual([legacy1, legacy2]); + expect(state.rescanV1).toStrictEqual({ + pending: ['legacy-1', 'legacy-2', 'legacy-3'], + }); + }); + }); + + describe('full scan coordination (single-flight, on-demand, recovery)', () => { + const NOW = 1_700_000_000_000; + let nowSpy: jest.SpyInstance; + + const buildUseCases = (): { + useCases: AccountUseCases; + snapClient: ReturnType>; + } => { + const state: Record = {}; + const snapClient = mock(); + snapClient.getState.mockImplementation(async (key?: string) => + key !== undefined && Object.hasOwn(state, key) + ? (JSON.parse(JSON.stringify(state[key])) as never) + : (null as never), + ); + snapClient.setState.mockImplementation(async (key, value) => { + state[key as string] = JSON.parse(JSON.stringify(value)); + }); + return { + useCases: new AccountUseCases( + mockLogger, + snapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ), + snapClient, + }; + }; + + const buildAccount = (id: string, synchronized = true): BitcoinAccount => { + const account = mock({ id, network: 'bitcoin' }); + account.hasSynchronized.mockReturnValue(synchronized); + account.listTransactions.mockReturnValue([]); + return account; + }; + + const fullScanEvents = ( + snapClient: ReturnType>, + ): unknown[] => + snapClient.scheduleBackgroundEvent.mock.calls.filter( + ([event]) => event.method === CronMethod.FullScanAccount, + ); + + beforeEach(() => { + mockChain.fullScan.mockReset(); + mockChain.sync.mockReset(); + mockRepository.update.mockResolvedValue(); + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + nowSpy.mockRestore(); + }); + + it('computes a bounded on-demand interval', () => { + expect(getOnDemandFullScanIntervalMs(0)).toBe( + FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS, + ); + expect(getOnDemandFullScanIntervalMs(1)).toBe(30_000); + expect(getOnDemandFullScanIntervalMs(2)).toBe(60_000); + expect(getOnDemandFullScanIntervalMs(3)).toBe( + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS, + ); + expect(getOnDemandFullScanIntervalMs(50)).toBe( + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS, + ); + expect(FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS).toBe(2 * 60_000); + }); + + it('runs one full scan per account at a time (single-flight)', async () => { + const { useCases: testUseCases } = buildUseCases(); + const account = buildAccount('a'); + let finishScan: () => void = () => undefined; + mockChain.fullScan.mockReturnValue( + new Promise((resolve) => { + finishScan = resolve; + }), + ); + + const first = testUseCases.fullScan(account); + const second = testUseCases.fullScan(account); + expect(testUseCases.isFullScanInFlight('a')).toBe(true); + finishScan(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + expect(secondResult).toBe(firstResult); + expect(testUseCases.isFullScanInFlight('a')).toBe(false); + + // A later scan runs again. + mockChain.fullScan.mockResolvedValue(); + await testUseCases.fullScan(account); + expect(mockChain.fullScan).toHaveBeenCalledTimes(2); + }); + + it('makes a sync join an in-flight full scan instead of fetching concurrently', async () => { + const { useCases: testUseCases } = buildUseCases(); + const scanned = buildAccount('a'); + const stale = buildAccount('a'); + let finishScan: () => void = () => undefined; + mockChain.fullScan.mockReturnValue( + new Promise((resolve) => { + finishScan = resolve; + }), + ); + + const scan = testUseCases.fullScan(scanned); + const sync = testUseCases.synchronize(stale, 'cron'); + finishScan(); + await scan; + + expect(await sync).toStrictEqual({ + account: scanned, + transactionsToNotify: [], + }); + expect(mockChain.sync).not.toHaveBeenCalled(); + }); + + it('excludes in-flight and just-scanned accounts from the cron queue', async () => { + const { useCases: testUseCases } = buildUseCases(); + const account = buildAccount('a', false); + let finishScan: () => void = () => undefined; + mockChain.fullScan.mockReturnValue( + new Promise((resolve) => { + finishScan = resolve; + }), + ); + + const scan = testUseCases.fullScan(account); + expect(await testUseCases.listDueFullScans([account], 2)).toStrictEqual( + [], + ); + finishScan(); + await scan; + }); + + it('starts the first full scan of new accounts right away, even without `synchronize`', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const accounts = ['n1', 'n2', 'n3'].map((id) => buildAccount(id)); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null, null, null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue(accounts); + mockRepository.insertMany.mockResolvedValue(accounts); + + await testUseCases.createMany( + [0, 1, 2].map((index) => ({ + network: 'bitcoin' as const, + entropySource: 'some-source', + index, + addressType: 'p2wpkh' as const, + synchronize: false, + })), + ); + + expect(fullScanEvents(snapClient)).toStrictEqual( + accounts.slice(0, MAX_IMMEDIATE_FULL_SCANS_ON_CREATE).map(({ id }) => [ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: id }, + }, + ]), + ); + // The scheduled accounts are not picked again by the cron queue. + const due = await testUseCases.listDueFullScans(accounts, 3); + expect(due.map(({ id }) => id)).toStrictEqual(['n3']); + }); + + it('deduplicates on-demand scan requests from balance requests', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(true); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(false); + expect( + await testUseCases.requestFullScan(account, 'sync-succeeded'), + ).toBe(false); + expect(fullScanEvents(snapClient)).toHaveLength(1); + + nowSpy.mockReturnValue(NOW + FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(true); + expect(fullScanEvents(snapClient)).toHaveLength(2); + }); + + it('does not request a scan while one is running', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + let finishScan: () => void = () => undefined; + mockChain.fullScan.mockReturnValue( + new Promise((resolve) => { + finishScan = resolve; + }), + ); + + const scan = testUseCases.fullScan(account); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(false); + finishScan(); + await scan; + expect(fullScanEvents(snapClient)).toHaveLength(0); + }); + + it('backs off on-demand scans after failures, bounded to 2 minutes', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + + for (let i = 0; i < 4; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + + nowSpy.mockReturnValue(NOW + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS - 1); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(false); + nowSpy.mockReturnValue(NOW + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(true); + expect(fullScanEvents(snapClient)).toHaveLength(1); + }); + + it('runs the pending full scan promptly once syncs succeed again after an outage', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([account]); + mockRepository.insertMany.mockResolvedValue([account]); + await testUseCases.createMany([ + { + network: 'bitcoin', + entropySource: 'some-source', + index: 0, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + snapClient.scheduleBackgroundEvent.mockClear(); + + // Outage: repeated scans fail (on-demand interval now 2 minutes) and + // the cron syncs fail too. + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + for (let i = 0; i < 4; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + mockChain.sync.mockRejectedValueOnce(new Error('Failed to fetch')); + await expect(testUseCases.synchronize(account, 'cron')).rejects.toThrow( + 'Failed to fetch', + ); + + // The network comes back: the next cron sync (30 s later) succeeds and + // runs the full scan right away, without waiting for the backoff. + nowSpy.mockReturnValue(NOW + 30_000); + mockChain.sync.mockResolvedValueOnce(); + await testUseCases.synchronize(account, 'cron'); + + expect(fullScanEvents(snapClient)).toStrictEqual([ + [ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'a' }, + }, + ], + ]); + }); + + it('lets a successful sync trigger the pending scan, bounded by the on-demand backoff', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([account]); + mockRepository.insertMany.mockResolvedValue([account]); + await testUseCases.createMany([ + { + network: 'bitcoin', + entropySource: 'some-source', + index: 0, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + snapClient.scheduleBackgroundEvent.mockClear(); + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + for (let i = 0; i < 3; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + mockChain.sync.mockResolvedValue(); + + nowSpy.mockReturnValue(NOW + 60_000); + await testUseCases.synchronize(account, 'cron'); + expect(fullScanEvents(snapClient)).toHaveLength(0); + + nowSpy.mockReturnValue(NOW + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS); + await testUseCases.synchronize(account, 'cron'); + expect(fullScanEvents(snapClient)).toHaveLength(1); + }); + + it('does not request scans from syncs of accounts with a known balance', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + mockChain.sync.mockResolvedValue(); + + await testUseCases.synchronize(account, 'cron'); + + expect(fullScanEvents(snapClient)).toHaveLength(0); + }); + + it('treats a failed full scan joined by a sync as a failed sync, so the next successful sync recovers promptly (v3 item 6)', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = buildAccount('a'); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([account]); + mockRepository.insertMany.mockResolvedValue([account]); + await testUseCases.createMany([ + { + network: 'bitcoin', + entropySource: 'some-source', + index: 0, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + snapClient.scheduleBackgroundEvent.mockClear(); + + // Outage: scans keep failing (on-demand interval now 2 minutes). + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + for (let i = 0; i < 3; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + // The cron sync joins the next (failing) scan instead of syncing. + let failScan: (error: Error) => void = () => undefined; + mockChain.fullScan.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failScan = reject; + }), + ); + const scan = testUseCases.fullScan(account); + const joinedSynchronization = testUseCases.synchronize(account, 'cron'); + failScan(new Error('Failed to fetch')); + await expect(scan).rejects.toThrow('Failed to fetch'); + await expect(joinedSynchronization).rejects.toThrow('Failed to fetch'); + expect(mockChain.sync).not.toHaveBeenCalled(); + + // The connection heals: the next cron sync (30 s later) succeeds and + // requests the full scan right away, not after the 2-minute backoff. + nowSpy.mockReturnValue(NOW + 30_000); + mockChain.sync.mockResolvedValueOnce(); + await testUseCases.synchronize(account, 'cron'); + + expect(fullScanEvents(snapClient)).toStrictEqual([ + [ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'a' }, + }, + ], + ]); + }); + + describe('first heal of a never-synchronized account (v7)', () => { + const createNeverSyncedAccount = async ( + testUseCases: AccountUseCases, + snapClient: ReturnType>, + ): Promise => { + const account = buildAccount('a', false); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([account]); + mockRepository.insertMany.mockResolvedValue([account]); + await testUseCases.createMany([ + { + network: 'bitcoin', + entropySource: 'some-source', + index: 0, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + snapClient.scheduleBackgroundEvent.mockClear(); + return account; + }; + + const at = (offsetMs: number): void => { + nowSpy.mockReturnValue(NOW + offsetMs); + }; + + /** + * Seconds until the scheduled full scans run, from their ISO durations. + * + * @param snapClient - The snap client mock. + * @returns The delays in seconds, one per scheduled full scan. + */ + const scheduledScanDelays = ( + snapClient: ReturnType>, + ): number[] => + snapClient.scheduleBackgroundEvent.mock.calls + .filter(([event]) => event.method === CronMethod.FullScanAccount) + .map(([event]) => Number(/^PT(\d+)S$/u.exec(event.duration ?? '')?.[1])); + + it('requests the full scan within seconds when the first successful sync comes 25 s after a failed scan (A-v6 22:49:13)', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = await createNeverSyncedAccount(testUseCases, snapClient); + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + mockChain.sync.mockRejectedValue(new Error('Failed to fetch')); + + // Outage, with the A-v6 timeline (seconds after onboarding): the + // first scans fail at +0 and +2, on-demand retries at +31 and +92, + // and the 30 s cron syncs fail in between. + for (const [offset, kind] of [ + [0, 'scan'], + [2, 'scan'], + [26, 'sync'], + [31, 'scan'], + [56, 'sync'], + [86, 'sync'], + [92, 'scan'], + ] as const) { + at(offset * 1000); + const step = + kind === 'scan' + ? testUseCases.fullScan(account) + : testUseCases.synchronize(account, 'cron'); + await expect(step).rejects.toThrow('Failed to fetch'); + } + // The backoff has grown: a balance request (client retry) is now + // held back by the 2-minute on-demand interval. + at(116_000); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(false); + expect(fullScanEvents(snapClient)).toHaveLength(0); + + // Heal: the next cron sync, 25 s after the last failed scan, succeeds. + at(117_000); + mockChain.sync.mockResolvedValue(); + await testUseCases.synchronize(account, 'cron'); + + const delays = scheduledScanDelays(snapClient); + expect(delays).toHaveLength(1); + expect(delays[0]).toBeLessThanOrEqual(5); + expect(fullScanEvents(snapClient)).toStrictEqual([ + [ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'a' }, + }, + ], + ]); + + // No duplicate scan before the scheduled one runs: neither the cron + // queue, nor later syncs, nor balance requests add another. + at(118_000); + expect(await testUseCases.listDueFullScans([account], 2)).toStrictEqual( + [], + ); + await testUseCases.synchronize(account, 'cron'); + expect( + await testUseCases.requestFullScan(account, 'balance-request'), + ).toBe(false); + expect(fullScanEvents(snapClient)).toHaveLength(1); + + // The scheduled scan runs and succeeds: the balance is known and the + // following syncs request nothing more. + mockChain.fullScan.mockResolvedValue(); + await testUseCases.fullScan(account); + jest.mocked(account.hasSynchronized).mockReturnValue(true); + expect(await testUseCases.isBalanceUnknown(account)).toBe(false); + at(147_000); + await testUseCases.synchronize(account, 'cron'); + expect(fullScanEvents(snapClient)).toHaveLength(1); + expect(mockChain.fullScan).toHaveBeenCalledTimes(5); + }); + + it('defers, rather than drops, a recovery that arrives right after a failed scan', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = await createNeverSyncedAccount(testUseCases, snapClient); + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + mockChain.sync.mockRejectedValueOnce(new Error('Failed to fetch')); + for (let i = 0; i < 3; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + await expect(testUseCases.synchronize(account, 'cron')).rejects.toThrow( + 'Failed to fetch', + ); + + // The sync succeeds 2 s after the last failed scan. + at(2_000); + mockChain.sync.mockResolvedValue(); + await testUseCases.synchronize(account, 'cron'); + + expect(scheduledScanDelays(snapClient)).toStrictEqual([ + (FULL_SCAN_RECOVERY_MIN_INTERVAL_MS - 2_000) / 1000, + ]); + // Still a single scan while it is pending. + at(4_000); + await testUseCases.synchronize(account, 'cron'); + expect(await testUseCases.listDueFullScans([account], 2)).toStrictEqual( + [], + ); + expect(fullScanEvents(snapClient)).toHaveLength(1); + }); + + it('keeps the on-demand backoff for successful syncs that are not a recovery', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const account = await createNeverSyncedAccount(testUseCases, snapClient); + mockChain.fullScan.mockRejectedValue(new Error('Failed to fetch')); + for (let i = 0; i < 3; i++) { + await expect(testUseCases.fullScan(account)).rejects.toThrow( + 'Failed to fetch', + ); + } + // Syncs never failed (only the scans did): no recovery, so the + // 2-minute on-demand interval still applies. + mockChain.sync.mockResolvedValue(); + at(25_000); + await testUseCases.synchronize(account, 'cron'); + expect(fullScanEvents(snapClient)).toHaveLength(0); + }); + }); + }); + + describe('discovery scan reuse and the discovery frontier (v3 item 1)', () => { + const NOW = 1_700_000_000_000; + let nowSpy: jest.SpyInstance; + + const buildUseCases = (): { + useCases: AccountUseCases; + snapClient: ReturnType>; + } => { + const state: Record = {}; + const snapClient = mock(); + snapClient.getState.mockImplementation(async (key?: string) => + key !== undefined && Object.hasOwn(state, key) + ? (JSON.parse(JSON.stringify(state[key])) as never) + : (null as never), + ); + snapClient.setState.mockImplementation(async (key, value) => { + state[key as string] = JSON.parse(JSON.stringify(value)); + }); + return { + useCases: new AccountUseCases( + mockLogger, + snapClient, + mockRepository, + mockConfirmationRepository, + mockChain, + fallbackFeeRate, + targetBlocksConfirmation, + undefined, + ), + snapClient, + }; + }; + + const buildAccount = ( + id: string, + { synchronized = true, transactions = 0 } = {}, + ): BitcoinAccount => { + const account = mock({ id, network: 'bitcoin' }); + account.hasSynchronized.mockReturnValue(synchronized); + account.listTransactions.mockReturnValue( + Array.from({ length: transactions }, () => mock()), + ); + return account; + }; + + const request = (index: number): CreateAccountParams => ({ + network: 'bitcoin', + entropySource: 'some-source', + index, + addressType: 'p2wpkh', + synchronize: false, + }); + + const eventsOf = ( + snapClient: ReturnType>, + method: string, + ): unknown[] => + snapClient.scheduleBackgroundEvent.mock.calls + .filter(([event]) => event.method === method) + .map(([event]) => event); + + const discover = async ( + testUseCases: AccountUseCases, + account: BitcoinAccount, + index: number, + ): Promise => { + mockRepository.getByDerivationPath.mockResolvedValueOnce(null); + mockRepository.create.mockResolvedValueOnce(account); + mockChain.fullScan.mockResolvedValueOnce(); + await testUseCases.discover({ + network: 'bitcoin', + entropySource: 'some-source', + index, + addressType: 'p2wpkh', + }); + }; + + beforeEach(() => { + mockChain.fullScan.mockReset(); + mockChain.sync.mockReset(); + mockRepository.createMany.mockReset(); + mockRepository.insertMany.mockReset(); + mockRepository.update.mockResolvedValue(); + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + nowSpy.mockRestore(); + }); + + it('reuses the account discovery just scanned as its first synchronization', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const discovered = buildAccount('d1'); + await discover(testUseCases, discovered, 1); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.insertMany.mockResolvedValue([discovered]); + + const created = await testUseCases.createMany([request(1)]); + + expect(created).toStrictEqual([discovered]); + expect(mockRepository.createMany).not.toHaveBeenCalled(); + expect(mockRepository.insertMany).toHaveBeenCalledWith( + [discovered], + expect.anything(), + ); + expect(discovered.revealNextAddress).toHaveBeenCalled(); + // No second full scan: not scheduled, not queued. + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + expect(eventsOf(snapClient, CronMethod.FullScanAccount)).toHaveLength(0); + expect(await testUseCases.isInitialFullScanPending('d1')).toBe(false); + expect(await testUseCases.isBalanceUnknown(discovered)).toBe(false); + }); + + it('syncs (instead of full-scanning) new accounts above the lowest index discovery found unused', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + // Onboarding: discovery finds index 1 unused, then alignment creates + // groups 1..4 because other networks have activity there. + const discovered = buildAccount('d1'); + await discover(testUseCases, discovered, 1); + const fresh = ['a2', 'a3', 'a4'].map((id) => + buildAccount(id, { synchronized: false }), + ); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null, null, null, null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue(fresh); + mockRepository.insertMany.mockResolvedValue([discovered, ...fresh]); + + await testUseCases.createMany([1, 2, 3, 4].map(request)); + + expect(mockRepository.createMany).toHaveBeenCalledTimes(1); + expect( + mockRepository.createMany.mock.calls[0]?.[0].map( + ({ derivationPath }) => derivationPath[3], + ), + ).toStrictEqual(["2'", "3'", "4'"]); + expect(eventsOf(snapClient, CronMethod.FullScanAccount)).toHaveLength(0); + expect( + eventsOf(snapClient, CronMethod.SyncSelectedAccounts), + ).toStrictEqual([ + { + duration: 'PT1S', + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: ['a2', 'a3', 'a4'] }, + }, + ]); + expect(await testUseCases.isInitialFullScanPending('a2')).toBe(false); + // Not picked by the cron full-scan queue either. + expect(await testUseCases.listDueFullScans(fresh, 5)).toStrictEqual([]); + // Discovery's own scan is the only full scan. + expect(mockChain.fullScan).toHaveBeenCalledTimes(1); + }); + + it('answers a balance request for a fresh account with a sync, and full-scans normally once it synced', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + await discover(testUseCases, buildAccount('d1'), 1); + const fresh = buildAccount('a2', { synchronized: false }); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([fresh]); + mockRepository.insertMany.mockResolvedValue([fresh]); + await testUseCases.createMany([request(2)]); + snapClient.scheduleBackgroundEvent.mockClear(); + + expect(await testUseCases.requestFullScan(fresh, 'balance-request')).toBe( + true, + ); + expect(snapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: ['a2'] }, + }); + + // After its first successful sync, it is a regular account again. + mockChain.sync.mockResolvedValueOnce(); + jest.mocked(fresh.hasSynchronized).mockReturnValue(true); + await testUseCases.synchronize(fresh, 'cron'); + snapClient.scheduleBackgroundEvent.mockClear(); + nowSpy.mockReturnValue(NOW + 60_000); + await testUseCases.requestFullScan(fresh, 'balance-request'); + expect(snapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'a2' }, + }); + }); + + it('still full-scans accounts at or below an index with activity', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + // Discovery found activity at index 1 (frontier not set by it). + await discover(testUseCases, buildAccount('d1', { transactions: 2 }), 1); + const next = buildAccount('a2', { synchronized: false }); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([next]); + mockRepository.insertMany.mockResolvedValue([next]); + + await testUseCases.createMany([request(2)]); + + expect( + eventsOf(snapClient, CronMethod.SyncSelectedAccounts), + ).toHaveLength(0); + expect(eventsOf(snapClient, CronMethod.FullScanAccount)).toStrictEqual([ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'a2' }, + }, + ]); + }); + + it('reproduces the onboarding of run B-v3: accounts created before discovery get discovery-gap first scans', async () => { + // Run B-v3 (restored SRP with five synced account groups): + // 1. wallet init creates index 0; + // 2. the account-tree sync creates groups 1..4, whose non-EVM + // post-alignment calls createAccounts(range 1..4); + // 3. only then discovery runs, at index 5 (the next group index). + // Discovery therefore never precedes the creations: no reuse and no + // frontier apply, and all five accounts need an initial scan. + const { useCases: testUseCases, snapClient } = buildUseCases(); + const funded = buildAccount('a0', { synchronized: false }); + const empty = ['a1', 'a2', 'a3', 'a4'].map((id) => + buildAccount(id, { synchronized: false }), + ); + mockRepository.getByDerivationPaths.mockResolvedValueOnce({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValueOnce([funded]); + await testUseCases.createMany([request(0)]); + mockRepository.getByDerivationPaths.mockResolvedValueOnce({ + accounts: [null, null, null, null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValueOnce(empty); + await testUseCases.createMany([1, 2, 3, 4].map(request)); + await discover(testUseCases, buildAccount('d5'), 5); + + // Immediate scans: index 0, then the first MAX_IMMEDIATE ones of the + // batch; the rest wait for the cron queue (the +26 s burst). + expect( + eventsOf(snapClient, CronMethod.FullScanAccount).map( + (event) => (event as { params: { accountId: string } }).params, + ), + ).toStrictEqual([ + { accountId: 'a0' }, + ...['a1', 'a2'] + .slice(0, MAX_IMMEDIATE_FULL_SCANS_ON_CREATE) + .map((accountId) => ({ accountId })), + ]); + expect( + (await testUseCases.listDueFullScans([funded, ...empty], 5)).map( + ({ id }) => id, + ), + ).toStrictEqual(['a3', 'a4']); + + // Every initial scan uses the discovery gap; only the funded account + // gets the standard-gap completion. + mockChain.fullScan.mockReset(); + mockChain.fullScan.mockImplementation(async (account) => { + if (account === funded) { + jest + .mocked(funded.listTransactions) + .mockReturnValue([mock()]); + } + }); + stubSentAmount(funded); + for (const account of [funded, ...empty]) { + await testUseCases.fullScan(account); + } + expect( + mockChain.fullScan.mock.calls.map(([account, mode]) => [ + account.id, + mode, + ]), + ).toStrictEqual([ + ['a0', 'discovery'], + ['a1', 'discovery'], + ['a2', 'discovery'], + ['a3', 'discovery'], + ['a4', 'discovery'], + ]); + // B-v4: the funded account is completed to the standard gap without + // repeating its discovery-gap scan. + expect( + mockChain.extendDiscoveryScan.mock.calls.map(([account]) => account.id), + ).toStrictEqual(['a0']); + }); + + it('requests a standard-gap scan for a reused discovery scan that found activity', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + const discovered = buildAccount('d1', { transactions: 3 }); + await discover(testUseCases, discovered, 1); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null], + snapshot: { accounts: null, derivationPaths: null }, + }); + + await testUseCases.createMany([request(1)]); + + expect(mockRepository.createMany).not.toHaveBeenCalled(); + expect(eventsOf(snapClient, CronMethod.FullScanAccount)).toStrictEqual([ + { + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: 'd1' }, + }, + ]); + // Its balance is known from the discovery scan meanwhile. + expect(await testUseCases.isInitialFullScanPending('d1')).toBe(false); + + // The scheduled scan uses the standard gap (the account synchronized). + mockChain.fullScan.mockResolvedValueOnce(); + stubSentAmount(discovered); + await testUseCases.fullScan(discovered); + expect(mockChain.fullScan).toHaveBeenLastCalledWith(discovered, 'scan'); + }); + + it('does not reuse discovery results older than the reuse TTL', async () => { + const { useCases: testUseCases, snapClient } = buildUseCases(); + await discover(testUseCases, buildAccount('d1'), 1); + nowSpy.mockReturnValue(NOW + DISCOVERY_SCAN_REUSE_TTL_MS); + const derived = buildAccount('n1', { synchronized: false }); + const fresh = buildAccount('n2', { synchronized: false }); + mockRepository.getByDerivationPaths.mockResolvedValue({ + accounts: [null, null], + snapshot: { accounts: null, derivationPaths: null }, + }); + mockRepository.createMany.mockResolvedValue([derived, fresh]); + mockRepository.insertMany.mockResolvedValue([derived, fresh]); + + await testUseCases.createMany([request(1), request(2)]); + + expect(mockRepository.createMany.mock.calls[0]?.[0]).toHaveLength(2); + expect( + eventsOf(snapClient, CronMethod.SyncSelectedAccounts), + ).toHaveLength(0); + expect(eventsOf(snapClient, CronMethod.FullScanAccount)).toHaveLength(2); + }); + }); }); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index b9771a4ab..478ca5464 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -42,6 +42,8 @@ import { WalletError, } from '../entities'; import { CronMethod } from '../handlers/CronHandler'; +import { FullScanQueue } from '../store/FullScanQueue'; +import { SyncHealthStore } from '../store/SyncHealthStore'; import { resolveFeeRate } from '../utils/fee-rate'; export type DiscoverAccountParams = { @@ -225,6 +227,116 @@ class SenderLookupLimiter { } } +/** + * Minimum interval between two on-demand full scans of the same account (and + * between a scheduled-but-not-started scan and a new request for it). + */ +export const FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS = 30_000; + +/** + * Upper bound of the on-demand full scan interval while scans keep failing: + * client retries and successful syncs can trigger a scan at most this often, + * so recovery after the network comes back is bounded by it. + */ +export const FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS = 2 * 60_000; + +/** + * Minimum time between a failed full scan and the scan requested by the + * network recovery that follows it (the first successful sync after failing + * syncs). The failed attempt says nothing about the network now that a sync + * reached it, so only this short floor applies (it keeps a scan that failed + * an instant ago from being retried back-to-back). A recovery that arrives + * inside the floor schedules the scan for when it elapses instead of being + * dropped: the recovery is seen only once, and dropping it left the account + * waiting for the on-demand backoff (up to 2 minutes). + */ +export const FULL_SCAN_RECOVERY_MIN_INTERVAL_MS = 5_000; + +/** + * Maximum number of new accounts per `createMany` call whose first full scan + * is started right away; the others are picked up by the cron queue. + */ +export const MAX_IMMEDIATE_FULL_SCANS_ON_CREATE = 2; + +/** + * How long the result of an account-discovery scan counts as the account's + * first synchronization: an account created for a derivation path discovery + * scanned less than this long ago reuses the scanned wallet instead of + * running a second full scan. + */ +export const DISCOVERY_SCAN_REUSE_TTL_MS = 10 * 60_000; + +/** + * Minimum time between two syncs requested by balance calls on a stale + * account (`scheduleStaleRefresh`), so a client polling a broken connection + * does not schedule a sync per call. + */ +export const STALE_SYNC_REQUEST_MIN_INTERVAL_MS = 10_000; + +/** + * Upper bound on the discovery results kept in memory for reuse. + */ +export const MAX_REUSABLE_DISCOVERY_SCANS = 64; + +type DiscoveryScan = { + account: BitcoinAccount; + scannedAt: number; +}; + +type DiscoveryFrontier = { + /** Lowest account index discovery found without on-chain activity. */ + index: number; + scannedAt: number; +}; + +/** + * @param req - The discovery or creation request. + * @returns The key identifying the request's wallet (entropy source, address + * type, network), shared by all its account indexes. + */ +function getDiscoveryFrontierKey(req: DiscoverAccountParams): string { + return `${req.network}|${req.addressType}|${req.entropySource}`; +} + +/** + * Why a full scan is requested outside the cron queue. + * - `created`: first scan of a newly created account, started immediately so + * the first balance is not delayed. + * - `balance-request`: the client asked for the balance of an account whose + * balance is unknown (initial fetch, scheduled or manual retry). + * - `sync-succeeded`: a sync of an account whose initial scan is pending + * succeeded, so the network works again. + * - `network-recovered`: same, right after failing syncs (the network just + * came back): only the minimum interval applies. + */ +export type FullScanRequestReason = + | 'created' + | 'balance-request' + | 'sync-succeeded' + | 'network-recovered'; + +type FullScanAttemptInfo = { + lastAttemptAt: number; + consecutiveFailures: number; +}; + +/** + * @param consecutiveFailures - Consecutive failed full scans of the account. + * @returns The minimum interval before the next on-demand full scan. + */ +export function getOnDemandFullScanIntervalMs( + consecutiveFailures: number, +): number { + if (consecutiveFailures <= 0) { + return FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS; + } + return Math.min( + FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS * + 2 ** Math.min(consecutiveFailures - 1, 16), + FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS, + ); +} + export class AccountUseCases { readonly #logger: Logger; @@ -246,6 +358,50 @@ export class AccountUseCases { #accountMutationQueue: Promise = Promise.resolve(); + readonly #fullScanQueue: FullScanQueue; + + /** + * In-flight full scans per account (single-flight): concurrent requests + * (cron queue, scheduled scan, balance requests, syncs) join the running + * scan instead of starting another one. + */ + readonly #fullScansInFlight = new Map>(); + + /** Last full scan attempt and consecutive failures per account. */ + readonly #fullScanAttempts = new Map(); + + /** When an on-demand full scan was scheduled and has not started yet. */ + readonly #fullScanRequestedAt = new Map(); + + /** Accounts whose last sync failed (used to detect network recovery). */ + readonly #failingSyncs = new Set(); + + /** Failing-sync streaks per account (stale balance detection). */ + readonly #syncHealth: SyncHealthStore; + + /** When a balance call on a stale account last requested a sync. */ + readonly #staleSyncRequestedAt = new Map(); + + /** + * Recent discovery scans per network and derivation path: discovery already + * ran a full scan of these wallets, so creating the account reuses them + * (counting as its first synchronization) instead of scanning again. + */ + readonly #discoveryScans = new Map(); + + /** + * Per wallet, the lowest account index discovery found without activity. + * Like BIP-44 account discovery, accounts above it are treated as unused: + * creating them needs a regular sync, not a full address-gap scan. + */ + readonly #discoveryFrontiers = new Map(); + + /** + * New accounts that only need a regular sync to know their balance (see + * {@link AccountUseCases.#discoveryFrontiers}), until that sync succeeds. + */ + readonly #syncOnlyAccounts = new Set(); + constructor( logger: Logger, snapClient: SnapClient, @@ -264,6 +420,261 @@ export class AccountUseCases { this.#fallbackFeeRate = fallbackFeeRate; this.#targetBlocksConfirmation = targetBlocksConfirmation; this.#metaProtocols = metaProtocols; + this.#fullScanQueue = new FullScanQueue(snapClient); + this.#syncHealth = new SyncHealthStore(snapClient); + } + + /** + * @param id - The account ID. + * @returns Whether the account never completed its first full scan, in + * which case its balance is unknown rather than zero. + */ + async isInitialFullScanPending(id: string): Promise { + return this.#fullScanQueue.isInitialScanPending(id); + } + + /** + * Whether the account's balance is unknown rather than zero: the wallet + * never synchronized with the chain, or its first full scan has not + * succeeded yet (only the revealed addresses may have been synced, so the + * balance can be partial). + * + * @param account - The account. + * @returns `true` if the balance must not be reported as known. + */ + async isBalanceUnknown(account: BitcoinAccount): Promise { + if (!account.hasSynchronized()) { + return true; + } + return this.#fullScanQueue.isInitialScanPending(account.id); + } + + /** + * Whether the account's stored balance may be outdated: its syncs have been + * failing since a successful sync more than `BALANCE_STALE_AFTER_MS` ago + * (see {@link SyncHealthStore}). An account that has not been synced yet, + * or whose latest sync succeeded, is not stale. + * + * @param id - The account ID. + * @returns `true` if the balance must not be reported as current. + */ + async isBalanceStale(id: string): Promise { + return this.#syncHealth.isStale(id); + } + + /** + * Schedules a sync of a stale account right away (a balance call, such as + * the client's Retry, on a stale account), at most once per + * {@link STALE_SYNC_REQUEST_MIN_INTERVAL_MS}. A successful sync clears the + * stale state and emits the balances-updated event. + * + * @param id - The account ID. + * @returns Whether a sync was scheduled. + */ + async scheduleStaleRefresh(id: string): Promise { + const now = Date.now(); + const requestedAt = this.#staleSyncRequestedAt.get(id); + if ( + requestedAt !== undefined && + now - requestedAt < STALE_SYNC_REQUEST_MIN_INTERVAL_MS + ) { + return false; + } + this.#staleSyncRequestedAt.set(id, now); + try { + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: [id] }, + }); + return true; + } catch (error) { + this.#staleSyncRequestedAt.delete(id); + this.#logger.warn('Failed to schedule a sync of a stale account', error); + return false; + } + } + + async #recordSyncSuccess(id: string): Promise { + await this.#syncHealth + .recordSuccess(id) + .catch((stateError: unknown) => + this.#logger.warn('Failed to record sync success', stateError), + ); + } + + async #recordSyncFailure(id: string): Promise { + await this.#syncHealth + .recordFailure(id) + .catch((stateError: unknown) => + this.#logger.warn('Failed to record sync failure', stateError), + ); + } + + /** + * Returns the accounts due for a (re)tried full scan, in queue order. + * + * @param accounts - The live accounts. + * @param max - Maximum number of accounts to return. + * @returns The due accounts. + */ + async listDueFullScans( + accounts: BitcoinAccount[], + max: number, + ): Promise { + // Accounts that never synchronized at all (genesis checkpoint) are + // (re)queued: before the queue existed, a failed first scan was never + // retried and such accounts kept reporting a zero balance forever. + // Fresh accounts beyond the discovery frontier only wait for a regular + // sync (scheduled at creation), not a full scan. + const neverSynchronized = accounts + .filter( + (account) => + !account.hasSynchronized() && !this.#syncOnlyAccounts.has(account.id), + ) + .map(({ id }) => id); + await this.#fullScanQueue.ensureQueued(neverSynchronized); + + const dueIds = await this.#fullScanQueue.due( + accounts.map(({ id }) => id), + max, + ); + const byId = new Map(accounts.map((account) => [account.id, account])); + // Sync-only accounts are skipped even when queued (e.g. by the one-off + // repair scan of every live account): discovery already covered them. + return dueIds + .filter( + (id) => + !this.#syncOnlyAccounts.has(id) && !this.#isFullScanBusyOrRecent(id), + ) + .map((id) => byId.get(id)) + .filter((account): account is BitcoinAccount => account !== undefined); + } + + /** + * @param accountId - The account ID. + * @returns Whether a full scan of the account is running. + */ + isFullScanInFlight(accountId: string): boolean { + return this.#fullScansInFlight.has(accountId); + } + + /** + * Whether a full scan of the account is running, scheduled to start, or + * was attempted less than {@link FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS} ago: + * the cron queue skips such accounts so scans never overlap or repeat + * back-to-back. + * + * @param accountId - The account ID. + * @returns `true` if the queue must not scan the account now. + */ + #isFullScanBusyOrRecent(accountId: string): boolean { + if (this.#fullScansInFlight.has(accountId)) { + return true; + } + const now = Date.now(); + const requestedAt = this.#fullScanRequestedAt.get(accountId); + if ( + requestedAt !== undefined && + now - requestedAt < FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS + ) { + return true; + } + const attempt = this.#fullScanAttempts.get(accountId); + return ( + attempt !== undefined && + now - attempt.lastAttemptAt < FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS + ); + } + + /** + * Requests a full scan outside the cron queue by scheduling a + * `FullScanAccount` background event, deduplicated per account: + * - never while a scan of the account is running or already scheduled; + * - for `balance-request` and `sync-succeeded`, at most once per + * {@link getOnDemandFullScanIntervalMs} (30 s, doubling per consecutive + * failure up to {@link FULL_SCAN_ON_DEMAND_MAX_INTERVAL_MS}), bypassing the + * longer persistent queue backoff; + * - for `network-recovered`, never dropped because of a previous attempt: + * after a failed scan it runs once {@link FULL_SCAN_RECOVERY_MIN_INTERVAL_MS} + * has elapsed since that attempt (after a successful one, + * {@link FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS}); a request inside that + * window is scheduled for when it ends. + * + * Never throws. + * + * @param account - The account. + * @param reason - Why the scan is requested. + * @returns Whether a scan was scheduled. + */ + async requestFullScan( + account: BitcoinAccount, + reason: FullScanRequestReason, + ): Promise { + const { id } = account; + if (this.#fullScansInFlight.has(id)) { + return false; + } + const now = Date.now(); + let delayMs = 0; + if (reason !== 'created') { + const requestedAt = this.#fullScanRequestedAt.get(id); + if ( + requestedAt !== undefined && + now - requestedAt < FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS + ) { + // A scan is already scheduled and has not started yet. + return false; + } + const attempt = this.#fullScanAttempts.get(id); + if (attempt) { + const elapsed = now - attempt.lastAttemptAt; + if (reason === 'network-recovered') { + const interval = + attempt.consecutiveFailures > 0 + ? FULL_SCAN_RECOVERY_MIN_INTERVAL_MS + : FULL_SCAN_ON_DEMAND_MIN_INTERVAL_MS; + delayMs = Math.max(0, interval - elapsed); + } else if ( + elapsed < getOnDemandFullScanIntervalMs(attempt.consecutiveFailures) + ) { + return false; + } + } + } + + // Marks the scan as scheduled until it starts (`#runFullScan` clears it), + // so the cron queue and other requests don't add a second one. + this.#fullScanRequestedAt.set(id, now + delayMs); + const duration = `PT${Math.max(1, Math.ceil(delayMs / 1000))}S`; + try { + // An account known to be fresh (beyond the discovery frontier) only + // needs a regular sync to know its balance. + await this.#snapClient.scheduleBackgroundEvent( + this.#syncOnlyAccounts.has(id) + ? { + duration, + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: [id] }, + } + : { + duration, + method: CronMethod.FullScanAccount, + params: { accountId: id }, + }, + ); + this.#logger.debug( + 'Full scan requested (%s, in %ss): %s', + reason, + duration.slice(2, -1), + id, + ); + return true; + } catch (error) { + this.#fullScanRequestedAt.delete(id); + this.#logger.warn('Failed to schedule a full scan', error); + return false; + } } async list(): Promise { @@ -328,6 +739,12 @@ export class AccountUseCases { // has any previous activity since later on we filter out // accounts with no tx history await this.#chain.fullScan(newAccount, 'discovery'); + try { + this.#rememberDiscoveryScan(req, derivationPath, newAccount); + } catch (error) { + // Only an optimization: never fail discovery because of it. + this.#logger.warn('Failed to remember the discovery scan', error); + } this.#logger.info( 'Bitcoin account discovered successfully. Request: %o', @@ -336,6 +753,97 @@ export class AccountUseCases { return newAccount; } + /** + * Keeps a discovery scan so that creating the same account shortly after + * (account alignment right after discovery) counts it as the account's + * first synchronization, and records the discovery frontier. + * + * @param req - The discovery request. + * @param derivationPath - The scanned account's derivation path. + * @param account - The scanned, not persisted, account. + */ + #rememberDiscoveryScan( + req: DiscoverAccountParams, + derivationPath: string[], + account: BitcoinAccount, + ): void { + const now = Date.now(); + this.#pruneDiscoveryScans(now); + + const key = `${req.network}|${getDerivationPathKey(derivationPath)}`; + this.#discoveryScans.delete(key); + this.#discoveryScans.set(key, { account, scannedAt: now }); + while (this.#discoveryScans.size > MAX_REUSABLE_DISCOVERY_SCANS) { + const oldest = this.#discoveryScans.keys().next().value as string; + this.#discoveryScans.delete(oldest); + } + + const transactions = account.listTransactions() ?? []; + if (transactions.length === 0) { + const frontierKey = getDiscoveryFrontierKey(req); + const frontier = this.#discoveryFrontiers.get(frontierKey); + if (!frontier || req.index <= frontier.index) { + this.#discoveryFrontiers.set(frontierKey, { + index: req.index, + scannedAt: now, + }); + } + } + } + + #pruneDiscoveryScans(now: number): void { + for (const [key, { scannedAt }] of this.#discoveryScans) { + if (now - scannedAt >= DISCOVERY_SCAN_REUSE_TTL_MS) { + this.#discoveryScans.delete(key); + } + } + for (const [key, { scannedAt }] of this.#discoveryFrontiers) { + if (now - scannedAt >= DISCOVERY_SCAN_REUSE_TTL_MS) { + this.#discoveryFrontiers.delete(key); + } + } + } + + /** + * Takes the recent discovery scan of an account to create, if any. + * + * @param req - The creation request. + * @param pathKey - The account's derivation path key. + * @returns The scanned account, or `undefined`. + */ + #takeDiscoveryScan( + req: CreateAccountParams, + pathKey: string, + ): BitcoinAccount | undefined { + const key = `${req.network}|${pathKey}`; + const scan = this.#discoveryScans.get(key); + if (!scan) { + return undefined; + } + this.#discoveryScans.delete(key); + if ( + Date.now() - scan.scannedAt >= DISCOVERY_SCAN_REUSE_TTL_MS || + scan.account.network !== req.network + ) { + return undefined; + } + return scan.account; + } + + /** + * @param req - The creation request. + * @returns Whether discovery recently found a lower account index of the + * same wallet without activity, so this new account is unused. + */ + #isBeyondDiscoveryFrontier(req: CreateAccountParams): boolean { + const frontier = this.#discoveryFrontiers.get(getDiscoveryFrontierKey(req)); + return ( + frontier !== undefined && + Date.now() - frontier.scannedAt < DISCOVERY_SCAN_REUSE_TTL_MS && + req.index > frontier.index + ); + } + async createMany(reqs: CreateAccountParams[]): Promise { if (reqs.length === 0) { return []; @@ -343,134 +851,225 @@ export class AccountUseCases { const startMs = Date.now(); - const { accounts, createdAccountKeys } = await this.#runAccountMutation( - async () => { - const lookupStartMs = Date.now(); - const entries = reqs.map((req, index) => { - const derivationPath = getAccountDerivationPath(req); - return { - req, - index, - derivationPath, - pathKey: getDerivationPathKey(derivationPath), - }; - }); + const { + accounts, + createdAccountKeys, + discoveredAccountKeys, + syncOnlyAccountKeys, + } = await this.#runAccountMutation(async () => { + const lookupStartMs = Date.now(); + const entries = reqs.map((req, index) => { + const derivationPath = getAccountDerivationPath(req); + return { + req, + index, + derivationPath, + pathKey: getDerivationPathKey(derivationPath), + }; + }); - const uniqueEntriesByPath = new Map(); - for (const entry of entries) { - if (!uniqueEntriesByPath.has(entry.pathKey)) { - uniqueEntriesByPath.set(entry.pathKey, entry); - } + const uniqueEntriesByPath = new Map(); + for (const entry of entries) { + if (!uniqueEntriesByPath.has(entry.pathKey)) { + uniqueEntriesByPath.set(entry.pathKey, entry); } - const uniqueEntries = [...uniqueEntriesByPath.values()]; - - const { accounts: existingAccounts, snapshot } = - await this.#repository.getByDerivationPaths( - uniqueEntries.map(({ derivationPath }) => derivationPath), - ); - const existingAccountsByPath = new Map(); - - uniqueEntries.forEach((entry, index) => { - const account = existingAccounts[index]; - if (account?.network === entry.req.network) { - existingAccountsByPath.set(entry.pathKey, account); - } - }); + } + const uniqueEntries = [...uniqueEntriesByPath.values()]; - const entriesToCreate = uniqueEntries.filter( - ({ pathKey }) => !existingAccountsByPath.has(pathKey), + const { accounts: existingAccounts, snapshot } = + await this.#repository.getByDerivationPaths( + uniqueEntries.map(({ derivationPath }) => derivationPath), ); - const lookupMs = Date.now() - lookupStartMs; - - // Batch-create so entropy is fetched once per parent path instead of - // once per account; remaining per-account work is local derivation - // plus synchronous WASM wallet construction, so no throttling needed. - const deriveStartMs = Date.now(); - const newAccounts = - entriesToCreate.length > 0 - ? await this.#repository.createMany( - entriesToCreate.map(({ derivationPath, req }) => ({ - derivationPath, - network: req.network, - addressType: req.addressType, - })), - ) - : []; - - for (const newAccount of newAccounts) { - newAccount.revealNextAddress(); + const existingAccountsByPath = new Map(); + + uniqueEntries.forEach((entry, index) => { + const account = existingAccounts[index]; + if (account?.network === entry.req.network) { + existingAccountsByPath.set(entry.pathKey, account); } - const deriveMs = Date.now() - deriveStartMs; - - const persistStartMs = Date.now(); - if (newAccounts.length > 0) { - // Reuse the lookup's derivation-path snapshot: account lifecycle - // mutations are serialized, while `insertMany` refreshes the - // accounts map to preserve concurrent sync updates. - await this.#repository.insertMany(newAccounts, snapshot); + }); + + const entriesToCreate = uniqueEntries.filter( + ({ pathKey }) => !existingAccountsByPath.has(pathKey), + ); + const lookupMs = Date.now() - lookupStartMs; + + // Batch-create so entropy is fetched once per parent path instead of + // once per account; remaining per-account work is local derivation + // plus synchronous WASM wallet construction, so no throttling needed. + const deriveStartMs = Date.now(); + this.#pruneDiscoveryScans(Date.now()); + // Accounts discovery just scanned reuse that scan as their first + // synchronization instead of deriving and scanning them again. + const discoveredByPath = new Map(); + for (const { req, pathKey } of entriesToCreate) { + const discovered = this.#takeDiscoveryScan(req, pathKey); + if (discovered) { + discoveredByPath.set(pathKey, discovered); } - const persistMs = Date.now() - persistStartMs; - - // Stringified so the values survive in the console after the snap's - // execution environment is torn down. - this.#logger.info( - `[createMany] Phase timings ${JSON.stringify({ - requested: reqs.length, - created: newAccounts.length, - lookupMs, - deriveMs, - persistMs, - totalMs: Date.now() - startMs, - })}`, - ); + } + const entriesToDerive = entriesToCreate.filter( + ({ pathKey }) => !discoveredByPath.has(pathKey), + ); + const derivedAccounts = + entriesToDerive.length > 0 + ? await this.#repository.createMany( + entriesToDerive.map(({ derivationPath, req }) => ({ + derivationPath, + network: req.network, + addressType: req.addressType, + })), + ) + : []; + const derivedByPath = new Map( + entriesToDerive.map((entry, index) => [ + entry.pathKey, + derivedAccounts[index] as BitcoinAccount, + ]), + ); + const newAccounts = entriesToCreate.map( + ({ pathKey }) => + (discoveredByPath.get(pathKey) ?? + derivedByPath.get(pathKey)) as BitcoinAccount, + ); - const newAccountsByPath = new Map( - entriesToCreate.map((entry, index) => [ - entry.pathKey, - newAccounts[index] as BitcoinAccount, - ]), - ); + for (const newAccount of newAccounts) { + newAccount.revealNextAddress(); + } + const deriveMs = Date.now() - deriveStartMs; + + const persistStartMs = Date.now(); + if (newAccounts.length > 0) { + // Reuse the lookup's derivation-path snapshot: account lifecycle + // mutations are serialized, while `insertMany` refreshes the + // accounts map to preserve concurrent sync updates. + await this.#repository.insertMany(newAccounts, snapshot); + } + const persistMs = Date.now() - persistStartMs; - const accountsInOrder = entries.map((entry) => { - const account = - existingAccountsByPath.get(entry.pathKey) ?? - newAccountsByPath.get(entry.pathKey); + // Stringified so the values survive in the console after the snap's + // execution environment is torn down. + this.#logger.info( + `[createMany] Phase timings ${JSON.stringify({ + requested: reqs.length, + created: newAccounts.length, + lookupMs, + deriveMs, + persistMs, + totalMs: Date.now() - startMs, + })}`, + ); - if (!account) { - throw new AssertionError('Failed to create account', { - index: entry.index, - derivationPath: entry.derivationPath, - }); - } + const newAccountsByPath = new Map( + entriesToCreate.map((entry, index) => [ + entry.pathKey, + newAccounts[index] as BitcoinAccount, + ]), + ); - return account; - }); + const accountsInOrder = entries.map((entry) => { + const account = + existingAccountsByPath.get(entry.pathKey) ?? + newAccountsByPath.get(entry.pathKey); - return { - accounts: accountsInOrder, - createdAccountKeys: new Set(newAccountsByPath.keys()), - }; - }, + if (!account) { + throw new AssertionError('Failed to create account', { + index: entry.index, + derivationPath: entry.derivationPath, + }); + } + + return account; + }); + + return { + accounts: accountsInOrder, + createdAccountKeys: new Set(newAccountsByPath.keys()), + discoveredAccountKeys: new Set(discoveredByPath.keys()), + syncOnlyAccountKeys: new Set( + entriesToDerive + .filter(({ req }) => this.#isBeyondDiscoveryFrontier(req)) + .map(({ pathKey }) => pathKey), + ), + }; + }); + + // Every new account needs one first synchronization covering its address + // gap (the discovery gap, extended to the standard gap when it finds + // activity, see `#runFullScan`); until it succeeds its balance is + // reported as unknown: + // - accounts discovery just scanned already had it (reused scan); + // - accounts above the lowest index discovery found unused are fresh + // (BIP-44 account discovery), so a regular sync is enough; + // - any other account needs a full scan: syncs only watch revealed + // addresses. They are all queued (also those scheduled below, in case + // the background event fails) so the cron retries with backoff. + const createdKeyByIndex = reqs.map((req) => + getDerivationPathKey(getAccountDerivationPath(req)), + ); + const createdAccountsByKind = ( + predicate: (pathKey: string) => boolean, + ): BitcoinAccount[] => [ + ...new Map( + accounts + .filter((_account, index) => { + const pathKey = createdKeyByIndex[index] as string; + return createdAccountKeys.has(pathKey) && predicate(pathKey); + }) + .map((account) => [account.id, account]), + ).values(), + ]; + const syncOnlyAccounts = createdAccountsByKind((pathKey) => + syncOnlyAccountKeys.has(pathKey), + ); + const createdAccounts = createdAccountsByKind( + (pathKey) => + !discoveredAccountKeys.has(pathKey) && + !syncOnlyAccountKeys.has(pathKey), ); + const createdAccountIds = createdAccounts.map(({ id }) => id); + try { + await this.#fullScanQueue.enqueueInitial(createdAccountIds); + } catch (error) { + this.#logger.warn('Failed to queue the initial full scan', error); + } - const scheduledAccountIds = new Set(); - for (const [index, account] of accounts.entries()) { - const req = reqs[index] as CreateAccountParams; - const pathKey = getDerivationPathKey(getAccountDerivationPath(req)); - if ( - req.synchronize && - createdAccountKeys.has(pathKey) && - !scheduledAccountIds.has(account.id) - ) { - scheduledAccountIds.add(account.id); + if (syncOnlyAccounts.length > 0) { + const syncOnlyIds = syncOnlyAccounts.map(({ id }) => id); + for (const id of syncOnlyIds) { + this.#syncOnlyAccounts.add(id); + } + try { await this.#snapClient.scheduleBackgroundEvent({ duration: 'PT1S', - method: CronMethod.FullScanAccount, - params: { accountId: account.id }, + method: CronMethod.SyncSelectedAccounts, + params: { accountIds: syncOnlyIds }, }); + } catch (error) { + this.#logger.warn('Failed to schedule the first sync', error); } } + // Start the first full scan right away (deduplicated, single-flight) so + // the first balance is not delayed until the next cron run; the queue + // above is the fallback if it fails or the background event is lost. + for (const account of createdAccounts.slice( + 0, + MAX_IMMEDIATE_FULL_SCANS_ON_CREATE, + )) { + await this.requestFullScan(account, 'created'); + } + + // A reused discovery scan only covered the discovery gap: an account where + // it found activity gets the standard-gap scan (its balance is already + // known, so it is not queued as an initial scan). + const discoveredWithActivity = createdAccountsByKind((pathKey) => + discoveredAccountKeys.has(pathKey), + ).filter((account) => (account.listTransactions() ?? []).length > 0); + for (const account of discoveredWithActivity) { + await this.requestFullScan(account, 'created'); + } + return accounts; } @@ -480,8 +1079,39 @@ export class AccountUseCases { ): Promise { this.#logger.debug('Synchronizing account: %s', account.id); + // A running full scan covers this sync: join it instead of fetching the + // same addresses concurrently. Its result carries the scanned account; + // its transactions are notified by the scan's own caller. + const inFlightScan = this.#fullScansInFlight.get(account.id); + if (inFlightScan) { + this.#logger.debug( + 'Full scan in progress, joining it instead of syncing: %s', + account.id, + ); + try { + const scanned = await inFlightScan; + return { account: scanned.account, transactionsToNotify: [] }; + } catch (error) { + // The joined scan failed: this sync failed too. Record it, so that + // the next successful sync is seen as a network recovery and runs the + // pending full scan promptly instead of waiting for its backoff. + this.#failingSyncs.add(account.id); + await this.#recordSyncFailure(account.id); + throw error; + } + } + const txsBeforeSync = account.listTransactions(); - await this.#chain.sync(account); + try { + await this.#chain.sync(account); + } catch (error) { + this.#failingSyncs.add(account.id); + await this.#recordSyncFailure(account.id); + throw error; + } + const networkRecovered = this.#failingSyncs.delete(account.id); + await this.#recordSyncSuccess(account.id); + this.#syncOnlyAccounts.delete(account.id); const txsAfterSync = account.listTransactions(); // If new transactions appeared, fetch inscriptions; otherwise, just update. @@ -557,6 +1187,16 @@ export class AccountUseCases { this.#logger.debug('Account synchronized successfully: %s', account.id); + // The sync reached the chain, so the network works: if the first full + // scan is still pending (e.g. it failed while the network was down and is + // now backing off), run it promptly instead of waiting for the backoff. + if (await this.isBalanceUnknown(account)) { + await this.requestFullScan( + account, + networkRecovered ? 'network-recovered' : 'sync-succeeded', + ); + } + return { account, transactionsToNotify: txsToNotify, @@ -568,15 +1208,78 @@ export class AccountUseCases { }; } + /** + * Performs a full scan of the account. Single-flight per account: a call + * made while a scan of the same account is running returns that scan's + * result instead of starting a concurrent one. + * + * @param account - The account. + * @returns The scan result. + */ async fullScan(account: BitcoinAccount): Promise { + const inFlight = this.#fullScansInFlight.get(account.id); + if (inFlight) { + this.#logger.debug('Joining in-flight full scan: %s', account.id); + return inFlight; + } + + const run = this.#runFullScan(account).finally(() => { + this.#fullScansInFlight.delete(account.id); + }); + this.#fullScansInFlight.set(account.id, run); + return run; + } + + async #runFullScan(account: BitcoinAccount): Promise { this.#logger.debug('Performing initial full scan: %s', account.id); - await this.#chain.fullScan(account); + this.#fullScanRequestedAt.delete(account.id); + const previousAttempt = this.#fullScanAttempts.get(account.id); + const attempt: FullScanAttemptInfo = { + lastAttemptAt: Date.now(), + consecutiveFailures: previousAttempt?.consecutiveFailures ?? 0, + }; + this.#fullScanAttempts.set(account.id, attempt); + + try { + // The first scan of an account uses the discovery gap: most new + // accounts are unused, and the standard gap costs four times the + // requests per empty account (onboarding scanned five accounts this + // way). Only an account where it finds activity is completed to the + // standard gap right after (checking just the addresses the standard + // gap adds), so used accounts keep their full address coverage. + const initialScan = !account.hasSynchronized(); + await this.#chain.fullScan(account, initialScan ? 'discovery' : 'scan'); + if (initialScan && account.listTransactions().length > 0) { + this.#logger.debug( + 'Activity found by the initial scan, completing it to the standard gap: %s', + account.id, + ); + await this.#chain.extendDiscoveryScan(account); + } + } catch (error) { + attempt.consecutiveFailures += 1; + await this.#recordSyncFailure(account.id); + await this.#fullScanQueue + .markFailed(account.id, error) + .catch((stateError: unknown) => + this.#logger.warn('Failed to record full scan failure', stateError), + ); + throw error; + } const inscriptions = this.#metaProtocols ? await this.#metaProtocols.fetchInscriptions(account) : []; await this.#repository.update(account, inscriptions); + attempt.consecutiveFailures = 0; + this.#failingSyncs.delete(account.id); + await this.#recordSyncSuccess(account.id); + await this.#fullScanQueue + .markSucceeded(account.id) + .catch((stateError: unknown) => + this.#logger.warn('Failed to record full scan success', stateError), + ); const transactionsToNotify = account.listTransactions(); @@ -599,6 +1302,13 @@ export class AccountUseCases { async delete(id: string): Promise { this.#logger.debug('Deleting account: %s', id); + this.#syncOnlyAccounts.delete(id); + this.#staleSyncRequestedAt.delete(id); + await this.#syncHealth + .forget(id) + .catch((stateError: unknown) => + this.#logger.warn('Failed to forget sync health', stateError), + ); await this.#runAccountMutation(async () => { const account = await this.#repository.get(id); if (!account) { diff --git a/packages/bitcoin-wallet-snap/src/utils/errors.test.ts b/packages/bitcoin-wallet-snap/src/utils/errors.test.ts index 5cc8b5e4b..3d72a75f5 100644 --- a/packages/bitcoin-wallet-snap/src/utils/errors.test.ts +++ b/packages/bitcoin-wallet-snap/src/utils/errors.test.ts @@ -1,6 +1,10 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { UserActionError } from '../entities'; +import { + BalanceNotSynchronizedError, + BalanceStaleError, + UserActionError, +} from '../entities'; import { shouldTrackError, trackError } from './errors'; import logger from './logger'; @@ -38,6 +42,31 @@ describe('errors', () => { expect(shouldTrackError(new UserRejectedRequestError())).toBe(false); }); + it('returns false for the "not synchronized yet" state however it was wrapped (v3)', () => { + const original = new BalanceNotSynchronizedError(); + // As re-created by the keyring SDK's handleKeyringRequest. + expect(shouldTrackError(new Error(original.message))).toBe(false); + expect(shouldTrackError(original)).toBe(false); + expect( + shouldTrackError(new Error('Snap request failed', { cause: original })), + ).toBe(false); + // Serialized error object (e.g. after crossing the snap boundary). + expect( + shouldTrackError({ name: 'Error', message: original.message }), + ).toBe(false); + expect(shouldTrackError(new Error('Failed to fetch'))).toBe(true); + }); + + it('returns false for the stale-balance state however it was wrapped (v6)', () => { + const original = new BalanceStaleError(); + expect(shouldTrackError(original)).toBe(false); + // As re-created by the keyring SDK's handleKeyringRequest. + expect(shouldTrackError(new Error(original.message))).toBe(false); + expect( + shouldTrackError(new Error('Snap request failed', { cause: original })), + ).toBe(false); + }); + it('returns true for other errors', () => { expect(shouldTrackError(new Error('boom'))).toBe(true); expect(shouldTrackError(new UserActionError('Another user action'))).toBe( diff --git a/packages/bitcoin-wallet-snap/src/utils/errors.ts b/packages/bitcoin-wallet-snap/src/utils/errors.ts index 82c4d14f0..479d31d64 100644 --- a/packages/bitcoin-wallet-snap/src/utils/errors.ts +++ b/packages/bitcoin-wallet-snap/src/utils/errors.ts @@ -1,7 +1,7 @@ import { createTrackError } from '@metamask/snap-networks-utils'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { UserActionError } from '../entities'; +import { isBalanceUnavailableError, UserActionError } from '../entities'; import { getSnapProvider } from '../infra/getSnapProvider'; import logger from './logger'; @@ -17,6 +17,11 @@ export function shouldTrackError(error: unknown): boolean { return false; } + // Expected state, never a failure, however the error was wrapped. + if (isBalanceUnavailableError(error)) { + return false; + } + return !( (error as UserActionError)?.message === 'User canceled the confirmation' ); diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 022f4a6ac..6c6633bd8 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -156,6 +156,11 @@ export { parseRetryAfter, } from './utils/fetchPolicy/executeWithFetchPolicy'; export type { FetchPolicy } from './utils/fetchPolicy/executeWithFetchPolicy'; +export { + shouldUseFallbackEndpoint, + withEndpointFallback, +} from './utils/fetchPolicy/withEndpointFallback'; +export type { WithEndpointFallbackOptions } from './utils/fetchPolicy/withEndpointFallback'; export { ActionHeader } from './ui/ActionHeader/ActionHeader'; export type { ActionHeaderProps } from './ui/ActionHeader/ActionHeader'; export { Asset, AssetIcon } from './ui/Asset/Asset'; diff --git a/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.test.ts b/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.test.ts new file mode 100644 index 000000000..41830a546 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.test.ts @@ -0,0 +1,90 @@ +import { BrokenCircuitError } from '@metamask/base-data-service'; + +import { + createFetchPolicyError, + FetchPolicyErrorName, +} from './createFetchPolicy'; +import { + shouldUseFallbackEndpoint, + withEndpointFallback, +} from './withEndpointFallback'; + +describe('shouldUseFallbackEndpoint', () => { + it.each([ + ['a network error', createFetchPolicyError('x', { name: FetchPolicyErrorName.Network })], + ['a timeout', createFetchPolicyError('x', { name: FetchPolicyErrorName.Timeout })], + ['a 503', createFetchPolicyError('x', { httpStatus: 503 })], + ['a 429', createFetchPolicyError('x', { httpStatus: 429 })], + ['a bare TypeError', new TypeError('Failed to fetch')], + ['an open circuit', new BrokenCircuitError()], + ['a non-error', 'boom'], + ])('falls back on %s', (_label, error) => { + expect(shouldUseFallbackEndpoint(error)).toBe(true); + }); + + it.each([ + ['a 404', createFetchPolicyError('x', { httpStatus: 404 })], + ['a 400', createFetchPolicyError('x', { httpStatus: 400 })], + ['an abort', Object.assign(new Error('aborted'), { name: 'AbortError' })], + ])('does not fall back on %s', (_label, error) => { + expect(shouldUseFallbackEndpoint(error)).toBe(false); + }); +}); + +describe('withEndpointFallback', () => { + it('returns the primary result', async () => { + const fallback = jest.fn(); + expect(await withEndpointFallback(async () => 1, fallback)).toBe(1); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('uses the fallback on a transport failure', async () => { + const onFallback = jest.fn(); + const result = await withEndpointFallback( + async () => { + throw new TypeError('Failed to fetch'); + }, + async () => 2, + { onFallback }, + ); + expect(result).toBe(2); + expect(onFallback).toHaveBeenCalledTimes(1); + }); + + it('rethrows the primary error without a fallback', async () => { + const error = new TypeError('Failed to fetch'); + await expect( + withEndpointFallback(async () => { + throw error; + }, undefined), + ).rejects.toBe(error); + }); + + it('does not fall back on a 404', async () => { + const fallback = jest.fn(); + const error = createFetchPolicyError('nf', { httpStatus: 404 }); + await expect( + withEndpointFallback(async () => { + throw error; + }, fallback), + ).rejects.toBe(error); + expect(fallback).not.toHaveBeenCalled(); + }); + + it('rethrows the primary error with the fallback error attached', async () => { + const primaryError = new TypeError('Failed to fetch'); + const fallbackError = new Error('also down'); + const rejection = withEndpointFallback( + async () => { + throw primaryError; + }, + async () => { + throw fallbackError; + }, + ); + await expect(rejection).rejects.toBe(primaryError); + expect((primaryError as unknown as { fallbackError: unknown }).fallbackError).toBe( + fallbackError, + ); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.ts b/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.ts new file mode 100644 index 000000000..c28cf394a --- /dev/null +++ b/packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.ts @@ -0,0 +1,100 @@ +import { BrokenCircuitError } from '@metamask/base-data-service'; + +import type { FetchPolicyError } from './createFetchPolicy'; +import { FetchPolicyErrorName } from './createFetchPolicy'; + +/** + * Decides whether a failed call to the primary endpoint should be repeated + * against a fallback endpoint. + * + * Fallback is meant for failures that say nothing about the request itself: + * the transport never produced a readable response (a CORS-blocked or reset + * connection surfaces as a bare `TypeError: Failed to fetch`), the call timed + * out, the primary's circuit is open, or the server reported a 5xx / 429. + * A readable 4xx (other than 429) is an answer about the request and is not + * retried elsewhere. + * + * Note that in a browser an HTTP/2-coalesced request that the server answers + * with a 404 *without* CORS headers is indistinguishable from a network + * failure, so it qualifies for fallback. + * + * @param error - The error thrown by the primary call. + * @returns `true` if the call should be retried against the fallback. + */ +export const shouldUseFallbackEndpoint = (error: unknown): boolean => { + if (error instanceof BrokenCircuitError) { + return true; + } + if (!(error instanceof Error)) { + return true; + } + if ((error as { name?: unknown }).name === 'AbortError') { + return false; + } + const { name, httpStatus } = error as FetchPolicyError; + if ( + name === FetchPolicyErrorName.Network || + name === FetchPolicyErrorName.Timeout + ) { + return true; + } + if (httpStatus === undefined) { + // Unknown error without a status: treat as transport-level. + return true; + } + return httpStatus === 429 || httpStatus >= 500; +}; + +export type WithEndpointFallbackOptions = { + /** + * Called when the primary failed and the fallback is about to be used. + */ + onFallback?: (error: unknown) => void; + /** + * Overrides {@link shouldUseFallbackEndpoint}. + */ + shouldFallback?: (error: unknown) => boolean; +}; + +/** + * Runs `primary`, and when it fails with a transport-level error (see + * {@link shouldUseFallbackEndpoint}) runs `fallback` instead. When no + * fallback is configured the primary error is rethrown unchanged. + * + * Each operation should already be wrapped in its own fetch policy, so the + * primary is retried with backoff first and its circuit breaker, once open, + * routes calls straight to the fallback without hitting the primary. + * + * If the fallback fails too, the **primary** error is rethrown (with the + * fallback error attached as `fallbackError`) so callers keep classifying + * the original failure. + * + * @param primary - The call against the primary endpoint. + * @param fallback - The call against the fallback endpoint, if any. + * @param options - Options. + * @returns The result of whichever call succeeded. + */ +export async function withEndpointFallback( + primary: () => Promise, + fallback: (() => Promise) | undefined, + options: WithEndpointFallbackOptions = {}, +): Promise { + try { + return await primary(); + } catch (primaryError) { + const shouldFallback = + options.shouldFallback ?? shouldUseFallbackEndpoint; + if (!fallback || !shouldFallback(primaryError)) { + throw primaryError; + } + options.onFallback?.(primaryError); + try { + return await fallback(); + } catch (fallbackError) { + if (primaryError instanceof Error) { + Object.assign(primaryError, { fallbackError }); + } + throw primaryError; + } + } +} diff --git a/packages/stellar-wallet-snap/.env.example b/packages/stellar-wallet-snap/.env.example index 86e05503c..762024e2a 100644 --- a/packages/stellar-wallet-snap/.env.example +++ b/packages/stellar-wallet-snap/.env.example @@ -72,4 +72,12 @@ SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io #STELLAR_MAX_RECONCILE_ATTEMPTS=5 # Maximum age of a pending transaction in milliseconds -#STELLAR_MAX_PENDING_TRANSACTION_AGE=30000 \ No newline at end of file +#STELLAR_MAX_PENDING_TRANSACTION_AGE=30000 +# Optional fallback Horizon / Soroban RPC endpoints for account reads (stale-balances fix). Used only +# when the primary endpoint keeps failing at the transport level (e.g. the Infura empty-404/no-CORS +# failure under Chrome HTTP/2 connection coalescing). Unset by default: sending wallet addresses to +# a third-party endpoint has privacy implications. +# STELLAR_HORIZON_FALLBACK_URL_MAINNET=https://horizon.stellar.org +# STELLAR_RPC_FALLBACK_URL_MAINNET= +# STELLAR_HORIZON_FALLBACK_URL_TESTNET=https://horizon-testnet.stellar.org +# STELLAR_RPC_FALLBACK_URL_TESTNET=https://soroban-testnet.stellar.org diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index 4d29314f1..c10a793c9 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -72,7 +72,8 @@ "lodash": "^4.17.21", "oxfmt": "^0.70.0", "qrcode-generator": "^2.0.4", - "ts-jest": "^29.4.14" + "ts-jest": "^29.4.14", + "webpack": "5.107.2" }, "engines": { "node": "^22.14.0 || ^24" diff --git a/packages/stellar-wallet-snap/snap.config.ts b/packages/stellar-wallet-snap/snap.config.ts index a3a0eb5ac..164e969e8 100644 --- a/packages/stellar-wallet-snap/snap.config.ts +++ b/packages/stellar-wallet-snap/snap.config.ts @@ -3,6 +3,7 @@ import type { SnapConfig } from '@metamask/snaps-cli'; import { merge } from '@metamask/snaps-cli'; import { config as dotenv } from 'dotenv'; import { resolve } from 'path'; +import { ProvidePlugin } from 'webpack'; dotenv(); @@ -25,6 +26,17 @@ const config: SnapConfig = { STELLAR_HORIZON_URL_TESTNET: process.env.STELLAR_HORIZON_URL_TESTNET ?? '', STELLAR_EXPLORER_TESTNET_BASE_URL: process.env.STELLAR_EXPLORER_TESTNET_BASE_URL ?? '', + // Optional public endpoints used only when the primary one fails at the + // transport level (e.g. the Infura empty-404 / no-CORS failure). Unset by + // default: no request leaves the primary provider. + STELLAR_HORIZON_FALLBACK_URL_MAINNET: + process.env.STELLAR_HORIZON_FALLBACK_URL_MAINNET ?? '', + STELLAR_RPC_FALLBACK_URL_MAINNET: + process.env.STELLAR_RPC_FALLBACK_URL_MAINNET ?? '', + STELLAR_HORIZON_FALLBACK_URL_TESTNET: + process.env.STELLAR_HORIZON_FALLBACK_URL_TESTNET ?? '', + STELLAR_RPC_FALLBACK_URL_TESTNET: + process.env.STELLAR_RPC_FALLBACK_URL_TESTNET ?? '', STELLAR_TRANSACTION_TIMEOUT: process.env.STELLAR_TRANSACTION_TIMEOUT ?? '', STELLAR_TRANSACTION_POLLING_ATTEMPTS: process.env.STELLAR_TRANSACTION_POLLING_ATTEMPTS ?? '', @@ -70,6 +82,13 @@ const config: SnapConfig = { eventsource: resolve(__dirname, 'src/shims/eventsource.ts'), }, }, + // `createFetchPolicy` (cockatiel) times calls with `performance.now()`, + // which the snap runtime doesn't provide. + plugins: [ + new ProvidePlugin({ + performance: '@metamask/snap-networks-utils/performance-shim', + }), + ], }), }; diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 0b54358f5..b68fe03a5 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -6,7 +6,7 @@ import { UrlStruct, } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; -import { boolean, enums, object } from '@metamask/superstruct'; +import { boolean, enums, object, optional } from '@metamask/superstruct'; /* eslint-disable no-restricted-globals */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ @@ -32,6 +32,14 @@ const networkConfigStruct = object({ rpcUrl: UrlStruct, horizonUrl: UrlStruct, explorerBaseUrl: UrlStruct, + /** + * Optional Horizon endpoint used only when the primary fails at the transport level. + */ + horizonFallbackUrl: optional(UrlStruct), + /** + * Optional Soroban RPC endpoint used only when the primary fails at the transport level. + */ + rpcFallbackUrl: optional(UrlStruct), }); /** @@ -140,6 +148,9 @@ const ENVIRONMENT = { explorerBaseUrl: process.env.STELLAR_EXPLORER_MAINNET_BASE_URL || DEFAULT_EXPLORER_MAINNET_BASE_URL, + horizonFallbackUrl: + process.env.STELLAR_HORIZON_FALLBACK_URL_MAINNET || undefined, + rpcFallbackUrl: process.env.STELLAR_RPC_FALLBACK_URL_MAINNET || undefined, }, [KnownCaip2ChainId.Testnet]: { rpcUrl: process.env.STELLAR_RPC_URL_TESTNET, @@ -147,6 +158,9 @@ const ENVIRONMENT = { explorerBaseUrl: process.env.STELLAR_EXPLORER_TESTNET_BASE_URL || DEFAULT_EXPLORER_TESTNET_BASE_URL, + horizonFallbackUrl: + process.env.STELLAR_HORIZON_FALLBACK_URL_TESTNET || undefined, + rpcFallbackUrl: process.env.STELLAR_RPC_FALLBACK_URL_TESTNET || undefined, }, }, selectedNetwork: KnownCaip2ChainId.Mainnet, diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 95829ab5b..45e1c5837 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -14,7 +14,11 @@ import { TransactionValidationException, } from '../../services/transaction/exceptions'; import { HttpException } from '../../utils'; -import { StellarSnapException } from '../../utils/errors'; +import { + BALANCE_NOT_SYNCHRONIZED_MESSAGE, + BALANCE_STALE_MESSAGE, + StellarSnapException, +} from '../../utils/errors'; export class KeyringException extends StellarSnapException {} @@ -23,6 +27,35 @@ export class KeyringException extends StellarSnapException {} */ export class ExportAccountException extends KeyringException {} +/** + * Thrown by `getAccountBalances` when the account has no synchronized snapshot and is not + * confirmed unfunded: its balance is unknown, not zero. This is the expected state of a new + * account until its first sync, so it is not tracked as an error. + */ +export class AccountBalanceUnavailableException extends KeyringException { + /** An expected transient state, not a failure: never reported to Sentry. */ + readonly isExpectedState = true as const; + + constructor(accountId: string) { + super(BALANCE_NOT_SYNCHRONIZED_MESSAGE, { data: { accountId } }); + } +} + +/** + * Thrown by `getAccountBalances` when the stored snapshot is out of date: the syncs have been + * failing for longer than the stale threshold since the last successful one (e.g. the + * connection broke after the account was synchronized). Expected state: not tracked, and + * cleared by the next successful sync. + */ +export class AccountBalanceStaleException extends KeyringException { + /** An expected transient state, not a failure: never reported to Sentry. */ + readonly isExpectedState = true as const; + + constructor(accountId: string) { + super(BALANCE_STALE_MESSAGE, { data: { accountId } }); + } +} + /** * SEP-43 error codes. * diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index eab9b0e68..8861856b8 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -45,6 +45,7 @@ import { Duration, } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; +import { BALANCE_STALE_MESSAGE } from '../../utils/errors'; import { logger } from '../../utils/logger'; import { SyncAccountsHandler } from '../cronjob/syncAccounts'; import { @@ -54,8 +55,15 @@ import { SignTransactionResponseStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; -import { ExportAccountException } from './exceptions'; -import { KeyringHandler } from './keyring'; +import { + AccountBalanceStaleException, + AccountBalanceUnavailableException, + ExportAccountException, +} from './exceptions'; +import { + KeyringHandler, + UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS, +} from './keyring'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); @@ -726,13 +734,16 @@ describe('KeyringHandler', () => { }); }); - it('returns zero native balance when the account is not indexed', async () => { + it('returns zero native balance when the account is not indexed and confirmed unfunded', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(null); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(true); const fetchAndBindSpy = jest.spyOn( OnChainAccountService.prototype, 'fetchAndBindSep41Balances', @@ -826,6 +837,231 @@ describe('KeyringHandler', () => { keyringHandler.getAccountBalances(mockAccountId, [slipId]), ).rejects.toThrow('state unavailable'); }); + + it('throws AccountBalanceUnavailableException when the account was never synchronized', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + const isConfirmedUnfundedSpy = jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(false); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + expect(isConfirmedUnfundedSpy).toHaveBeenCalledWith( + mockAccountId, + KnownCaip2ChainId.Mainnet, + ); + }); + + describe('stale balance after failing syncs (v6)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const setUpSyncedAccount = (): void => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockResolvedValue( + createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 2.000001, + }), + ); + }; + + it('throws the untracked AccountBalanceStaleException and schedules a sync when the balance is stale', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpSyncedAccount(); + const staleSpy = jest + .spyOn(OnChainAccountService.prototype, 'isBalanceStale') + .mockResolvedValue(true); + const syncSpy = jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + const promise = keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + await expect(promise).rejects.toThrow(AccountBalanceStaleException); + await expect(promise).rejects.toThrow(BALANCE_STALE_MESSAGE); + expect(staleSpy).toHaveBeenCalledWith( + mockAccountId, + KnownCaip2ChainId.Mainnet, + ); + expect(syncSpy).toHaveBeenCalledWith( + { accountIds: [mockAccountId] }, + Duration.OneSecond, + ); + }); + + it('also fails for a confirmed-unfunded account whose syncs keep failing', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(true); + jest + .spyOn(OnChainAccountService.prototype, 'isBalanceStale') + .mockResolvedValue(true); + jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceStaleException); + }); + + it('answers from the snapshot when the balance is not stale', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpSyncedAccount(); + jest + .spyOn(OnChainAccountService.prototype, 'isBalanceStale') + .mockResolvedValue(false); + const syncSpy = jest.spyOn( + SyncAccountsHandler, + 'scheduleBackgroundEvent', + ); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(result[slipId]?.amount).toBe('2.000001'); + expect(syncSpy).not.toHaveBeenCalled(); + }); + }); + + describe('on-demand sync for never-synchronized accounts', () => { + const setUpUnsyncedAccount = (): void => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(false); + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('schedules one background sync for the account and still reports it as unavailable', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpUnsyncedAccount(); + const syncSpy = jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith( + { accountIds: [mockAccountId] }, + Duration.OneSecond, + ); + }); + + it('deduplicates bursts of balance requests within the interval', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpUnsyncedAccount(); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + const syncSpy = jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + for (let i = 0; i < 5; i++) { + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + } + expect(syncSpy).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue( + 1_000_000 + UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS, + ); + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + expect(syncSpy).toHaveBeenCalledTimes(2); + }); + + it('still throws the unavailable error, and retries scheduling next time, when scheduling fails', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpUnsyncedAccount(); + const syncSpy = jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockRejectedValueOnce(new Error('scheduling failed')) + .mockResolvedValue(undefined); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(AccountBalanceUnavailableException); + + expect(syncSpy).toHaveBeenCalledTimes(2); + }); + + it('does not schedule a sync for a confirmed-unfunded account', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + setUpUnsyncedAccount(); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(true); + const syncSpy = jest + .spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + await keyringHandler.getAccountBalances(mockAccountId, [slipId]); + + expect(syncSpy).not.toHaveBeenCalled(); + }); + }); + + it('returns zero native balance when the account is confirmed unfunded on-chain', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(true); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(result).toStrictEqual({ + [slipId]: { + unit: 'XLM', + amount: '0', + metadata: { + spendableBalance: '0', + minimumReserveBalance: '0', + decimal: 7, + }, + }, + }); + }); }); describe('when local config asset migration is enabled', () => { @@ -844,11 +1080,17 @@ describe('KeyringHandler', () => { const resolveOnChainAccountSpy = jest .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockResolvedValue(onChainAccount); + // A live read is current: the snap's sync health is not consulted. + const staleSpy = jest.spyOn( + OnChainAccountService.prototype, + 'isBalanceStale', + ); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, ]); + expect(staleSpy).not.toHaveBeenCalled(); expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( mockAccount.address, KnownCaip2ChainId.Mainnet, @@ -924,6 +1166,11 @@ describe('KeyringHandler', () => { KnownCaip2ChainId.Mainnet, ), ); + // Horizon confirmed the account is not activated: no unfunded marker is needed. + const isConfirmedUnfundedSpy = jest.spyOn( + OnChainAccountService.prototype, + 'isConfirmedUnfunded', + ); await keyringHandler.getAccountAssets(mockAccountId); resolveOnChainAccountSpy.mockClear(); @@ -933,6 +1180,7 @@ describe('KeyringHandler', () => { ]); expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(isConfirmedUnfundedSpy).not.toHaveBeenCalled(); expect(result).toStrictEqual({ [slipId]: { unit: 'XLM', @@ -946,6 +1194,19 @@ describe('KeyringHandler', () => { }); }); + it('propagates a live Horizon failure instead of reporting a zero balance', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow('Horizon unavailable'); + }); + it('loads Horizon when a previous asset read failed and was not cached', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 564bead82..54ca9ae71 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -82,13 +82,27 @@ import { GetAccountBalancesRequestStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; -import { ExportAccountException } from './exceptions'; +import { + AccountBalanceStaleException, + AccountBalanceUnavailableException, + ExportAccountException, +} from './exceptions'; export const KEYRING_HANDLER_LOGGER_PREFIX = '[🔑 KeyringHandler]'; +/** + * Minimum interval between two on-demand syncs requested by `getAccountBalances` for the + * same never-synchronized account. Balance requests arrive in bursts (several client + * controllers, retries), so this keeps them to one background sync per window. + */ +export const UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS = 30_000; + export class KeyringHandler implements KeyringSnapRpc { readonly #logger: Logger; + /** Last time an on-demand sync was requested per never-synchronized account. */ + readonly #unsyncedSyncRequestedAt = new Map(); + readonly #accountService: AccountService; readonly #onChainAccountService: OnChainAccountService; @@ -380,6 +394,28 @@ export class KeyringHandler implements KeyringSnapRpc { // Unfunded accounts have no trustlines. Return native zero when it was requested. if (onChainAccount === null) { + // A live fetch returns `null` only when Horizon confirmed the account is not activated + // (an empty-body 404 counts as a network failure, see `isAccountNotFoundError`), so its + // balance is a confirmed 0. A missing snapshot is either a confirmed unfunded account or + // one that was never synchronized (e.g. every load failed), whose balance is unknown. + // Reporting 0 for the latter shows a confident zero to the user; fail instead so the + // client keeps the balance as unavailable and retries. + if ( + !enableAssetMigration && + !(await this.#onChainAccountService.isConfirmedUnfunded( + accountId, + scope, + )) + ) { + // Answering from local state alone would never recover: kick off a (deduplicated) + // sync of this account so a later balance request, or the balances-updated event + // emitted by the sync, returns the real balance. + await this.#requestSyncForUnsyncedAccount(accountId); + throw new AccountBalanceUnavailableException(accountId); + } + if (!enableAssetMigration) { + await this.#assertBalanceNotStale(accountId, scope); + } const nativeAssetId = knownAssets.find(isSlip44Id); if (nativeAssetId !== undefined) { assetBalances[nativeAssetId] = getDefaultBalanceEntry(); @@ -387,6 +423,11 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } + // A live fetch is current by definition; a stored snapshot may be out of date. + if (!enableAssetMigration) { + await this.#assertBalanceNotStale(accountId, scope); + } + // Indexed snapshots and the Tokens API watchlist can both omit a SEP-41 id // the client asked for. Fetch those balances when nothing was stored. const missingSep41AssetIds = knownAssets.filter( @@ -540,6 +581,58 @@ export class KeyringHandler implements KeyringSnapRpc { } } + /** + * Fails when the stored balance is out of date (the syncs have been failing since the last + * successful one for longer than the stale threshold), so the client shows it as + * unavailable instead of a frozen value; also kicks off a (deduplicated) sync so the next + * successful one clears the state. + * + * @param accountId - The keyring account ID. + * @param scope - CAIP-2 network. + */ + async #assertBalanceNotStale( + accountId: string, + scope: KnownCaip2ChainId, + ): Promise { + if (await this.#onChainAccountService.isBalanceStale(accountId, scope)) { + await this.#requestSyncForUnsyncedAccount(accountId); + throw new AccountBalanceStaleException(accountId); + } + } + + /** + * Schedules a background sync for an account whose balance is unknown or out of date, at most once per + * {@link UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS}. Never throws: the balance request must + * still report the account as unavailable. + * + * @param accountId - The keyring account ID. + */ + async #requestSyncForUnsyncedAccount(accountId: string): Promise { + const now = Date.now(); + const lastRequestedAt = this.#unsyncedSyncRequestedAt.get(accountId); + if ( + lastRequestedAt !== undefined && + now - lastRequestedAt < UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS + ) { + return; + } + this.#unsyncedSyncRequestedAt.set(accountId, now); + try { + await SyncAccountsHandler.scheduleBackgroundEvent( + { accountIds: [accountId] }, + Duration.OneSecond, + ); + } catch (error: unknown) { + this.#unsyncedSyncRequestedAt.delete(accountId); + this.#logger.warn( + 'Failed to schedule a sync for an unsynchronized account', + { + error, + }, + ); + } + } + async submitRequest(request: KeyringRequest): Promise { const { method } = request.request; diff --git a/packages/stellar-wallet-snap/src/index.test.ts b/packages/stellar-wallet-snap/src/index.test.ts index 2b4adb590..290cc0826 100644 --- a/packages/stellar-wallet-snap/src/index.test.ts +++ b/packages/stellar-wallet-snap/src/index.test.ts @@ -1,3 +1,4 @@ +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import { noopAssetHandlers } from '@metamask/snap-networks-utils'; import { SnapError } from '@metamask/snaps-sdk'; @@ -11,6 +12,7 @@ import { onKeyringRequest, onUserInput, } from '.'; +import { AccountBalanceUnavailableException } from './handlers/keyring/exceptions'; import { logger } from './utils/logger'; const mockKeyringHandle = jest.fn(); @@ -121,6 +123,60 @@ describe('entrypoints', () => { }, ); + describe('the "not synchronized yet" balance state through the real keyring path (v3)', () => { + const balancesRequest = { + jsonrpc: '2.0', + id: 1, + method: 'keyring_getAccountBalances', + params: { + id: '9b1c3a4e-2f6d-4b8a-9c1e-5d7f3a2b1c0d', + assets: ['stellar:pubnet/slip44:148'], + }, + } as const; + + const keyringThrowing = (error: Error): unknown => ({ + getAccountBalances: jest.fn().mockRejectedValue(error), + }); + + it('is re-created by handleKeyringRequest as a plain Error, yet not tracked', async () => { + const keyring = keyringThrowing( + new AccountBalanceUnavailableException('account-1'), + ); + // Same as KeyringHandler.handle: the SDK dispatches the request and + // re-creates every non-SnapError as `new Error(message)`. + mockKeyringHandle.mockImplementation(async (_origin, keyringRequest) => + handleKeyringRequest(keyring as never, keyringRequest), + ); + + await expect( + onKeyringRequest({ origin, request: balancesRequest } as never), + ).rejects.toThrow('not been synchronized yet'); + + expect(mockKeyringHandle).toHaveBeenCalled(); + expect(snapRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'snap_trackError' }), + ); + }); + + it('still tracks other keyring errors re-created by the SDK', async () => { + const keyring = keyringThrowing(new Error('Horizon request failed')); + mockKeyringHandle.mockImplementation(async (_origin, keyringRequest) => + handleKeyringRequest(keyring as never, keyringRequest), + ); + + await expect( + onKeyringRequest({ origin, request: balancesRequest } as never), + ).rejects.toBeInstanceOf(SnapError); + + expect(snapRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ message: 'Horizon request failed' }), + }, + }); + }); + }); + it('exports the shared no-op asset handlers', () => { expect({ onAssetsLookup, diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index ae524d71e..10b4c7209 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -376,6 +376,144 @@ describe('NetworkService', () => { }); }); + describe('read retries and fallback endpoints', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + const HORIZON_FALLBACK = 'https://horizon-fallback.example.org'; + const RPC_FALLBACK = 'https://rpc-fallback.example.org'; + const isFallback = (client: { serverURL: unknown }, url: string): boolean => + String(client.serverURL).startsWith(url); + let originalNetworkConfig: (typeof AppConfig.networks)[KnownCaip2ChainId]; + + beforeEach(() => { + originalNetworkConfig = AppConfig.networks[KnownCaip2ChainId.Mainnet]; + }); + + afterEach(() => { + AppConfig.networks[KnownCaip2ChainId.Mainnet] = originalNetworkConfig; + }); + + const enableFallbacks = (): void => { + AppConfig.networks[KnownCaip2ChainId.Mainnet] = { + ...originalNetworkConfig, + horizonFallbackUrl: HORIZON_FALLBACK, + rpcFallbackUrl: RPC_FALLBACK, + }; + }; + + const mockAccount = (): StellarHorizon.AccountResponse => + createMockAccountWithBalances(testAddress, '1', { + nativeBalance: 1, + assets: [], + }) as unknown as StellarHorizon.AccountResponse; + + it('retries a transient Horizon failure on the primary endpoint', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy + .mockRejectedValueOnce(new Error('Failed to fetch')) + .mockResolvedValueOnce(mockAccount()); + + const result = await networkService.loadOnChainAccount( + testAddress, + scope, + ); + + expect(result.accountId).toBe(testAddress); + expect(loadAccountSpy).toHaveBeenCalledTimes(2); + }); + + it('does not retry a confirmed missing account', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue( + new NotFoundError('Not Found', { status: 404 }), + ); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(AccountNotActivatedException); + expect(loadAccountSpy).toHaveBeenCalledTimes(1); + }); + + it('treats an empty-body 404 as a network failure, not a missing account', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new NotFoundError('Not Found', '')); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(NetworkServiceException); + expect(loadAccountSpy.mock.calls.length).toBeGreaterThan(1); + }); + + it('uses the Horizon fallback endpoint when the primary keeps failing', async () => { + enableFallbacks(); + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockImplementation(async function ( + this: StellarHorizon.Server, + ) { + if (isFallback(this, HORIZON_FALLBACK)) { + return mockAccount(); + } + throw new Error('Failed to fetch'); + }); + + const result = await networkService.loadOnChainAccount( + testAddress, + scope, + ); + + expect(result.accountId).toBe(testAddress); + const fallbackCalls = loadAccountSpy.mock.contexts.filter((context) => + isFallback(context as StellarHorizon.Server, HORIZON_FALLBACK), + ); + expect(fallbackCalls).toHaveLength(1); + expect(loadAccountSpy.mock.calls.length).toBeGreaterThan(1); + }); + + it('does not use the fallback for a confirmed missing account', async () => { + enableFallbacks(); + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue( + new NotFoundError('Not Found', { status: 404 }), + ); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(AccountNotActivatedException); + expect(loadAccountSpy).toHaveBeenCalledTimes(1); + }); + + it('reports the primary error when the fallback also fails', async () => { + enableFallbacks(); + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new Error('Failed to fetch')); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(NetworkServiceException); + const fallbackCalls = loadAccountSpy.mock.contexts.filter((context) => + isFallback(context as StellarHorizon.Server, HORIZON_FALLBACK), + ); + expect(fallbackCalls.length).toBeGreaterThan(0); + }); + + it('uses the RPC fallback endpoint for getAccount', async () => { + enableFallbacks(); + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockImplementation(async function ( + this: StellarRpc.Server, + ) { + if (isFallback(this, RPC_FALLBACK)) { + return new Account(testAddress, '7'); + } + throw new Error('Failed to fetch'); + }); + + const result = await networkService.getAccount(testAddress, scope); + + expect(result.sequenceNumber).toBe('7'); + }); + }); + describe('getAccountLedgerMetadata', () => { const testAddress = 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index d16164ad7..379cb276d 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -1,9 +1,15 @@ import type { + FetchPolicy, ICache, Logger, Serializable, } from '@metamask/snap-networks-utils'; -import { useCache } from '@metamask/snap-networks-utils'; +import { + createFetchPolicy, + executeWithFetchPolicy, + useCache, + withEndpointFallback, +} from '@metamask/snap-networks-utils'; import { Address, Horizon as StellarHorizon, @@ -51,8 +57,20 @@ import { baseInclusionFee, isAccountNotFoundError, sep41MulticallCellToBalance, + toStellarFetchPolicyError, + unwrapFetchPolicyError, } from './utils'; +type ReadEndpoint = { + client: Client; + policy: FetchPolicy; +}; + +type ReadEndpoints = { + primary: ReadEndpoint; + fallback?: ReadEndpoint; +}; + /** * Stellar network access through **Horizon** and **Soroban RPC**: base fee, account loading (full * Horizon account vs RPC sequence-only), SEP-41 balance simulation, Soroban simulation / fee @@ -70,6 +88,16 @@ export class NetworkService { readonly #rpcClientMap = new Map(); + readonly #horizonReadEndpoints = new Map< + KnownCaip2ChainId, + ReadEndpoints + >(); + + readonly #rpcReadEndpoints = new Map< + KnownCaip2ChainId, + ReadEndpoints + >(); + constructor({ logger, cache, @@ -101,6 +129,112 @@ export class NetworkService { return client; } + #getHorizonReadEndpoints( + scope: KnownCaip2ChainId, + ): ReadEndpoints { + let endpoints = this.#horizonReadEndpoints.get(scope); + if (!endpoints) { + const { horizonUrl, horizonFallbackUrl } = this.#getNetworkConfig(scope); + endpoints = { + primary: { + client: this.#getHorizonClient(scope), + policy: createFetchPolicy({ + service: 'stellar_horizon', + chainIdCaip: scope, + logger: this.#logger, + }), + }, + ...(horizonFallbackUrl && horizonFallbackUrl !== horizonUrl + ? { + fallback: { + client: new StellarHorizon.Server(horizonFallbackUrl), + policy: createFetchPolicy({ + service: 'stellar_horizon_fallback', + chainIdCaip: scope, + logger: this.#logger, + }), + }, + } + : {}), + }; + this.#horizonReadEndpoints.set(scope, endpoints); + } + return endpoints; + } + + #getRpcReadEndpoints(scope: KnownCaip2ChainId): ReadEndpoints { + let endpoints = this.#rpcReadEndpoints.get(scope); + if (!endpoints) { + const { rpcUrl, rpcFallbackUrl } = this.#getNetworkConfig(scope); + endpoints = { + primary: { + client: this.#getRpcClient(scope), + policy: createFetchPolicy({ + service: 'stellar_rpc', + chainIdCaip: scope, + logger: this.#logger, + }), + }, + ...(rpcFallbackUrl && rpcFallbackUrl !== rpcUrl + ? { + fallback: { + client: new rpc.Server(rpcFallbackUrl), + policy: createFetchPolicy({ + service: 'stellar_rpc_fallback', + chainIdCaip: scope, + logger: this.#logger, + }), + }, + } + : {}), + }; + this.#rpcReadEndpoints.set(scope, endpoints); + } + return endpoints; + } + + /** + * Runs an idempotent read under the endpoint's fetch policy (retry with backoff on transient + * failures, circuit breaker) and, on a transport-level failure, against the optional fallback + * endpoint. Rethrows the original SDK error so callers keep classifying it. + * + * @param endpoints - The primary and optional fallback endpoints. + * @param read - The read, given the client to use. + * @param accountAddress - The account the read is about, to recognise a confirmed 404. + * @returns The read result. + */ + async #read( + endpoints: ReadEndpoints, + read: (client: Client) => Promise, + accountAddress?: string, + ): Promise { + const run = async ({ + client, + policy, + }: ReadEndpoint): Promise => + executeWithFetchPolicy( + policy, + async () => read(client), + (error) => toStellarFetchPolicyError(error, accountAddress), + ); + const { fallback } = endpoints; + try { + return await withEndpointFallback( + async () => run(endpoints.primary), + fallback ? async (): Promise => run(fallback) : undefined, + { + onFallback: (error): void => + this.#logger.warn( + 'Primary endpoint failed, using fallback endpoint', + { error }, + ), + }, + ); + } catch (error: unknown) { + throw unwrapFetchPolicyError(error); + } + } + #getNetworkConfig(scope: KnownCaip2ChainId): NetworkConfig { const config = AppConfig.networks[scope]; if (!config) { @@ -226,11 +360,12 @@ export class NetworkService { scope: KnownCaip2ChainId, ): Promise { try { - const client = this.#getHorizonClient(scope); - return OnChainAccount.fromHorizon( - await client.loadAccount(accountAddress), - scope, + const response = await this.#read( + this.#getHorizonReadEndpoints(scope), + async (client) => client.loadAccount(accountAddress), + accountAddress, ); + return OnChainAccount.fromHorizon(response, scope); } catch (error: unknown) { if (isAccountNotFoundError(error, accountAddress)) { throw new AccountNotActivatedException(accountAddress, scope, { @@ -338,8 +473,11 @@ export class NetworkService { scope: KnownCaip2ChainId, ): Promise { try { - const client = this.#getRpcClient(scope); - const loaded = await client.getAccount(accountAddress); + const loaded = await this.#read( + this.#getRpcReadEndpoints(scope), + async (client) => client.getAccount(accountAddress), + accountAddress, + ); return new OnChainAccount(loaded, scope); } catch (error: unknown) { if (isAccountNotFoundError(error, accountAddress)) { diff --git a/packages/stellar-wallet-snap/src/services/network/utils.test.ts b/packages/stellar-wallet-snap/src/services/network/utils.test.ts index 1891ae859..989a70915 100644 --- a/packages/stellar-wallet-snap/src/services/network/utils.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/utils.test.ts @@ -1,8 +1,16 @@ +import { FetchPolicyErrorName } from '@metamask/snap-networks-utils'; +import { NetworkError, NotFoundError } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { AppConfig } from '../../config'; import { toSmallestUnit } from '../../utils'; -import { multiplyFee } from './utils'; +import { + isAccountNotFoundError, + isEmptyHttpBody, + multiplyFee, + toStellarFetchPolicyError, + unwrapFetchPolicyError, +} from './utils'; const maxFeeThresholdInStroops = toSmallestUnit( new BigNumber(AppConfig.transaction.maxFeeThresholdInXLM), @@ -27,3 +35,117 @@ describe('multiplyFee', () => { ); }); }); + +const ADDRESS = 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI'; +const horizonNotFoundBody = { + type: 'https://stellar.org/horizon-errors/not_found', + title: 'Resource Missing', + status: 404, +}; + +describe('isEmptyHttpBody', () => { + it.each([undefined, null, '', ' '])('returns true for %p', (body) => { + expect(isEmptyHttpBody(body)).toBe(true); + }); + + it.each([horizonNotFoundBody, 'Not Found', {}])( + 'returns false for %p', + (body) => { + expect(isEmptyHttpBody(body)).toBe(false); + }, + ); +}); + +describe('isAccountNotFoundError', () => { + it('returns true for a Horizon 404 with a problem body', () => { + expect( + isAccountNotFoundError( + new NotFoundError('Not Found', horizonNotFoundBody), + ADDRESS, + ), + ).toBe(true); + }); + + it.each([undefined, ''])( + 'returns false for an empty-body 404 (%p), which does not come from Horizon', + (body) => { + expect( + isAccountNotFoundError(new NotFoundError('Not Found', body), ADDRESS), + ).toBe(false); + }, + ); + + it('returns true for the RPC "Account not found" error', () => { + expect( + isAccountNotFoundError( + new Error(`Account not found: ${ADDRESS}`), + ADDRESS, + ), + ).toBe(true); + }); + + it('returns false for a network failure', () => { + expect(isAccountNotFoundError(new TypeError('Failed to fetch'), ADDRESS)).toBe( + false, + ); + }); +}); + +describe('toStellarFetchPolicyError', () => { + it('maps a confirmed missing account to a non-retryable 404', () => { + const original = new NotFoundError('Not Found', horizonNotFoundBody); + + const mapped = toStellarFetchPolicyError(original, ADDRESS); + + expect(mapped.httpStatus).toBe(404); + expect(mapped.cause).toBe(original); + }); + + it('maps an empty-body 404 to a network error', () => { + const original = new NotFoundError('Not Found', ''); + + const mapped = toStellarFetchPolicyError(original, ADDRESS); + + expect(mapped.name).toBe(FetchPolicyErrorName.Network); + expect(mapped.httpStatus).toBeUndefined(); + expect(mapped.cause).toBe(original); + }); + + it('keeps the status of a Horizon error response', () => { + const original = new NetworkError('Service Unavailable', { + status: 503, + title: 'Service Unavailable', + } as never); + + const mapped = toStellarFetchPolicyError(original, ADDRESS); + + expect(mapped.httpStatus).toBe(503); + expect(mapped.cause).toBe(original); + }); + + it('maps a bare fetch failure to a network error', () => { + const original = new Error('Failed to fetch'); + + const mapped = toStellarFetchPolicyError(original); + + expect(mapped.name).toBe(FetchPolicyErrorName.Network); + expect(mapped.message).toBe('Failed to fetch'); + expect(mapped.cause).toBe(original); + }); +}); + +describe('unwrapFetchPolicyError', () => { + it('returns the original SDK error of a mapped error', () => { + const original = new NotFoundError('Not Found', horizonNotFoundBody); + + expect( + unwrapFetchPolicyError(toStellarFetchPolicyError(original, ADDRESS)), + ).toBe(original); + }); + + it('returns other errors unchanged', () => { + const error = new Error('boom'); + + expect(unwrapFetchPolicyError(error)).toBe(error); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/network/utils.ts b/packages/stellar-wallet-snap/src/services/network/utils.ts index 9852cdc3e..38f4bea80 100644 --- a/packages/stellar-wallet-snap/src/services/network/utils.ts +++ b/packages/stellar-wallet-snap/src/services/network/utils.ts @@ -1,5 +1,10 @@ +import type { FetchPolicyError } from '@metamask/snap-networks-utils'; +import { + createFetchPolicyError, + FetchPolicyErrorName, +} from '@metamask/snap-networks-utils'; import { ensureError } from '@metamask/utils'; -import { Networks, NotFoundError } from '@stellar/stellar-sdk'; +import { NetworkError, Networks, NotFoundError } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; @@ -71,6 +76,18 @@ export function sep41MulticallCellToBalance(value: unknown): BigNumber | null { return null; } +/** + * @param body - A parsed HTTP response body. + * @returns Whether the body is empty (no body, or an empty / whitespace-only string). + */ +export function isEmptyHttpBody(body: unknown): boolean { + return ( + body === undefined || + body === null || + (typeof body === 'string' && body.trim() === '') + ); +} + /** * Detects the error shape thrown by Soroban RPC `getAccount` / `getAccountEntry` when the account * ledger entry is missing (`Error` with message `Account not found: `). @@ -84,7 +101,11 @@ export function isAccountNotFoundError( accountAddress: string, ): boolean { if (error instanceof NotFoundError) { - return true; + // Horizon answers a missing account with a JSON problem body. An empty 404 comes from + // something in front of Horizon (e.g. a provider router rejecting a mis-routed, + // HTTP/2-coalesced request): it says nothing about the account, so it must not be read as + // "unfunded" (which reports a confident zero balance). + return !isEmptyHttpBody(error.getResponse()); } return ensureError(error).message === `Account not found: ${accountAddress}`; } @@ -122,3 +143,63 @@ export function baseInclusionFee( AppConfig.transaction.baseFeeMultiplier, ); } + +/** + * Maps an error thrown by the Horizon / Soroban RPC SDK clients on a read to the shape the + * fetch policy decides on, keeping the original error as `cause` so callers can still + * classify it (see {@link unwrapFetchPolicyError}). + * + * - A confirmed missing account (Horizon 404 with a body, or the RPC "Account not found" + * error) is a definitive 404: never retried nor sent to a fallback. + * - An empty Horizon 404 is treated as a transport failure (see {@link isAccountNotFoundError}). + * - A Horizon error response carrying a numeric `status` keeps it (5xx / 429 are retried). + * - Anything else (e.g. a bare `Failed to fetch` / `Network Error`) is a network failure. + * + * @param error - The SDK error. + * @param accountAddress - The account the read was about, if any. + * @returns The error in the fetch policy shape. + */ +export function toStellarFetchPolicyError( + error: unknown, + accountAddress?: string, +): FetchPolicyError { + const { message } = ensureError(error); + if ( + accountAddress !== undefined && + isAccountNotFoundError(error, accountAddress) + ) { + return createFetchPolicyError(message, { httpStatus: 404, cause: error }); + } + if (error instanceof NetworkError && !(error instanceof NotFoundError)) { + const status = (error.getResponse() as { status?: unknown } | undefined) + ?.status; + if (typeof status === 'number') { + return createFetchPolicyError(message, { + httpStatus: status, + cause: error, + }); + } + } + return createFetchPolicyError(message, { + name: FetchPolicyErrorName.Network, + cause: error, + }); +} + +/** + * Returns the SDK error a {@link toStellarFetchPolicyError} error wraps, or the error itself. + * + * @param error - The error thrown by `executeWithFetchPolicy` / `withEndpointFallback`. + * @returns The original SDK error when available. + */ +export function unwrapFetchPolicyError(error: unknown): unknown { + if ( + error instanceof Error && + ((error as FetchPolicyError).httpStatus !== undefined || + error.name === FetchPolicyErrorName.Network) && + error.cause !== undefined + ) { + return error.cause; + } + return error; +} diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.test.ts new file mode 100644 index 000000000..290647536 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.test.ts @@ -0,0 +1,232 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; + +import { KnownCaip2ChainId } from '../../api'; +import type { OnChainAccountState } from './api'; +import { OnChainAccountRepository } from './OnChainAccountRepository'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; + +const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'; +const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'; + +const snapshot = (scope: KnownCaip2ChainId): OnChainAccountSerializableFull => + ({ + scope, + accountId: 'GSNAPSHOT', + }) as unknown as OnChainAccountSerializableFull; + +describe('OnChainAccountRepository', () => { + const setup = ( + initial: Partial = {}, + ): { + state: InMemoryState; + repository: OnChainAccountRepository; + } => { + const state = new InMemoryState({ + onChainAccounts: {}, + ...initial, + } as OnChainAccountState); + return { state, repository: new OnChainAccountRepository(state) }; + }; + + describe('markSyncFailing / clearSyncFailing / getSyncFailingSince (v6)', () => { + it('has no failing streak by default', async () => { + const { repository } = setup(); + + expect( + await repository.getSyncFailingSince( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBeUndefined(); + }); + + it('keeps the start of the streak and writes only on transitions', async () => { + const { state, repository } = setup(); + const updateSpy = jest.spyOn(state, 'update'); + + await repository.markSyncFailing( + { [ACCOUNT_A]: 1_000 }, + KnownCaip2ChainId.Mainnet, + ); + await repository.markSyncFailing( + { [ACCOUNT_A]: 5_000 }, + KnownCaip2ChainId.Mainnet, + ); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect( + await repository.getSyncFailingSince( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(1_000); + expect( + await repository.getSyncFailingSince( + ACCOUNT_A, + KnownCaip2ChainId.Testnet, + ), + ).toBeUndefined(); + + await repository.clearSyncFailing( + [ACCOUNT_A, ACCOUNT_B], + KnownCaip2ChainId.Mainnet, + ); + await repository.clearSyncFailing([ACCOUNT_A], KnownCaip2ChainId.Mainnet); + + expect(updateSpy).toHaveBeenCalledTimes(2); + expect( + await repository.getSyncFailingSince( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBeUndefined(); + expect(await state.getKey('syncFailingSince')).toStrictEqual({}); + }); + + it('ignores a malformed persisted value', async () => { + const { repository } = setup({ + syncFailingSince: { + [ACCOUNT_A]: { [KnownCaip2ChainId.Mainnet]: 'x' as never }, + }, + }); + + expect( + await repository.getSyncFailingSince( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBeUndefined(); + }); + }); + + describe('markUnfunded / isConfirmedUnfunded', () => { + it('reports accounts as not confirmed unfunded by default', async () => { + const { repository } = setup(); + + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(false); + }); + + it('marks accounts as unfunded per scope and returns the newly marked ids', async () => { + const { repository } = setup(); + + const marked = await repository.markUnfunded( + [ACCOUNT_A, ACCOUNT_B], + KnownCaip2ChainId.Mainnet, + 1000, + ); + + expect(marked).toStrictEqual([ACCOUNT_A, ACCOUNT_B]); + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(true); + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Testnet, + ), + ).toBe(false); + }); + + it('returns only ids that were not already marked', async () => { + const { repository } = setup(); + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Mainnet); + + const marked = await repository.markUnfunded( + [ACCOUNT_A, ACCOUNT_B], + KnownCaip2ChainId.Mainnet, + ); + + expect(marked).toStrictEqual([ACCOUNT_B]); + }); + + it('does nothing for an empty list', async () => { + const { repository, state } = setup(); + const updateSpy = jest.spyOn(state, 'update'); + + expect( + await repository.markUnfunded([], KnownCaip2ChainId.Mainnet), + ).toStrictEqual([]); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('keeps an existing snapshot when an account is marked unfunded', async () => { + const { repository } = setup(); + await repository.save(ACCOUNT_A, snapshot(KnownCaip2ChainId.Mainnet)); + + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Mainnet); + + expect( + await repository.findByKeyringAccountId( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toStrictEqual(snapshot(KnownCaip2ChainId.Mainnet)); + }); + }); + + describe('clearing the unfunded marker', () => { + it('clears the marker for the saved scope on save', async () => { + const { repository } = setup(); + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Mainnet); + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Testnet); + + await repository.save(ACCOUNT_A, snapshot(KnownCaip2ChainId.Mainnet)); + + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(false); + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Testnet, + ), + ).toBe(true); + }); + + it('clears the marker for each saved account on saveMany', async () => { + const { repository } = setup(); + await repository.markUnfunded( + [ACCOUNT_A, ACCOUNT_B], + KnownCaip2ChainId.Mainnet, + ); + + await repository.saveMany({ + [ACCOUNT_A]: snapshot(KnownCaip2ChainId.Mainnet), + }); + + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(false); + expect( + await repository.isConfirmedUnfunded( + ACCOUNT_B, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(true); + }); + + it('re-marking after a save reports the account as newly unfunded', async () => { + const { repository } = setup(); + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Mainnet); + await repository.save(ACCOUNT_A, snapshot(KnownCaip2ChainId.Mainnet)); + + expect( + await repository.markUnfunded([ACCOUNT_A], KnownCaip2ChainId.Mainnet), + ).toStrictEqual([ACCOUNT_A]); + }); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts index 3ff8108ae..9a73a71a0 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -5,6 +5,8 @@ import type { KnownCaip2ChainId } from '../../api'; import type { OnChainAccountSnapshotsByKeyringId, OnChainAccountState, + SyncFailingSinceByKeyringId, + UnfundedOnChainAccountsByKeyringId, } from './api'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; @@ -13,6 +15,10 @@ export class OnChainAccountRepository { readonly #stateKey = 'onChainAccounts'; + readonly #unfundedStateKey = 'unfundedOnChainAccounts'; + + readonly #syncFailingStateKey = 'syncFailingSince'; + constructor(state: IStateManager) { this.#state = state; } @@ -83,6 +89,9 @@ export class OnChainAccountRepository { const root = newState[this.#stateKey]; root[keyringAccountId] ??= {}; root[keyringAccountId][account.scope] = account; + delete newState[this.#unfundedStateKey]?.[keyringAccountId]?.[ + account.scope + ]; return newState; }); } @@ -109,8 +118,154 @@ export class OnChainAccountRepository { for (const [keyringAccountId, account] of Object.entries(accounts)) { accountsByKeyringId[keyringAccountId] ??= {}; accountsByKeyringId[keyringAccountId][account.scope] = account; + delete newState[this.#unfundedStateKey]?.[keyringAccountId]?.[ + account.scope + ]; } return newState; }); } + + /** + * Records that the network confirmed these accounts do not exist (unfunded) on `scope`. + * Existing snapshots are kept: a false "not found" must never erase a known balance. + * + * @param keyringAccountIds - MetaMask keyring account ids. + * @param scope - CAIP-2 chain id. + * @param now - Confirmation time (ms since epoch). + * @returns The ids that were not already marked as unfunded. + */ + async markUnfunded( + keyringAccountIds: string[], + scope: KnownCaip2ChainId, + now: number = Date.now(), + ): Promise { + if (keyringAccountIds.length === 0) { + return []; + } + + const newlyMarked: string[] = []; + await this.#state.update((state) => { + newlyMarked.length = 0; + const newState = cloneDeep(state); + const unfunded: UnfundedOnChainAccountsByKeyringId = + newState[this.#unfundedStateKey] ?? {}; + newState[this.#unfundedStateKey] = unfunded; + for (const keyringAccountId of keyringAccountIds) { + unfunded[keyringAccountId] ??= {}; + if (unfunded[keyringAccountId][scope] === undefined) { + newlyMarked.push(keyringAccountId); + } + unfunded[keyringAccountId][scope] = now; + } + return newState; + }); + return [...newlyMarked]; + } + + /** + * @param keyringAccountId - MetaMask keyring account id. + * @param scope - CAIP-2 chain id. + * @returns Whether the network last confirmed the account as unfunded on `scope`. + */ + async isConfirmedUnfunded( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const unfunded = + (await this.#state.getKey( + this.#unfundedStateKey, + )) ?? {}; + return unfunded[keyringAccountId]?.[scope] !== undefined; + } + + /** + * Starts a failed-sync streak for the given accounts on `scope`. Accounts already in a + * streak keep their original start, so the state is written only on the first failure. + * + * @param staleSinceByKeyringId - Map of keyring account id → start of the out-of-date period. + * @param scope - CAIP-2 chain id. + */ + async markSyncFailing( + staleSinceByKeyringId: Record, + scope: KnownCaip2ChainId, + ): Promise { + const current = await this.#getSyncFailing(); + const toMark = Object.entries(staleSinceByKeyringId).filter( + ([keyringAccountId]) => current[keyringAccountId]?.[scope] === undefined, + ); + if (toMark.length === 0) { + return; + } + + await this.#state.update((state) => { + const newState = cloneDeep(state); + const failing: SyncFailingSinceByKeyringId = + newState[this.#syncFailingStateKey] ?? {}; + newState[this.#syncFailingStateKey] = failing; + for (const [keyringAccountId, staleSince] of toMark) { + failing[keyringAccountId] ??= {}; + failing[keyringAccountId][scope] ??= staleSince; + } + return newState; + }); + } + + /** + * Ends the failed-sync streak of the given accounts on `scope` (a sync succeeded). Writes + * the state only when one of them was in a streak. + * + * @param keyringAccountIds - MetaMask keyring account ids. + * @param scope - CAIP-2 chain id. + */ + async clearSyncFailing( + keyringAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise { + const current = await this.#getSyncFailing(); + const toClear = keyringAccountIds.filter( + (keyringAccountId) => current[keyringAccountId]?.[scope] !== undefined, + ); + if (toClear.length === 0) { + return; + } + + await this.#state.update((state) => { + const newState = cloneDeep(state); + const failing = newState[this.#syncFailingStateKey]; + for (const keyringAccountId of toClear) { + delete failing?.[keyringAccountId]?.[scope]; + if ( + failing?.[keyringAccountId] !== undefined && + Object.keys(failing[keyringAccountId]).length === 0 + ) { + delete failing[keyringAccountId]; + } + } + return newState; + }); + } + + /** + * @param keyringAccountId - MetaMask keyring account id. + * @param scope - CAIP-2 chain id. + * @returns Start of the out-of-date period of a failed-sync streak, if any. + */ + async getSyncFailingSince( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const since = (await this.#getSyncFailing())[keyringAccountId]?.[scope]; + return typeof since === 'number' && Number.isFinite(since) + ? since + : undefined; + } + + async #getSyncFailing(): Promise { + return ( + (await this.#state.getKey( + this.#syncFailingStateKey, + )) ?? {} + ); + } } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index b60ca8708..870e42fa6 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -35,7 +35,10 @@ import { } from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; -import { OnChainAccountService } from './OnChainAccountService'; +import { + BALANCE_STALE_AFTER_MS, + OnChainAccountService, +} from './OnChainAccountService'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; jest.mock('../../utils/logger'); @@ -756,4 +759,231 @@ describe('OnChainAccountService', () => { expect(synchronizeSpy).not.toHaveBeenCalled(); }); }); + + describe('recordSyncOutcome / isBalanceStale (v6)', () => { + const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'; + const scope = KnownCaip2ChainId.Mainnet; + + const setupWithMigration = ( + migrationEnabled: boolean, + ): ReturnType => { + const mocks = mockOnChainAccountService(); + jest + .spyOn(mocks.assetsService, 'isMigrationEnabled') + .mockResolvedValue(migrationEnabled); + return mocks; + }; + + it('is not stale when the account was not polled yet or the syncs succeed', async () => { + const { onChainAccountService } = setupWithMigration(false); + + expect(await onChainAccountService.isBalanceStale(ACCOUNT_A, scope)).toBe( + false, + ); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [ACCOUNT_A], failedIds: [] }, + scope, + 1_000, + ); + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 1_000 + 10 * BALANCE_STALE_AFTER_MS, + ), + ).toBe(false); + }); + + it('is stale once the last success is older than the threshold and the syncs since then failed', async () => { + const { onChainAccountService } = setupWithMigration(false); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [ACCOUNT_A], failedIds: [] }, + scope, + 1_000, + ); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [], failedIds: [ACCOUNT_A] }, + scope, + 31_000, + ); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [], failedIds: [ACCOUNT_A] }, + scope, + 61_000, + ); + + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 1_000 + BALANCE_STALE_AFTER_MS - 1, + ), + ).toBe(false); + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 1_000 + BALANCE_STALE_AFTER_MS, + ), + ).toBe(true); + + // Cleared as soon as a sync succeeds. + await onChainAccountService.recordSyncOutcome( + { succeededIds: [ACCOUNT_A], failedIds: [] }, + scope, + 200_000, + ); + expect( + await onChainAccountService.isBalanceStale(ACCOUNT_A, scope, 200_001), + ).toBe(false); + }); + + it('counts from the first failure when no success was seen by this instance', async () => { + const { onChainAccountService } = setupWithMigration(false); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [], failedIds: [ACCOUNT_A] }, + scope, + 50_000, + ); + + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 50_000 + BALANCE_STALE_AFTER_MS - 1, + ), + ).toBe(false); + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 50_000 + BALANCE_STALE_AFTER_MS, + ), + ).toBe(true); + }); + + it('is never stale while the assets migration is on', async () => { + const { onChainAccountService } = setupWithMigration(true); + await onChainAccountService.recordSyncOutcome( + { succeededIds: [], failedIds: [ACCOUNT_A] }, + scope, + 50_000, + ); + + expect( + await onChainAccountService.isBalanceStale( + ACCOUNT_A, + scope, + 50_000 + 10 * BALANCE_STALE_AFTER_MS, + ), + ).toBe(false); + }); + }); + + describe('markAccountsUnfunded / isConfirmedUnfunded', () => { + const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'; + const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'; + + beforeEach(() => { + jest.mocked(snap.request).mockReset(); + }); + + const setupWithMigration = ( + migrationEnabled: boolean, + ): ReturnType => { + const mocks = mockOnChainAccountService(); + jest + .spyOn(mocks.assetsService, 'isMigrationEnabled') + .mockResolvedValue(migrationEnabled); + return mocks; + }; + + it('marks accounts and emits a confirmed zero native balance once', async () => { + const { onChainAccountService } = setupWithMigration(false); + + const marked = await onChainAccountService.markAccountsUnfunded( + [ACCOUNT_A], + KnownCaip2ChainId.Mainnet, + ); + + expect(marked).toStrictEqual([ACCOUNT_A]); + expect( + await onChainAccountService.isConfirmedUnfunded( + ACCOUNT_A, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(true); + expect(snap.request).toHaveBeenCalledTimes(1); + expect(JSON.stringify(jest.mocked(snap.request).mock.calls[0])).toContain( + 'notify:accountBalancesUpdated', + ); + expect(JSON.stringify(jest.mocked(snap.request).mock.calls[0])).toContain( + ACCOUNT_A, + ); + + // Already marked: no second event. + await onChainAccountService.markAccountsUnfunded( + [ACCOUNT_A], + KnownCaip2ChainId.Mainnet, + ); + expect(snap.request).toHaveBeenCalledTimes(1); + }); + + it('reports never-synchronized accounts as not confirmed unfunded', async () => { + const { onChainAccountService } = setupWithMigration(false); + + expect( + await onChainAccountService.isConfirmedUnfunded( + ACCOUNT_B, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(false); + }); + + it('still records the marker when emitting the balance event fails', async () => { + const { onChainAccountService } = setupWithMigration(false); + jest + .mocked(snap.request) + .mockRejectedValueOnce(new Error('emit failed')) + .mockResolvedValue(null); + + const marked = await onChainAccountService.markAccountsUnfunded( + [ACCOUNT_B], + KnownCaip2ChainId.Mainnet, + ); + + expect(marked).toStrictEqual([ACCOUNT_B]); + expect( + await onChainAccountService.isConfirmedUnfunded( + ACCOUNT_B, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(true); + }); + + it('neither records nor emits while the assets migration is on, and treats a missing snapshot as unfunded', async () => { + const { onChainAccountService, onChainAccountRepository } = + setupWithMigration(true); + const markUnfundedSpy = jest.spyOn( + onChainAccountRepository, + 'markUnfunded', + ); + + const marked = await onChainAccountService.markAccountsUnfunded( + [ACCOUNT_A], + KnownCaip2ChainId.Mainnet, + ); + + expect(marked).toStrictEqual([]); + expect(markUnfundedSpy).not.toHaveBeenCalled(); + expect(snap.request).not.toHaveBeenCalled(); + // AssetsController owns holdings and the snap does not sync: keep the not-activated meaning. + expect( + await onChainAccountService.isConfirmedUnfunded( + ACCOUNT_B, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(true); + }); + }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 05edeed33..c8532d895 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,3 +1,5 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { Logger } from '@metamask/snap-networks-utils'; import { BigNumber } from 'bignumber.js'; @@ -6,6 +8,8 @@ import { KnownCaip2ChainId } from '../../api'; import { entries, getAssetReference, + getSlip44AssetId, + getSnapProvider, parseClassicAssetCodeIssuer, rethrowIfInstanceElseThrow, toSmallestUnit, @@ -30,6 +34,7 @@ import { OnChainAccountException, OnChainAccountSep41BalanceNotFoundException, } from './exceptions'; +import { getDefaultBalanceEntry } from './keyringBalance'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { @@ -44,6 +49,13 @@ import type { import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import { calculateLedgerMetaFromMinReserve } from './utils'; +/** + * How long after its last successful sync the stored balance of an account whose syncs keep + * failing is still reported (~3 missed sync cycles); after that `getAccountBalances` fails so + * the client shows the balance as unavailable instead of a silently frozen value. + */ +export const BALANCE_STALE_AFTER_MS = 90_000; + /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} * via {@link NetworkService}. @@ -61,6 +73,13 @@ export class OnChainAccountService { readonly #logger: Logger; + /** + * Last successful sync per `${keyringAccountId}:${scope}` seen by this snap instance. Kept + * in memory only: after a restart the streak start falls back to the persisted value or to + * the first failure. + */ + readonly #lastSyncSuccessAt = new Map(); + constructor({ networkService, onChainAccountRepository, @@ -470,6 +489,151 @@ export class OnChainAccountService { }); } + /** + * Records keyring accounts the network confirmed as unfunded (not created on-chain) on `scope`. + * + * @param keyringAccountIds - MetaMask keyring account ids. + * @param scope - CAIP-2 network. + * @returns The ids that were not already marked as unfunded. + */ + async markAccountsUnfunded( + keyringAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise { + // With the assets migration on, AssetsController owns fungible holdings: the snap neither + // persists account snapshots nor emits keyring balance events (see `synchronize`). + if (await this.#assetsService.isMigrationEnabled()) { + return []; + } + + const newlyMarked = await this.#onChainAccountRepository.markUnfunded( + keyringAccountIds, + scope, + ); + + // The client may hold these balances as unavailable (getAccountBalances fails until the + // account is confirmed unfunded): tell it the confirmed 0 once, best effort. + if (newlyMarked.length > 0) { + const nativeAssetId = getSlip44AssetId(scope); + try { + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountBalancesUpdated, + { + balances: Object.fromEntries( + newlyMarked.map((id) => [ + id, + { [nativeAssetId]: getDefaultBalanceEntry() }, + ]), + ), + }, + ); + } catch (error: unknown) { + await trackError(error); + } + } + + return newlyMarked; + } + + /** + * Records the outcome of a sync run: a success ends the failed-sync streak of the account, + * a failure starts one (from the last success seen by this snap instance, or now). Never + * throws: a state error must not fail the sync. + * + * @param outcome - Accounts whose sync succeeded or failed. + * @param outcome.succeededIds - Keyring account ids synchronized successfully. + * @param outcome.failedIds - Keyring account ids whose sync failed. + * @param scope - CAIP-2 network. + * @param now - Current time (ms). + */ + async recordSyncOutcome( + { + succeededIds, + failedIds, + }: { succeededIds: string[]; failedIds: string[] }, + scope: KnownCaip2ChainId, + now: number = Date.now(), + ): Promise { + try { + for (const id of succeededIds) { + this.#lastSyncSuccessAt.set(`${id}:${scope}`, now); + } + if (succeededIds.length > 0) { + await this.#onChainAccountRepository.clearSyncFailing( + succeededIds, + scope, + ); + } + if (failedIds.length > 0) { + await this.#onChainAccountRepository.markSyncFailing( + Object.fromEntries( + failedIds.map((id) => [ + id, + this.#lastSyncSuccessAt.get(`${id}:${scope}`) ?? now, + ]), + ), + scope, + ); + } + } catch (error: unknown) { + this.#logger.warn('Failed to record the sync outcome', { error }); + } + } + + /** + * Whether the stored balance of the account is out of date: its last successful sync is + * older than {@link BALANCE_STALE_AFTER_MS} and the syncs since then failed. An account + * that was simply not polled yet (no failure recorded) is not stale. Always `false` while + * the assets migration is on (AssetsController owns the holdings). + * + * @param keyringAccountId - MetaMask keyring account id. + * @param scope - CAIP-2 network. + * @param now - Current time (ms). + * @returns Whether the balance must be reported as unavailable. + */ + async isBalanceStale( + keyringAccountId: string, + scope: KnownCaip2ChainId, + now: number = Date.now(), + ): Promise { + // With the assets migration on, the snapshot comes from AssetsController, not from the + // snap's own (possibly failing) syncs, so the snap's sync health says nothing about it. + if (await this.#assetsService.isMigrationEnabled()) { + return false; + } + const since = await this.#onChainAccountRepository.getSyncFailingSince( + keyringAccountId, + scope, + ); + return since !== undefined && now - since >= BALANCE_STALE_AFTER_MS; + } + + /** + * Whether the network last confirmed the account as unfunded on `scope`, as opposed to an + * account that was never synchronized (e.g. every load failed), whose balance is unknown. + * Always `true` while the assets migration is on (AssetsController owns the holdings). + * + * @param keyringAccountId - MetaMask keyring account id. + * @param scope - CAIP-2 network. + * @returns `true` if the account is confirmed unfunded. + */ + async isConfirmedUnfunded( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + // With the assets migration on, the snapshot comes from AssetsController and the snap does + // not synchronize accounts, so no unfunded marker is ever written. An account without + // AssetsController holdings keeps the previous meaning: not activated, native balance 0. + if (await this.#assetsService.isMigrationEnabled()) { + return true; + } + return this.#onChainAccountRepository.isConfirmedUnfunded( + keyringAccountId, + scope, + ); + } + /** * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index f7cc1ac85..b92f5831e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -23,9 +23,35 @@ export type OnChainAccountSnapshotsByKeyringId = Record< Partial> >; +/** + * `unfundedOnChainAccounts[keyringAccountId][scope]` → when the network last confirmed that + * the account does not exist (unfunded). Lets the keyring tell a confirmed-unfunded account + * (balance 0) from one that was never synchronized (balance unknown). + */ +export type UnfundedOnChainAccountsByKeyringId = Record< + KeyringAccountId, + Partial> +>; + +/** + * `syncFailingSince[keyringAccountId][scope]` → since when the balance of the account is + * considered out of date: the last successful sync before the current streak of failed syncs + * (or the first failure when no success was seen since the snap started). Written only on + * transitions (first failure, next success) and persisted because the snap may be restarted + * between two syncs. + */ +export type SyncFailingSinceByKeyringId = Record< + KeyringAccountId, + Partial> +>; + /** * Snap state slice for cached on-chain account snapshots (persisted under `onChainAccounts`). */ export type OnChainAccountState = { onChainAccounts: OnChainAccountSnapshotsByKeyringId; + /** Optional: absent in states persisted before it was introduced. */ + unfundedOnChainAccounts?: UnfundedOnChainAccountsByKeyringId; + /** Optional: absent in states persisted before it was introduced. */ + syncFailingSince?: SyncFailingSinceByKeyringId; }; diff --git a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts index 3697d622e..e61927755 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts @@ -17,6 +17,7 @@ import { mockOnChainAccountService, } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { BALANCE_STALE_AFTER_MS } from '../on-chain-account/OnChainAccountService'; import { createMockTransactionService } from '../transaction/__mocks__/transaction.fixtures'; import { SynchronizeService } from './SynchronizeService'; @@ -189,6 +190,191 @@ describe('SynchronizeService', () => { ); }); + it('records unfunded accounts as confirmed unfunded', async () => { + const { service, onChainAccountService, resolveOnChainAccountSpy } = + setup(); + const [funded, unfunded] = generateMockStellarKeyringAccounts( + 2, + 'sync-mark-unfunded-entropy', + ) as [StellarKeyringAccount, StellarKeyringAccount]; + const markSpy = jest + .spyOn(onChainAccountService, 'markAccountsUnfunded') + .mockResolvedValue([unfunded.id]); + resolveOnChainAccountSpy.mockImplementation(async (accountAddress) => { + if (accountAddress === unfunded.address) { + throw new AccountNotActivatedException(unfunded.address, scope); + } + return buildOnChainAccount(funded); + }); + + await service.synchronize([funded, unfunded], { scope }); + + expect(markSpy).toHaveBeenCalledTimes(1); + expect(markSpy).toHaveBeenCalledWith([unfunded.id], scope); + }); + + it('does not record accounts whose load failed for another reason as unfunded', async () => { + const { service, onChainAccountService, resolveOnChainAccountSpy } = + setup(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'sync-load-failed-entropy', + ) as [StellarKeyringAccount]; + const markSpy = jest.spyOn(onChainAccountService, 'markAccountsUnfunded'); + resolveOnChainAccountSpy.mockRejectedValue(new Error('Failed to fetch')); + + await service.synchronize([account], { scope }); + + expect(markSpy).not.toHaveBeenCalled(); + }); + + it('continues the sync when recording unfunded accounts fails', async () => { + const { + service, + onChainAccountService, + resolveOnChainAccountSpy, + onChainSynchronizeSpy, + sep41Assets, + } = setup(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'sync-mark-fails-entropy', + ) as [StellarKeyringAccount]; + jest + .spyOn(onChainAccountService, 'markAccountsUnfunded') + .mockRejectedValue(new Error('state write failed')); + resolveOnChainAccountSpy.mockRejectedValue( + new AccountNotActivatedException(account.address, scope), + ); + + await service.synchronize([account], { scope }); + + expect(onChainSynchronizeSpy).toHaveBeenCalledWith([], scope, sep41Assets); + }); + + describe('sync health for stale-balance detection (v6)', () => { + it('records a load failure as a failed sync and a saved snapshot as a success', async () => { + const { service, onChainAccountService, resolveOnChainAccountSpy } = + setup(); + const [ok, failing, unfunded] = generateMockStellarKeyringAccounts( + 3, + 'sync-health-entropy', + ) as [ + StellarKeyringAccount, + StellarKeyringAccount, + StellarKeyringAccount, + ]; + const recordSpy = jest.spyOn(onChainAccountService, 'recordSyncOutcome'); + jest + .spyOn(onChainAccountService, 'markAccountsUnfunded') + .mockResolvedValue([]); + resolveOnChainAccountSpy.mockImplementation(async (accountAddress) => { + if (accountAddress === failing.address) { + throw new Error('Failed to fetch'); + } + if (accountAddress === unfunded.address) { + throw new AccountNotActivatedException(unfunded.address, scope); + } + return buildOnChainAccount(ok); + }); + + await service.synchronize([ok, failing, unfunded], { scope }); + + expect(recordSpy).toHaveBeenCalledWith( + { succeededIds: [unfunded.id, ok.id], failedIds: [failing.id] }, + scope, + ); + }); + + it('records the loaded accounts as failed when saving their snapshots fails', async () => { + const { + service, + onChainAccountService, + resolveOnChainAccountSpy, + onChainSynchronizeSpy, + } = setup(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'sync-health-save-fails-entropy', + ) as [StellarKeyringAccount]; + const recordSpy = jest.spyOn(onChainAccountService, 'recordSyncOutcome'); + resolveOnChainAccountSpy.mockResolvedValue(buildOnChainAccount(account)); + onChainSynchronizeSpy.mockRejectedValue(new Error('Failed to fetch')); + + await service.synchronize([account], { scope }); + + expect(recordSpy).toHaveBeenCalledWith( + { succeededIds: [], failedIds: [account.id] }, + scope, + ); + }); + + it('does not count a transactions-only run as a balance sync success', async () => { + const { service, onChainAccountService, resolveOnChainAccountSpy } = + setup(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'sync-health-tx-only-entropy', + ) as [StellarKeyringAccount]; + const recordSpy = jest.spyOn(onChainAccountService, 'recordSyncOutcome'); + resolveOnChainAccountSpy.mockResolvedValue(buildOnChainAccount(account)); + + await service.synchronize([account], { scope, syncAccounts: false }); + + expect(recordSpy).toHaveBeenCalledWith( + { succeededIds: [], failedIds: [] }, + scope, + ); + }); + + it('marks the balance stale after ~90 s of failed syncs and clears it on the next success (real service)', async () => { + const { service, onChainAccountService, resolveOnChainAccountSpy } = + setup(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'sync-health-e2e-entropy', + ) as [StellarKeyringAccount]; + let now = 1_000_000; + const setNow = (value: number): void => { + now = value; + }; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + try { + resolveOnChainAccountSpy.mockResolvedValue( + buildOnChainAccount(account), + ); + await service.synchronize([account], { scope }); + const lastSuccessAt = now; + + resolveOnChainAccountSpy.mockRejectedValue( + new Error('Failed to fetch'), + ); + setNow(now + 30_000); + await service.synchronize([account], { scope }); + setNow(now + 30_000); + await service.synchronize([account], { scope }); + expect( + await onChainAccountService.isBalanceStale(account.id, scope), + ).toBe(false); + setNow(lastSuccessAt + BALANCE_STALE_AFTER_MS); + expect( + await onChainAccountService.isBalanceStale(account.id, scope), + ).toBe(true); + + resolveOnChainAccountSpy.mockResolvedValue( + buildOnChainAccount(account), + ); + setNow(now + 30_000); + await service.synchronize([account], { scope }); + expect( + await onChainAccountService.isBalanceStale(account.id, scope), + ).toBe(false); + } finally { + nowSpy.mockRestore(); + } + }); + }); + it('respects syncAccounts and syncTransactions options', async () => { const { service, diff --git a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts index 9ebd0ae6b..7b1b5b330 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts @@ -16,6 +16,14 @@ import type { OnChainAccountService } from '../on-chain-account'; import type { TransactionService } from '../transaction'; import type { ActivatedAccountPair, SynchronizeOptions } from './api'; +/** Per-account outcome of the account load step of a sync run. */ +type AccountLoadOutcome = { + /** Accounts whose load failed (network error): their sync failed. */ + failedIds: string[]; + /** Accounts the network confirmed as unfunded: their (zero) balance is up to date. */ + unfundedIds: string[]; +}; + /** * Orchestrates Stellar wallet synchronization: on-chain account snapshots, transaction * history, and asset metadata. Loads SEP-41 assets once per account sync run and passes @@ -99,8 +107,12 @@ export class SynchronizeService { try { // Both async loads are fail-safe, so we can use Promise.all. + const loadOutcome: AccountLoadOutcome = { + failedIds: [], + unfundedIds: [], + }; const [activatedAccountPairs, sep41Assets] = await Promise.all([ - this.#loadActivatedPairsSafe(accounts, scope), + this.#loadActivatedPairsSafe(accounts, scope, loadOutcome), this.#loadSep41AssetsSafe(scope), ]); @@ -142,6 +154,29 @@ export class SynchronizeService { }); } } + + // Sync health (stale-balance detection): an account load failure is a failed sync; + // a success needs the snapshot to be saved, i.e. the accounts task to succeed. + const accountsTaskSucceeded = + syncAccounts && results[0]?.status === 'fulfilled'; + const pairIds = activatedAccountPairs.map( + ({ keyringAccount }) => keyringAccount.id, + ); + await this.#onChainAccountService.recordSyncOutcome( + { + succeededIds: syncAccounts + ? [ + ...loadOutcome.unfundedIds, + ...(accountsTaskSucceeded ? pairIds : []), + ] + : [], + failedIds: [ + ...loadOutcome.failedIds, + ...(syncAccounts && !accountsTaskSucceeded ? pairIds : []), + ], + }, + scope, + ); } finally { this.#accountsInSync.clear(); this.#logger.debug('Synchronize finished', { @@ -245,16 +280,19 @@ export class SynchronizeService { * * @param accounts - Keyring accounts to load. * @param scope - CAIP-2 network to query. + * @param outcome - Collects the ids of the accounts whose load failed or that are unfunded. * @returns Pairs keyed for SEP-41 sync and persistence. */ async #loadActivatedPairsSafe( accounts: StellarKeyringAccount[], scope: KnownCaip2ChainId, + outcome: AccountLoadOutcome = { failedIds: [], unfundedIds: [] }, ): Promise { this.#logger.debug('number of accounts to synchronize', { noOfAccounts: accounts.length, }); + const unfundedAccountIds: string[] = []; const results = await Promise.all( accounts.map(async (account): Promise => { try { @@ -269,7 +307,10 @@ export class SynchronizeService { } catch (error: unknown) { // Only capture the error if it is unexpected. // AccountNotActivatedException is expected when the account is not activated yet. - if (!(error instanceof AccountNotActivatedException)) { + if (error instanceof AccountNotActivatedException) { + unfundedAccountIds.push(account.id); + } else { + outcome.failedIds.push(account.id); await trackError(error); this.#logger.warn('Failed to load account for sync', { @@ -287,6 +328,22 @@ export class SynchronizeService { (result): result is ActivatedAccountPair => result !== null, ); + outcome.unfundedIds.push(...unfundedAccountIds); + + // Remember which accounts the network confirmed as unfunded, so the keyring reports their + // balance as 0 while a never-synchronized account (every load failed) stays unknown. + if (unfundedAccountIds.length > 0) { + try { + await this.#onChainAccountService.markAccountsUnfunded( + unfundedAccountIds, + scope, + ); + } catch (error: unknown) { + await trackError(error); + this.#logger.warn('Failed to record unfunded accounts', { error }); + } + } + this.#logger.debug('number of activated account pairs', { noOfAccounts: pairs.length, }); diff --git a/packages/stellar-wallet-snap/src/utils/errors.test.ts b/packages/stellar-wallet-snap/src/utils/errors.test.ts index 5cd8e2eee..718d9ba1c 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.test.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.test.ts @@ -1,9 +1,63 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { + AccountBalanceStaleException, + AccountBalanceUnavailableException, +} from '../handlers/keyring/exceptions'; import { InvalidHttpRequestParamsException, + isExpectedStateException, rethrowIfInstanceElseThrow, + shouldTrackError, + StellarSnapException, } from './errors'; describe('errors', () => { + describe('shouldTrackError', () => { + it('does not track the expected "not synchronized yet" balance state', () => { + const error = new AccountBalanceUnavailableException('account-1'); + expect(isExpectedStateException(error)).toBe(true); + expect(shouldTrackError(error)).toBe(false); + }); + + it('does not track the state once re-created, wrapped or serialized (v3)', () => { + const { message } = new AccountBalanceUnavailableException('account-1'); + expect(shouldTrackError(new Error(message))).toBe(false); + expect( + shouldTrackError( + new Error('wrapped', { + cause: new AccountBalanceUnavailableException('account-1'), + }), + ), + ).toBe(false); + expect(shouldTrackError({ name: 'Error', message })).toBe(false); + expect(shouldTrackError(message)).toBe(false); + }); + + it('does not track the stale-balance state, even re-created or wrapped (v6)', () => { + const error = new AccountBalanceStaleException('account-1'); + expect(isExpectedStateException(error)).toBe(true); + expect(shouldTrackError(error)).toBe(false); + expect(shouldTrackError(new Error(error.message))).toBe(false); + expect(shouldTrackError(new Error('wrapped', { cause: error }))).toBe( + false, + ); + expect(shouldTrackError({ name: 'Error', message: error.message })).toBe( + false, + ); + }); + + it('does not track user rejections', () => { + expect(shouldTrackError(new UserRejectedRequestError())).toBe(false); + }); + + it('tracks real failures', () => { + expect(shouldTrackError(new StellarSnapException('boom'))).toBe(true); + expect(shouldTrackError(new Error('boom'))).toBe(true); + expect(isExpectedStateException(new Error('boom'))).toBe(false); + }); + }); + describe('rethrowIfInstanceElseThrow', () => { class SampleDomainError extends Error { constructor(message: string) { diff --git a/packages/stellar-wallet-snap/src/utils/errors.ts b/packages/stellar-wallet-snap/src/utils/errors.ts index 9e878ef07..56ac04b03 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.ts @@ -1,4 +1,5 @@ import { createSnapErrorHandling } from '@metamask/snap-networks-utils'; +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import type { Struct } from '@metamask/superstruct'; import { assert, enums, object, type } from '@metamask/superstruct'; @@ -252,8 +253,101 @@ export function isStellarSnapException( return error instanceof StellarSnapException; } +/** + * Marks an exception as an expected, transient state (e.g. an account that has + * not been synchronized yet) rather than a failure: it is still thrown to the + * caller, but never reported to Sentry through `snap_trackError`. + */ +export type ExpectedStateException = StellarSnapException & { + readonly isExpectedState: true; +}; + +/** + * @param error - Value from a `catch` clause. + * @returns Whether `error` is an expected state that must not be tracked. + */ +export function isExpectedStateException( + error: unknown, +): error is ExpectedStateException { + return ( + error instanceof StellarSnapException && + (error as Partial).isExpectedState === true + ); +} + +/** + * Message of the "balance not synchronized yet" expected state. It is the + * stable marker of that state: the keyring SDK's `handleKeyringRequest` + * re-creates every non-`SnapError` as a plain `Error` carrying only the + * message, so the class and the `isExpectedState` flag are lost before the + * error reaches the tracking wrapper. + */ +export const BALANCE_NOT_SYNCHRONIZED_MESSAGE = + 'Account balance unavailable: the account has not been synchronized yet'; + +/** + * Message of the "stored balance is out of date" expected state: the syncs have been failing + * since the last successful one, longer than the stale threshold. Stable marker for the same + * reason as {@link BALANCE_NOT_SYNCHRONIZED_MESSAGE}. + */ +export const BALANCE_STALE_MESSAGE = + 'Account balance unavailable: synchronization has been failing since the last successful sync'; + +const EXPECTED_STATE_MESSAGES = [ + BALANCE_NOT_SYNCHRONIZED_MESSAGE, + BALANCE_STALE_MESSAGE, +]; + +const hasExpectedStateMessage = (text: string): boolean => + EXPECTED_STATE_MESSAGES.some((marker) => text.includes(marker)); + +/** + * Whether an error is an expected state, even after it was re-created, + * wrapped or serialized along the way: matches the class flag, then the + * stable message marker on the error, its `cause` chain or a serialized error. + * + * @param error - Value from a `catch` clause. + * @returns Whether `error` is (or wraps) an expected state. + */ +export function isExpectedStateError(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + if (isExpectedStateException(current)) { + return true; + } + if (typeof current === 'string') { + return hasExpectedStateMessage(current); + } + if (typeof current !== 'object') { + return false; + } + const { message, cause } = current as { + message?: unknown; + cause?: unknown; + }; + if (typeof message === 'string' && hasExpectedStateMessage(message)) { + return true; + } + current = cause; + } + return false; +} + +/** + * Tracking filter: skips user rejections (the default) and expected states. + * + * @param error - Value from a `catch` clause. + * @returns Whether the error should be reported through `snap_trackError`. + */ +export function shouldTrackError(error: unknown): boolean { + return ( + !(error instanceof UserRejectedRequestError) && !isExpectedStateError(error) + ); +} + export const { trackError, withCatchAndThrowSnapError } = createSnapErrorHandling({ getSnapProvider, logError: logger.error.bind(logger), + shouldTrack: shouldTrackError, }); diff --git a/yarn.lock b/yarn.lock index b745a1aa1..6013c9630 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2279,6 +2279,7 @@ __metadata: ts-jest: "npm:^29.4.14" typescript: "npm:~5.8.3" uuid: "npm:^14.0.0" + webpack: "npm:5.107.2" wif: "npm:^5.0.0" languageName: unknown linkType: soft @@ -4006,6 +4007,7 @@ __metadata: oxfmt: "npm:^0.70.0" qrcode-generator: "npm:^2.0.4" ts-jest: "npm:^29.4.14" + webpack: "npm:5.107.2" languageName: unknown linkType: soft