From 1ed9293158f32db86f215b241fcd1c454a7fcd6a Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:11:05 +0100 Subject: [PATCH 1/8] fix(stellar): report an unknown balance for never-synced accounts instead of zero - getAccountBalances throws AccountBalanceUnavailableException (an expected state, not sent to snap_trackError) when the account has no snapshot and is not confirmed unfunded, and schedules a deduplicated SynchronizeAccounts (at most once per 30 s per account), so Retry triggers a real sync. - Sync persists a confirmed-unfunded marker (AccountNotActivatedException) and emits a native 0 once. - An empty-body Horizon 404 is no longer read as 'account not found': it is a transport failure (e.g. a provider router rejecting a mis-routed, HTTP/2-coalesced request), not a confirmed unfunded account. --- .../src/handlers/keyring/exceptions.ts | 19 ++- .../src/handlers/keyring/keyring.test.ts | 133 ++++++++++++++- .../src/handlers/keyring/keyring.ts | 64 ++++++- .../stellar-wallet-snap/src/index.test.ts | 56 +++++++ .../src/services/network/utils.test.ts | 62 ++++++- .../src/services/network/utils.ts | 18 +- .../OnChainAccountRepository.test.ts | 158 ++++++++++++++++++ .../OnChainAccountRepository.ts | 62 +++++++ .../OnChainAccountService.test.ts | 72 ++++++++ .../on-chain-account/OnChainAccountService.ts | 69 +++++++- .../src/services/on-chain-account/api.ts | 12 ++ .../services/sync/SynchronizeService.test.ts | 62 +++++++ .../src/services/sync/SynchronizeService.ts | 19 ++- .../src/utils/errors.test.ts | 38 +++++ .../stellar-wallet-snap/src/utils/errors.ts | 78 +++++++++ 15 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.test.ts diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 95829ab5b..6f229b279 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -14,7 +14,10 @@ import { TransactionValidationException, } from '../../services/transaction/exceptions'; import { HttpException } from '../../utils'; -import { StellarSnapException } from '../../utils/errors'; +import { + BALANCE_NOT_SYNCHRONIZED_MESSAGE, + StellarSnapException, +} from '../../utils/errors'; export class KeyringException extends StellarSnapException {} @@ -23,6 +26,20 @@ 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 } }); + } +} + /** * 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 e55473825..1c00be6c7 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -47,8 +47,14 @@ import { SignTransactionResponseStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; -import { ExportAccountException } from './exceptions'; -import { KeyringHandler } from './keyring'; +import { + AccountBalanceUnavailableException, + ExportAccountException, +} from './exceptions'; +import { + KeyringHandler, + UNSYNCED_ACCOUNT_SYNC_MIN_INTERVAL_MS, +} from './keyring'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); @@ -607,7 +613,7 @@ describe('KeyringHandler', () => { }); }); - it('returns zero native balance when the account is not activated on-chain', async () => { + it('throws AccountBalanceUnavailableException when the account was never synchronized', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); @@ -617,6 +623,127 @@ describe('KeyringHandler', () => { 'resolveOnChainAccountByKeyringAccountId', ) .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('on-demand sync for never-synchronized accounts', () => { + const setUpUnsyncedAccount = (): void => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .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, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); + jest + .spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded') + .mockResolvedValue(true); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 12c835268..731710774 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -78,13 +78,26 @@ import { GetAccountBalancesRequestStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; -import { ExportAccountException } from './exceptions'; +import { + 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; @@ -325,8 +338,22 @@ export class KeyringHandler implements KeyringSnapRpc { scope, ); - // If the account is not activated or not yet synced, return the native asset with zero balance if (onChainAccount === null) { + // No snapshot: either the network confirmed the account is unfunded (balance 0), or it + // 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. + const isUnfunded = await this.#onChainAccountService.isConfirmedUnfunded( + accountId, + scope, + ); + if (!isUnfunded) { + // 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); + } const nativeAssetId = knownAssets.find(isSlip44Id); if (nativeAssetId !== undefined) { assetBalances[nativeAssetId] = getDefaultBalanceEntry(); @@ -470,6 +497,39 @@ export class KeyringHandler implements KeyringSnapRpc { } } + /** + * Schedules a background sync for an account whose balance is unknown, 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/utils.test.ts b/packages/stellar-wallet-snap/src/services/network/utils.test.ts index 1891ae859..2342f950e 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,13 @@ +import { 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, +} from './utils'; const maxFeeThresholdInStroops = toSmallestUnit( new BigNumber(AppConfig.transaction.maxFeeThresholdInXLM), @@ -27,3 +32,58 @@ 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, + ); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/network/utils.ts b/packages/stellar-wallet-snap/src/services/network/utils.ts index 9852cdc3e..5c8271d4f 100644 --- a/packages/stellar-wallet-snap/src/services/network/utils.ts +++ b/packages/stellar-wallet-snap/src/services/network/utils.ts @@ -71,6 +71,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 +96,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}`; } 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..f5a501948 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.test.ts @@ -0,0 +1,158 @@ +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('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..d2ae4bee1 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,7 @@ import type { KnownCaip2ChainId } from '../../api'; import type { OnChainAccountSnapshotsByKeyringId, OnChainAccountState, + UnfundedOnChainAccountsByKeyringId, } from './api'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; @@ -13,6 +14,8 @@ export class OnChainAccountRepository { readonly #stateKey = 'onChainAccounts'; + readonly #unfundedStateKey = 'unfundedOnChainAccounts'; + constructor(state: IStateManager) { this.#state = state; } @@ -83,6 +86,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 +115,64 @@ 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; } } 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 449b260fa..57fd326e5 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 @@ -469,4 +469,76 @@ describe('OnChainAccountService', () => { ); }); }); + + 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(); + }); + + it('marks accounts and emits a confirmed zero native balance once', async () => { + const { onChainAccountService } = mockOnChainAccountService(); + + 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 } = mockOnChainAccountService(); + + expect( + await onChainAccountService.isConfirmedUnfunded( + ACCOUNT_B, + KnownCaip2ChainId.Mainnet, + ), + ).toBe(false); + }); + + it('still records the marker when emitting the balance event fails', async () => { + const { onChainAccountService } = mockOnChainAccountService(); + 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); + }); + }); }); 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 6d099078f..065e67d53 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,9 +1,16 @@ +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'; import type { KnownCaip19Sep41AssetId } from '../../api'; import { AssetType, KnownCaip2ChainId } from '../../api'; -import { entries } from '../../utils'; +import { + entries, + getSlip44AssetId, + getSnapProvider, + trackError, +} from '../../utils'; import { assertSameAddress } from '../account/utils'; import type { AssetMetadataService, @@ -13,6 +20,7 @@ import { AccountNotActivatedException } from '../network'; import type { NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; import { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; +import { getDefaultBalanceEntry } from './keyringBalance'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; @@ -230,6 +238,65 @@ export class OnChainAccountService { : null; } + /** + * 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 { + 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; + } + + /** + * 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. + * + * @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 { + 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..bf1b81b2c 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,21 @@ 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> +>; + /** * 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; }; 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 872adcb07..d2d803dcf 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts @@ -182,6 +182,68 @@ 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); + }); + 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 060f9808f..a2e527562 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts @@ -250,6 +250,7 @@ export class SynchronizeService { noOfAccounts: accounts.length, }); + const unfundedAccountIds: string[] = []; const results = await Promise.all( accounts.map(async (account): Promise => { try { @@ -264,7 +265,9 @@ 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 { await trackError(error); this.#logger.warn('Failed to load account for sync', { @@ -282,6 +285,20 @@ export class SynchronizeService { (result): result is ActivatedAccountPair => result !== null, ); + // 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..58213dce4 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.test.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.test.ts @@ -1,9 +1,47 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { 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 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..79ff6afa8 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,85 @@ 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'; + +/** + * 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 current.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE); + } + if (typeof current !== 'object') { + return false; + } + const { message, cause } = current as { message?: unknown; cause?: unknown }; + if ( + typeof message === 'string' && + message.includes(BALANCE_NOT_SYNCHRONIZED_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, }); From 0e57d8fd1297484aa5faf7a6c3a76cab10a4b431 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:14:01 +0100 Subject: [PATCH 2/8] fix(stellar): run account reads under the fetch policy and report stale balances as unavailable - loadOnChainAccount (Horizon) and getAccount (Soroban RPC) run under createFetchPolicy from #462: retries with backoff on transient failures and a circuit breaker. A confirmed missing account is a non-retryable 404; an empty-body 404 or a bare 'Failed to fetch' is a network failure. The original SDK error is rethrown so callers keep classifying it. The snap bundle provides the performance shim the policy needs. - A balance is stale when the last successful sync is older than 90 s and the syncs since then have failed. getAccountBalances then throws AccountBalanceStaleException (expected state, untracked) and requests an on-demand sync through the existing 30 s dedupe. The failure-streak start is persisted per account only on transitions; the error clears on the next successful sync. --- packages/stellar-wallet-snap/package.json | 3 +- packages/stellar-wallet-snap/snap.config.ts | 8 ++ .../src/handlers/keyring/exceptions.ts | 16 +++ .../src/handlers/keyring/keyring.test.ts | 93 +++++++++++++ .../src/handlers/keyring/keyring.ts | 25 +++- .../services/network/NetworkService.test.ts | 47 +++++++ .../src/services/network/NetworkService.ts | 108 ++++++++++++++- .../src/services/network/utils.test.ts | 64 ++++++++- .../src/services/network/utils.ts | 67 +++++++++- .../OnChainAccountRepository.test.ts | 76 ++++++++++- .../OnChainAccountRepository.ts | 93 +++++++++++++ .../OnChainAccountService.test.ts | 98 +++++++++++++- .../on-chain-account/OnChainAccountService.ts | 84 ++++++++++++ .../src/services/on-chain-account/api.ts | 14 ++ .../services/sync/SynchronizeService.test.ts | 124 ++++++++++++++++++ .../src/services/sync/SynchronizeService.ts | 42 +++++- .../src/utils/errors.test.ts | 18 ++- .../stellar-wallet-snap/src/utils/errors.ts | 28 +++- yarn.lock | 1 + 19 files changed, 988 insertions(+), 21 deletions(-) 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 cc7714465..b2fb367bb 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(); @@ -68,6 +69,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/handlers/keyring/exceptions.ts b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 6f229b279..45e1c5837 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -16,6 +16,7 @@ import { import { HttpException } from '../../utils'; import { BALANCE_NOT_SYNCHRONIZED_MESSAGE, + BALANCE_STALE_MESSAGE, StellarSnapException, } from '../../utils/errors'; @@ -40,6 +41,21 @@ export class AccountBalanceUnavailableException extends KeyringException { } } +/** + * 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 1c00be6c7..6cb71cb9c 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -38,6 +38,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 { @@ -48,6 +49,7 @@ import { } from './api'; import type { IKeyringRequestHandler } from './base'; import { + AccountBalanceStaleException, AccountBalanceUnavailableException, ExportAccountException, } from './exceptions'; @@ -636,6 +638,97 @@ describe('KeyringHandler', () => { ); }); + describe('stale balance after failing syncs (v6)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const setUpSyncedAccount = (): void => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .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, + 'resolveOnChainAccountByKeyringAccountId', + ) + .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(); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 731710774..9bffd5268 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -79,6 +79,7 @@ import { } from './api'; import type { IKeyringRequestHandler } from './base'; import { + AccountBalanceStaleException, AccountBalanceUnavailableException, ExportAccountException, } from './exceptions'; @@ -354,6 +355,7 @@ export class KeyringHandler implements KeyringSnapRpc { await this.#requestSyncForUnsyncedAccount(accountId); throw new AccountBalanceUnavailableException(accountId); } + await this.#assertBalanceNotStale(accountId, scope); const nativeAssetId = knownAssets.find(isSlip44Id); if (nativeAssetId !== undefined) { assetBalances[nativeAssetId] = getDefaultBalanceEntry(); @@ -361,6 +363,8 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } + await this.#assertBalanceNotStale(accountId, scope); + for (const assetId of knownAssets) { const asset = onChainAccount.getAsset(assetId); // Skip when the asset is not visible (tombstone, zero SEP-41, or missing entry). @@ -498,7 +502,26 @@ export class KeyringHandler implements KeyringSnapRpc { } /** - * Schedules a background sync for an account whose balance is unknown, at most once per + * 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. * 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..f24be6252 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,53 @@ describe('NetworkService', () => { }); }); + describe('read retries', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + 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); + }); + }); + 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..7de074762 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -1,9 +1,14 @@ import type { + FetchPolicy, ICache, Logger, Serializable, } from '@metamask/snap-networks-utils'; -import { useCache } from '@metamask/snap-networks-utils'; +import { + createFetchPolicy, + executeWithFetchPolicy, + useCache, +} from '@metamask/snap-networks-utils'; import { Address, Horizon as StellarHorizon, @@ -51,8 +56,19 @@ import { baseInclusionFee, isAccountNotFoundError, sep41MulticallCellToBalance, + toStellarFetchPolicyError, + unwrapFetchPolicyError, } from './utils'; +type ReadEndpoint = { + client: Client; + policy: FetchPolicy; +}; + +type ReadEndpoints = { + primary: 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 +86,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 +127,70 @@ export class NetworkService { return client; } + #getHorizonReadEndpoints( + scope: KnownCaip2ChainId, + ): ReadEndpoints { + let endpoints = this.#horizonReadEndpoints.get(scope); + if (!endpoints) { + endpoints = { + primary: { + client: this.#getHorizonClient(scope), + policy: createFetchPolicy({ + service: 'stellar_horizon', + chainIdCaip: scope, + logger: this.#logger, + }), + }, + }; + this.#horizonReadEndpoints.set(scope, endpoints); + } + return endpoints; + } + + #getRpcReadEndpoints(scope: KnownCaip2ChainId): ReadEndpoints { + let endpoints = this.#rpcReadEndpoints.get(scope); + if (!endpoints) { + endpoints = { + primary: { + client: this.#getRpcClient(scope), + policy: createFetchPolicy({ + service: 'stellar_rpc', + 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). Rethrows the original SDK error so callers keep classifying it. + * + * @param endpoints - The endpoints to read from. + * @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 { client, policy } = endpoints.primary; + try { + return await executeWithFetchPolicy( + policy, + async () => read(client), + (error) => toStellarFetchPolicyError(error, accountAddress), + ); + } catch (error: unknown) { + throw unwrapFetchPolicyError(error); + } + } + #getNetworkConfig(scope: KnownCaip2ChainId): NetworkConfig { const config = AppConfig.networks[scope]; if (!config) { @@ -226,11 +316,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 +429,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 2342f950e..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,4 +1,5 @@ -import { NotFoundError } from '@stellar/stellar-sdk'; +import { FetchPolicyErrorName } from '@metamask/snap-networks-utils'; +import { NetworkError, NotFoundError } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { AppConfig } from '../../config'; @@ -7,6 +8,8 @@ import { isAccountNotFoundError, isEmptyHttpBody, multiplyFee, + toStellarFetchPolicyError, + unwrapFetchPolicyError, } from './utils'; const maxFeeThresholdInStroops = toSmallestUnit( @@ -87,3 +90,62 @@ describe('isAccountNotFoundError', () => { ); }); }); + +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 5c8271d4f..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'; @@ -138,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 index f5a501948..290647536 100644 --- 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 @@ -9,7 +9,10 @@ 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; + ({ + scope, + accountId: 'GSNAPSHOT', + }) as unknown as OnChainAccountSerializableFull; describe('OnChainAccountRepository', () => { const setup = ( @@ -25,6 +28,77 @@ describe('OnChainAccountRepository', () => { 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(); 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 d2ae4bee1..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,7 @@ import type { KnownCaip2ChainId } from '../../api'; import type { OnChainAccountSnapshotsByKeyringId, OnChainAccountState, + SyncFailingSinceByKeyringId, UnfundedOnChainAccountsByKeyringId, } from './api'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; @@ -16,6 +17,8 @@ export class OnChainAccountRepository { readonly #unfundedStateKey = 'unfundedOnChainAccounts'; + readonly #syncFailingStateKey = 'syncFailingSince'; + constructor(state: IStateManager) { this.#state = state; } @@ -175,4 +178,94 @@ export class OnChainAccountRepository { )) ?? {}; 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 57fd326e5..f6c5b625e 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 @@ -29,7 +29,10 @@ import { import { OnChainAccountSep41BalanceNotFoundException } 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'); @@ -470,6 +473,99 @@ describe('OnChainAccountService', () => { }); }); + describe('recordSyncOutcome / isBalanceStale (v6)', () => { + const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'; + const scope = KnownCaip2ChainId.Mainnet; + + it('is not stale when the account was not polled yet or the syncs succeed', async () => { + const { onChainAccountService } = mockOnChainAccountService(); + + 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 } = mockOnChainAccountService(); + 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 } = mockOnChainAccountService(); + 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); + }); + }); + describe('markAccountsUnfunded / isConfirmedUnfunded', () => { const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'; const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'; 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 065e67d53..3dea42704 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 @@ -25,6 +25,13 @@ import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; +/** + * 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}. @@ -38,6 +45,15 @@ export class OnChainAccountService { readonly #assetMetadataService: AssetMetadataService; + 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, @@ -58,6 +74,7 @@ export class OnChainAccountService { }); this.#onChainAccountRepository = onChainAccountRepository; this.#assetMetadataService = assetMetadataService; + this.#logger = logger; } /** @@ -287,6 +304,73 @@ export class OnChainAccountService { * @param scope - CAIP-2 network. * @returns `true` if the account is confirmed unfunded. */ + /** + * 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. + * + * @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 { + const since = await this.#onChainAccountRepository.getSyncFailingSince( + keyringAccountId, + scope, + ); + return since !== undefined && now - since >= BALANCE_STALE_AFTER_MS; + } + async isConfirmedUnfunded( keyringAccountId: string, scope: KnownCaip2ChainId, 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 bf1b81b2c..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 @@ -33,6 +33,18 @@ export type UnfundedOnChainAccountsByKeyringId = Record< 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`). */ @@ -40,4 +52,6 @@ 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 d2d803dcf..b5763950d 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts @@ -18,6 +18,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'; @@ -244,6 +245,129 @@ describe('SynchronizeService', () => { 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 a2e527562..384edb431 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts @@ -15,6 +15,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 @@ -98,8 +106,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), ]); @@ -141,6 +153,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', { @@ -240,11 +275,13 @@ 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, @@ -268,6 +305,7 @@ export class SynchronizeService { 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', { @@ -285,6 +323,8 @@ 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) { diff --git a/packages/stellar-wallet-snap/src/utils/errors.test.ts b/packages/stellar-wallet-snap/src/utils/errors.test.ts index 58213dce4..718d9ba1c 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.test.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.test.ts @@ -1,6 +1,9 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { AccountBalanceUnavailableException } from '../handlers/keyring/exceptions'; +import { + AccountBalanceStaleException, + AccountBalanceUnavailableException, +} from '../handlers/keyring/exceptions'; import { InvalidHttpRequestParamsException, isExpectedStateException, @@ -31,6 +34,19 @@ describe('errors', () => { 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); }); diff --git a/packages/stellar-wallet-snap/src/utils/errors.ts b/packages/stellar-wallet-snap/src/utils/errors.ts index 79ff6afa8..56ac04b03 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.ts @@ -285,6 +285,22 @@ export function isExpectedStateException( 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 @@ -300,16 +316,16 @@ export function isExpectedStateError(error: unknown): boolean { return true; } if (typeof current === 'string') { - return current.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE); + return hasExpectedStateMessage(current); } if (typeof current !== 'object') { return false; } - const { message, cause } = current as { message?: unknown; cause?: unknown }; - if ( - typeof message === 'string' && - message.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE) - ) { + const { message, cause } = current as { + message?: unknown; + cause?: unknown; + }; + if (typeof message === 'string' && hasExpectedStateMessage(message)) { return true; } current = cause; diff --git a/yarn.lock b/yarn.lock index b745a1aa1..a3e04ecea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4006,6 +4006,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 From 6b2e4dc1451bd06830c83297cda316ecc1eb4a84 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:18:09 +0100 Subject: [PATCH 3/8] fix(bitcoin): report a never-synced balance as unavailable, not 0 getAccountBalances throws the expected-state BalanceNotSynchronizedError while the wallet still sits on the genesis checkpoint, instead of answering a confident zero, and the cron holds back balance events for such accounts. The error is matched by its stable message marker (the keyring SDK re-creates it as a plain Error) and is never sent to Sentry. --- .../src/entities/account.ts | 9 ++ .../bitcoin-wallet-snap/src/entities/error.ts | 56 +++++++++++ .../src/handlers/CronHandler.test.ts | 32 +++++++ .../src/handlers/CronHandler.ts | 25 ++++- .../src/handlers/HandlerMiddleware.test.ts | 94 ++++++++++++++++++- .../src/handlers/HandlerMiddleware.ts | 22 ++++- .../src/handlers/KeyringHandler.test.ts | 36 ++++++- .../src/handlers/KeyringHandler.ts | 11 +++ .../src/infra/BdkAccountAdapter.ts | 6 ++ .../src/infra/StoredAccountAdapter.ts | 7 ++ .../src/use-cases/AccountUseCases.test.ts | 16 ++++ .../src/use-cases/AccountUseCases.ts | 12 +++ .../src/utils/errors.test.ts | 17 +++- .../bitcoin-wallet-snap/src/utils/errors.ts | 7 +- 14 files changed, 338 insertions(+), 12 deletions(-) diff --git a/packages/bitcoin-wallet-snap/src/entities/account.ts b/packages/bitcoin-wallet-snap/src/entities/account.ts index 067a3be3b..2dbe6b243 100644 --- a/packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/packages/bitcoin-wallet-snap/src/entities/account.ts @@ -126,6 +126,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/error.ts b/packages/bitcoin-wallet-snap/src/entities/error.ts index e8c73d1d4..06e4691a6 100644 --- a/packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/packages/bitcoin-wallet-snap/src/entities/error.ts @@ -85,6 +85,62 @@ 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'; + } +} + +/** + * Whether an error is (or wraps) a {@link BalanceNotSynchronizedError}, 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. + * @returns `true` for the expected "not synchronized yet" state. + */ +export function isBalanceNotSynchronizedError(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + if (current instanceof BalanceNotSynchronizedError) { + return true; + } + if (typeof current === 'string') { + return current.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE); + } + if (typeof current !== 'object') { + return false; + } + const { message, cause } = current as { message?: unknown; cause?: unknown }; + if ( + typeof message === 'string' && + message.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE) + ) { + return true; + } + current = cause; + } + return false; +} + /** * 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..225e0540c 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -44,6 +44,7 @@ describe('CronHandler', () => { // Default the repair rescan to nothing pending so existing tests don't // trigger a repair scan. mockSnapClient.getState.mockResolvedValue({ pending: [] }); + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(false); }); describe('synchronizeAccounts', () => { @@ -446,6 +447,37 @@ describe('CronHandler', () => { }); expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount1); }); + + it('holds back the balance of a synced account whose balance is still unknown', async () => { + mockAccountUseCases.synchronize + .mockResolvedValueOnce({ + account: mockAccount1, + transactionsToNotify: [], + }) + .mockResolvedValueOnce({ + account: mockAccount2, + transactionsToNotify: [], + }); + mockAccountUseCases.isBalanceUnknown.mockImplementation( + async (account) => account === mockAccount1, + ); + + await handler.route(request); + + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount2]); + }); + + it('emits no balance event when every synced balance is unknown', async () => { + mockAccountUseCases.isBalanceUnknown.mockResolvedValue(true); + + await handler.route(request); + + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 8214d50c5..67f86c4d7 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -13,7 +13,12 @@ import { 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 = { @@ -239,10 +244,20 @@ 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. Pushing the balance of an account that never synchronized would + // show a confident zero, so it is held back until a sync 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 { diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 76553a0b7..916237232 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -1,7 +1,12 @@ +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, + BaseError, + ExternalServiceError, +} from '../entities'; import type { Logger, SnapClient, Translator } from '../entities'; import { trackError } from '../utils/errors'; import { HandlerMiddleware } from './HandlerMiddleware'; @@ -131,5 +136,92 @@ 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('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..72c753e31 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, + isBalanceNotSynchronizedError, } from '../entities'; import { trackError } from '../utils/errors'; @@ -45,13 +46,24 @@ export class HandlerMiddleware { try { return await fn(); } catch (error) { - await trackError(error); + // An account that has not synchronized yet 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 = isBalanceNotSynchronizedError(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 +131,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..a3d6b17b5 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -24,7 +24,11 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { BitcoinAccount, Logger, SnapClient } from '../entities'; -import { AccountCapability, CurrencyUnit } from '../entities'; +import { + AccountCapability, + BalanceNotSynchronizedError, + CurrencyUnit, +} from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { Caip19Asset } from './caip'; import { KeyringHandler } from './KeyringHandler'; @@ -679,6 +683,36 @@ 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 when the balance is unknown', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccounts.isBalanceUnknown.mockResolvedValue(true); + + await expect( + handler.getAccountBalances(mockAccount.id), + ).rejects.toBeInstanceOf(BalanceNotSynchronizedError); + }); + + 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..8155b558f 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -31,6 +31,7 @@ import { encode } from 'wif'; import snapManifest from '../../snap.manifest.json'; import type { Logger, SnapClient } from '../entities'; import { + BalanceNotSynchronizedError, computeDisplayBalanceSats, FormatError, networkToCurrencyUnit, @@ -277,6 +278,16 @@ export class KeyringHandler implements KeyringSnapRpc { id: string, ): Promise> { const account = await this.#accountsUseCases.get(id); + + // An account that never synchronized has an unknown balance: reporting + // the zero of an empty wallet would show a confident 0 to the user. Fail + // instead (an expected state, not reported to Sentry) so the client keeps + // it as unavailable and retries; the next successful sync delivers the + // real balance. + if (await this.#accountsUseCases.isBalanceUnknown(account)) { + throw new BalanceNotSynchronizedError({ account: id }); + } + const balance = Amount.from_sat(computeDisplayBalanceSats(account)) .to_btc() .toString(); diff --git a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 2171e1b94..7977f9dc0 100644 --- a/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -183,6 +183,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/StoredAccountAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts index edb5164c6..3f4d12f7b 100644 --- a/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts @@ -123,6 +123,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/use-cases/AccountUseCases.test.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 24911957a..65215326a 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -119,6 +119,22 @@ describe('AccountUseCases', () => { }); }); + describe('isBalanceUnknown', () => { + it('is unknown while the wallet never synchronized', async () => { + const mockAccount = mock(); + mockAccount.hasSynchronized.mockReturnValue(false); + + expect(await useCases.isBalanceUnknown(mockAccount)).toBe(true); + }); + + it('is known once the wallet synchronized', async () => { + const mockAccount = mock(); + mockAccount.hasSynchronized.mockReturnValue(true); + + expect(await useCases.isBalanceUnknown(mockAccount)).toBe(false); + }); + }); + describe('list', () => { it('returns accounts', async () => { const mockAccount = mock(); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index b9771a4ab..57a5ebbc5 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -266,6 +266,18 @@ export class AccountUseCases { this.#metaProtocols = metaProtocols; } + /** + * Whether the account's balance is unknown rather than zero: the wallet + * never synchronized with the chain (it still sits on the genesis + * checkpoint), so its zero balance means "unknown", not "empty". + * + * @param account - The account. + * @returns `true` if the balance must not be reported as known. + */ + async isBalanceUnknown(account: BitcoinAccount): Promise { + return !account.hasSynchronized(); + } + async list(): Promise { this.#logger.debug('Listing accounts'); diff --git a/packages/bitcoin-wallet-snap/src/utils/errors.test.ts b/packages/bitcoin-wallet-snap/src/utils/errors.test.ts index 5cc8b5e4b..7fcfd5c1c 100644 --- a/packages/bitcoin-wallet-snap/src/utils/errors.test.ts +++ b/packages/bitcoin-wallet-snap/src/utils/errors.test.ts @@ -1,6 +1,6 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { UserActionError } from '../entities'; +import { BalanceNotSynchronizedError, UserActionError } from '../entities'; import { shouldTrackError, trackError } from './errors'; import logger from './logger'; @@ -38,6 +38,21 @@ 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 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..a169e383d 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 { isBalanceNotSynchronizedError, 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 (isBalanceNotSynchronizedError(error)) { + return false; + } + return !( (error as UserActionError)?.message === 'User canceled the confirmation' ); From be7e0f291174c1fe45880a35bcd84d16fdf88f19 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:21:08 +0100 Subject: [PATCH 4/8] fix(bitcoin): guard Esplora reads with the shared fetch policy and timeouts Scans, syncs, REST lookups and fee estimates run under createFetchPolicy from @metamask/snap-networks-utils (bounded retries with backoff on transient failures, circuit breaker); a broadcast is never retried. A whole sync is capped at 60 s and a full scan at 120 s, so a request stalled by a broken connection can no longer keep the coalesced sync stuck. performance is provided to the bundle through a ProvidePlugin shim. --- packages/bitcoin-wallet-snap/package.json | 1 + packages/bitcoin-wallet-snap/snap.config.ts | 13 + packages/bitcoin-wallet-snap/src/index.ts | 2 +- .../src/infra/EsploraClientAdapter.test.ts | 245 ++++++++++++++++-- .../src/infra/EsploraClientAdapter.ts | 224 +++++++++++++--- yarn.lock | 1 + 6 files changed, 423 insertions(+), 63 deletions(-) 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..6237b2112 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(); @@ -29,6 +32,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/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/EsploraClientAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts index 3c31994fa..4e03169ba 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts @@ -5,7 +5,10 @@ import { mock } from 'jest-mock-extended'; import type { BitcoinAccount, ChainConfig } from '../entities'; import { EsploraClientAdapter, + FULL_SCAN_TIMEOUT_MS, SENDER_REQUEST_TIMEOUT_MS, + SYNC_TIMEOUT_MS, + withOperationTimeout, } from './EsploraClientAdapter'; jest.mock('@metamask/bitcoindevkit', () => ({ @@ -69,6 +72,162 @@ 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('read retries', () => { + const setupRetryTest = (): { + adapter: EsploraClientAdapter; + client: ReturnType>; + account: BitcoinAccount; + } => { + const client = mock(); + jest.mocked(EsploraClient).mockImplementation(() => client); + 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', + }, + } as unknown as ChainConfig; + const account = mock({ network: 'bitcoin' }); + account.startFullScan.mockReturnValue(mock()); + return { + adapter: new EsploraClientAdapter(config), + client, + account, + }; + }; + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('retries a scan that fails with a network error once', async () => { + const { adapter, client, account } = setupRetryTest(); + const update = { update: true }; + client.full_scan + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValue(update as never); + + const pending = adapter.fullScan(account); + await jest.runAllTimersAsync(); + await pending; + + expect(client.full_scan).toHaveBeenCalledTimes(2); + expect(account.applyUpdate).toHaveBeenCalledWith(update); + }); + + it('fails once the retry also fails with a network error', async () => { + const { adapter, client, account } = setupRetryTest(); + client.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', + }); + + // 1 attempt + 1 retry. + expect(client.full_scan).toHaveBeenCalledTimes(2); + expect(account.applyUpdate).not.toHaveBeenCalled(); + }); + + it('never retries a broadcast', async () => { + const { adapter, client } = setupRetryTest(); + client.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(client.broadcast).toHaveBeenCalledTimes(1); + }); + }); + describe('getTransactionSenders', () => { const mockFetch = jest.fn(); let abortController: AbortController; @@ -124,28 +283,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 +376,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 +388,27 @@ 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(); + } + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 3ae7b6ad4..d5f96a2a6 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -4,6 +4,13 @@ import type { 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, +} from '@metamask/snap-networks-utils'; import { ExternalServiceError } from '../entities'; import type { @@ -51,37 +58,154 @@ function toEsploraRestUrl(url: string): string { */ export const SENDER_REQUEST_TIMEOUT_MS = 10_000; +/** + * Upper bound on a whole sync (all its requests and retries). + * 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'; + 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 #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; + for (const network of NETWORKS) { + endpoints[network] = this.#createEndpoint( + network, + config.url[network], + 'primary', + ); + } + this.#endpoints = endpoints; + } - 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 endpoint under its fetch policy (bounded retries + * with backoff on transient failures, circuit breaker). + * + * @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 endpoint = this.#endpoints[network]; + return executeWithFetchPolicy(endpoint[policy], async () => + operation(endpoint), + ); } async fullScan( @@ -93,11 +217,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) { @@ -111,10 +241,12 @@ export class EsploraClientAdapter implements BlockchainClient { 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 +260,8 @@ export class EsploraClientAdapter implements BlockchainClient { async broadcast(network: Network, transaction: Transaction): Promise { try { - await this.#clients[network].broadcast(transaction); + // Never retried: a retry could broadcast twice. + await this.#endpoints[network].client.broadcast(transaction); } catch (error) { throw new ExternalServiceError( `Failed to broadcast transaction`, @@ -140,7 +273,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 +328,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/yarn.lock b/yarn.lock index b745a1aa1..84c11b7b9 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 From d0acea00e307e5a060d8b7efe4107142d9063c2d Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:22:38 +0100 Subject: [PATCH 5/8] fix(bitcoin): retry the first full scan and make Retry trigger a real scan - FullScanQueue: persistent per-account full-scan queue (reuses the rescanV1 state) with backoff 30 s..5 min; a failing account rotates to the tail, so neither new accounts nor the repair migration are blocked by a stuck one; never-synced accounts are re-queued - the balance stays unknown until the first full scan succeeds; the cron holds back partial balances - one scan per account: single-flight full scans, discovery scans reused for account creation (10 min TTL), at most 2 immediate scans on create - a balance request on an unknown account (the wallet's Retry) requests an on-demand full scan (30 s..2 min backoff) - the first successful sync after a failed scan requests a recovery scan within 5 s --- .../src/handlers/CronHandler.test.ts | 230 ++--- .../src/handlers/CronHandler.ts | 117 ++- .../src/handlers/KeyringHandler.test.ts | 16 +- .../src/handlers/KeyringHandler.ts | 13 +- .../src/store/FullScanQueue.test.ts | 231 +++++ .../src/store/FullScanQueue.ts | 302 ++++++ .../bitcoin-wallet-snap/src/store/index.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 932 +++++++++++++++++- .../src/use-cases/AccountUseCases.ts | 812 ++++++++++++--- 9 files changed, 2340 insertions(+), 314 deletions(-) create mode 100644 packages/bitcoin-wallet-snap/src/store/FullScanQueue.test.ts create mode 100644 packages/bitcoin-wallet-snap/src/store/FullScanQueue.ts diff --git a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 225e0540c..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,10 +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', () => { @@ -272,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 }), @@ -296,159 +297,98 @@ 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(); - }); - - 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: [], - }); + expect(mockSnapClient.emitTrackingError).not.toHaveBeenCalled(); }); - 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(); - }); - - it('does nothing when nothing is pending, short-circuiting before list()', async () => { - mockSnapClient.getState.mockResolvedValue({ pending: [] }); - - 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.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('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('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(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Account repair scan failed', - cause: scanError, - }), + expect.objectContaining({ message: 'Account repair scan failed' }), ); - // 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(); - }); - - 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: [], - }); - - await handler.route(request); - - expect(mockSnapClient.setState).toHaveBeenCalledWith('rescanV1', { - pending: ['account-1', 'account-2'], - }); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount1); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); }); it('holds back the balance of a synced account whose balance is still unknown', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); mockAccountUseCases.synchronize .mockResolvedValueOnce({ account: mockAccount1, @@ -470,6 +410,7 @@ describe('CronHandler', () => { }); it('emits no balance event when every synced balance is unknown', async () => { + mockAccountUseCases.listDueFullScans.mockResolvedValue([]); mockAccountUseCases.isBalanceUnknown.mockResolvedValue(true); await handler.route(request); @@ -784,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); @@ -821,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 67f86c4d7..18cd94b50 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -6,7 +6,7 @@ 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, @@ -42,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; @@ -142,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 }), @@ -154,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) => @@ -245,8 +246,10 @@ export class CronHandler { } // Emit one batched balance event for the accounts whose balance is - // known. Pushing the balance of an account that never synchronized would - // show a confident zero, so it is held back until a sync succeeds. + // 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))) { @@ -277,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/KeyringHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index a3d6b17b5..15cc30bb4 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -694,13 +694,27 @@ describe('KeyringHandler', () => { expect(mockAccounts.isBalanceUnknown).toHaveBeenCalledWith(mockAccount); }); - it('throws the untracked BalanceNotSynchronizedError when the balance is unknown', async () => { + 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('reports the balance once it is known', async () => { diff --git a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 8155b558f..91b5b86b4 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -279,12 +279,15 @@ export class KeyringHandler implements KeyringSnapRpc { ): Promise> { const account = await this.#accountsUseCases.get(id); - // An account that never synchronized has an unknown balance: reporting - // the zero of an empty wallet would show a confident 0 to the user. Fail - // instead (an expected state, not reported to Sentry) so the client keeps - // it as unavailable and retries; the next successful sync delivers the - // real balance. + // 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 }); } 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/index.ts b/packages/bitcoin-wallet-snap/src/store/index.ts index c23e9e145..bb00c0f59 100644 --- a/packages/bitcoin-wallet-snap/src/store/index.ts +++ b/packages/bitcoin-wallet-snap/src/store/index.ts @@ -1,2 +1,3 @@ export * from './BdkAccountRepository'; export * from './JSXSendFlowRepository'; +export * from './FullScanQueue'; 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 65215326a..e01f5c42c 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -43,6 +43,12 @@ 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, } from './AccountUseCases'; @@ -119,22 +125,6 @@ describe('AccountUseCases', () => { }); }); - describe('isBalanceUnknown', () => { - it('is unknown while the wallet never synchronized', async () => { - const mockAccount = mock(); - mockAccount.hasSynchronized.mockReturnValue(false); - - expect(await useCases.isBalanceUnknown(mockAccount)).toBe(true); - }); - - it('is known once the wallet synchronized', async () => { - const mockAccount = mock(); - mockAccount.hasSynchronized.mockReturnValue(true); - - expect(await useCases.isBalanceUnknown(mockAccount)).toBe(false); - }); - }); - describe('list', () => { it('returns accounts', async () => { const mockAccount = mock(); @@ -2727,4 +2717,914 @@ 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; + }; + + 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('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 57a5ebbc5..833a111fa 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -42,6 +42,7 @@ import { WalletError, } from '../entities'; import { CronMethod } from '../handlers/CronHandler'; +import { FullScanQueue } from '../store/FullScanQueue'; import { resolveFeeRate } from '../utils/fee-rate'; export type DiscoverAccountParams = { @@ -225,6 +226,109 @@ 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; + +/** + * 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 +350,44 @@ 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(); + + /** + * 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,18 +406,198 @@ export class AccountUseCases { this.#fallbackFeeRate = fallbackFeeRate; this.#targetBlocksConfirmation = targetBlocksConfirmation; this.#metaProtocols = metaProtocols; + this.#fullScanQueue = new FullScanQueue(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 (it still sits on the genesis - * checkpoint), so its zero balance means "unknown", not "empty". + * 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 { - return !account.hasSynchronized(); + if (!account.hasSynchronized()) { + return true; + } + return this.#fullScanQueue.isInitialScanPending(account.id); + } + + /** + * 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 { @@ -340,6 +662,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', @@ -348,6 +676,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 []; @@ -355,134 +774,213 @@ 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 whole + // address gap; 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'); + } + return accounts; } @@ -492,8 +990,36 @@ 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); + throw error; + } + } + const txsBeforeSync = account.listTransactions(); - await this.#chain.sync(account); + try { + await this.#chain.sync(account); + } catch (error) { + this.#failingSyncs.add(account.id); + throw error; + } + const networkRecovered = this.#failingSyncs.delete(account.id); + this.#syncOnlyAccounts.delete(account.id); const txsAfterSync = account.listTransactions(); // If new transactions appeared, fetch inscriptions; otherwise, just update. @@ -569,6 +1095,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, @@ -580,15 +1116,62 @@ 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 { + await this.#chain.fullScan(account); + } catch (error) { + attempt.consecutiveFailures += 1; + 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.#fullScanQueue + .markSucceeded(account.id) + .catch((stateError: unknown) => + this.#logger.warn('Failed to record full scan success', stateError), + ); const transactionsToNotify = account.listTransactions(); @@ -611,6 +1194,7 @@ export class AccountUseCases { async delete(id: string): Promise { this.#logger.debug('Deleting account: %s', id); + this.#syncOnlyAccounts.delete(id); await this.#runAccountMutation(async () => { const account = await this.#repository.get(id); if (!account) { From 1a8ef4771087af7c3dca56c12e22e671216b1b8b Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:23:46 +0100 Subject: [PATCH 6/8] perf(bitcoin): scan new accounts with a gap of 5 and deepen only if used The first full scan of a new account uses the discovery stop gap (5) instead of the standard gap (20). When that scan finds activity, extendDiscoveryScan checks only the addresses the standard gap adds (one Esplora history request each, 10 s timeout) and runs the gap-20 full scan only if one of them has history or the check fails, so coverage is unchanged. In the A/B runs this cut the first-2-minute bitcoin requests from 257 to 137. --- .../src/entities/account.ts | 29 +++ .../bitcoin-wallet-snap/src/entities/chain.ts | 12 ++ .../src/infra/BdkAccountAdapter.test.ts | 36 ++++ .../src/infra/BdkAccountAdapter.ts | 23 +- .../src/infra/EsploraClientAdapter.test.ts | 201 +++++++++++++++++- .../src/infra/EsploraClientAdapter.ts | 119 +++++++++++ .../src/infra/StoredAccountAdapter.test.ts | 12 ++ .../src/infra/StoredAccountAdapter.ts | 13 ++ .../src/use-cases/AccountUseCases.test.ts | 170 ++++++++++++++- .../src/use-cases/AccountUseCases.ts | 32 ++- 10 files changed, 640 insertions(+), 7 deletions(-) diff --git a/packages/bitcoin-wallet-snap/src/entities/account.ts b/packages/bitcoin-wallet-snap/src/entities/account.ts index 2dbe6b243..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. 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/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 7977f9dc0..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'); } diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts index 4e03169ba..fa1699efd 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts @@ -1,13 +1,15 @@ -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'; @@ -411,4 +413,199 @@ describe('EsploraClientAdapter', () => { } }); }); + + 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 d5f96a2a6..27e5b8e8d 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -1,6 +1,7 @@ import type { FeeEstimates, Network, + ScriptBuf, Transaction, } from '@metamask/bitcoindevkit'; import { EsploraClient } from '@metamask/bitcoindevkit'; @@ -17,6 +18,7 @@ import type { BitcoinAccount, ChainConfig, BlockchainClient, + Keychain, } from '../entities'; /** @@ -58,6 +60,31 @@ 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 and retries). * The WASM client sets no timeout on its requests: a request stalled by a @@ -239,6 +266,98 @@ 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 update = await withOperationTimeout( 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 3f4d12f7b..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(); } 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 e01f5c42c..028832682 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -829,6 +829,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 () => { @@ -838,7 +840,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, ); @@ -853,6 +856,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); @@ -3605,6 +3666,113 @@ describe('AccountUseCases', () => { ]); }); + 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); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 833a111fa..2fb3efc04 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -917,8 +917,10 @@ export class AccountUseCases { }; }); - // Every new account needs one first synchronization covering its whole - // address gap; until it succeeds its balance is reported as unknown: + // 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; @@ -981,6 +983,16 @@ export class AccountUseCases { 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; } @@ -1150,7 +1162,21 @@ export class AccountUseCases { this.#fullScanAttempts.set(account.id, attempt); try { - await this.#chain.fullScan(account); + // 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.#fullScanQueue From 03a7979a69d71e60b036ee89611e02692bb04a59 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:24:57 +0100 Subject: [PATCH 7/8] fix(bitcoin): report a stale balance as unavailable when syncs keep failing SyncHealthStore tracks per account whether its syncs are failing and since when its stored balance was last confirmed; the streak is persisted on transitions so a snap restart does not reset it. After 90 s of failed syncs since the last success (three missed 30 s syncs, matching the client's 90 s re-check), getAccountBalances throws the expected-state BalanceStaleError (not sent to Sentry) and schedules a sync, rate limited to one per 10 s; the next successful sync clears the state and emits the balances-updated event. --- .../bitcoin-wallet-snap/src/entities/error.ts | 82 +++++++++-- .../src/handlers/HandlerMiddleware.test.ts | 20 +++ .../src/handlers/HandlerMiddleware.ts | 9 +- .../src/handlers/KeyringHandler.test.ts | 28 ++++ .../src/handlers/KeyringHandler.ts | 12 ++ .../src/store/SyncHealthStore.test.ts | 99 +++++++++++++ .../src/store/SyncHealthStore.ts | 135 ++++++++++++++++++ .../bitcoin-wallet-snap/src/store/index.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 111 ++++++++++++++ .../src/use-cases/AccountUseCases.ts | 88 ++++++++++++ .../src/utils/errors.test.ts | 16 ++- .../bitcoin-wallet-snap/src/utils/errors.ts | 4 +- 12 files changed, 588 insertions(+), 17 deletions(-) create mode 100644 packages/bitcoin-wallet-snap/src/store/SyncHealthStore.test.ts create mode 100644 packages/bitcoin-wallet-snap/src/store/SyncHealthStore.ts diff --git a/packages/bitcoin-wallet-snap/src/entities/error.ts b/packages/bitcoin-wallet-snap/src/entities/error.ts index 06e4691a6..2c08ce9bd 100644 --- a/packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/packages/bitcoin-wallet-snap/src/entities/error.ts @@ -109,31 +109,62 @@ export class BalanceNotSynchronizedError extends ExternalServiceError { } /** - * Whether an error is (or wraps) a {@link BalanceNotSynchronizedError}, even + * 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. - * @returns `true` for the expected "not synchronized yet" state. + * @param classes - The error classes. + * @param markers - The stable message markers. + * @returns `true` if the error matches. */ -export function isBalanceNotSynchronizedError(error: unknown): boolean { +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) { - if (current instanceof BalanceNotSynchronizedError) { + const candidate = current; + if (classes.some((errorClass) => candidate instanceof errorClass)) { return true; } if (typeof current === 'string') { - return current.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE); + return hasMarker(current); } if (typeof current !== 'object') { return false; } - const { message, cause } = current as { message?: unknown; cause?: unknown }; - if ( - typeof message === 'string' && - message.includes(BALANCE_NOT_SYNCHRONIZED_MESSAGE) - ) { + const { message, cause } = current as { + message?: unknown; + cause?: unknown; + }; + if (typeof message === 'string' && hasMarker(message)) { return true; } current = cause; @@ -141,6 +172,37 @@ export function isBalanceNotSynchronizedError(error: unknown): boolean { 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/HandlerMiddleware.test.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 916237232..ed22b46de 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -4,6 +4,7 @@ import { mock } from 'jest-mock-extended'; import { BalanceNotSynchronizedError, + BalanceStaleError, BaseError, ExternalServiceError, } from '../entities'; @@ -205,6 +206,25 @@ describe('HandlerMiddleware', () => { 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 diff --git a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index 72c753e31..caad9661d 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -25,7 +25,7 @@ import { ValidationError, WalletError, AssertionError, - isBalanceNotSynchronizedError, + isBalanceUnavailableError, } from '../entities'; import { trackError } from '../utils/errors'; @@ -46,11 +46,12 @@ export class HandlerMiddleware { try { return await fn(); } catch (error) { - // An account that has not synchronized yet is an expected state, not a - // failure: it is still returned to the caller, but not reported. Match + // 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 = isBalanceNotSynchronizedError(error); + const isExpectedState = isBalanceUnavailableError(error); if (!isExpectedState) { await trackError(error); } diff --git a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 15cc30bb4..8b594997f 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -26,7 +26,9 @@ import { assert } from 'superstruct'; import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { AccountCapability, + BALANCE_STALE_MESSAGE, BalanceNotSynchronizedError, + BalanceStaleError, CurrencyUnit, } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -717,6 +719,32 @@ describe('KeyringHandler', () => { 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); diff --git a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 91b5b86b4..cebf47f6d 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -32,6 +32,7 @@ import snapManifest from '../../snap.manifest.json'; import type { Logger, SnapClient } from '../entities'; import { BalanceNotSynchronizedError, + BalanceStaleError, computeDisplayBalanceSats, FormatError, networkToCurrencyUnit, @@ -291,6 +292,17 @@ export class KeyringHandler implements KeyringSnapRpc { 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/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 bb00c0f59..ce0bf53b8 100644 --- a/packages/bitcoin-wallet-snap/src/store/index.ts +++ b/packages/bitcoin-wallet-snap/src/store/index.ts @@ -1,3 +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 028832682..0f0d77d17 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -51,7 +51,12 @@ import { 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(); @@ -2820,6 +2825,112 @@ describe('AccountUseCases', () => { 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(); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 2fb3efc04..478ca5464 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -43,6 +43,7 @@ import { } 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 = { @@ -265,6 +266,13 @@ export const MAX_IMMEDIATE_FULL_SCANS_ON_CREATE = 2; */ 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. */ @@ -368,6 +376,12 @@ export class AccountUseCases { /** 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 @@ -407,6 +421,7 @@ export class AccountUseCases { this.#targetBlocksConfirmation = targetBlocksConfirmation; this.#metaProtocols = metaProtocols; this.#fullScanQueue = new FullScanQueue(snapClient); + this.#syncHealth = new SyncHealthStore(snapClient); } /** @@ -434,6 +449,68 @@ export class AccountUseCases { 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. * @@ -1019,6 +1096,7 @@ export class AccountUseCases { // 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; } } @@ -1028,9 +1106,11 @@ export class AccountUseCases { 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(); @@ -1179,6 +1259,7 @@ export class AccountUseCases { } } catch (error) { attempt.consecutiveFailures += 1; + await this.#recordSyncFailure(account.id); await this.#fullScanQueue .markFailed(account.id, error) .catch((stateError: unknown) => @@ -1193,6 +1274,7 @@ export class AccountUseCases { 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) => @@ -1221,6 +1303,12 @@ export class AccountUseCases { 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 7fcfd5c1c..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 { BalanceNotSynchronizedError, UserActionError } from '../entities'; +import { + BalanceNotSynchronizedError, + BalanceStaleError, + UserActionError, +} from '../entities'; import { shouldTrackError, trackError } from './errors'; import logger from './logger'; @@ -53,6 +57,16 @@ describe('errors', () => { 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 a169e383d..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 { isBalanceNotSynchronizedError, UserActionError } from '../entities'; +import { isBalanceUnavailableError, UserActionError } from '../entities'; import { getSnapProvider } from '../infra/getSnapProvider'; import logger from './logger'; @@ -18,7 +18,7 @@ export function shouldTrackError(error: unknown): boolean { } // Expected state, never a failure, however the error was wrapped. - if (isBalanceNotSynchronizedError(error)) { + if (isBalanceUnavailableError(error)) { return false; } From 3b293db7e3277ad9fa6feb3aa1772b4740212630 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:25:41 +0100 Subject: [PATCH 8/8] feat(snaps): optional public-endpoint fallback for bitcoin and stellar reads (draft) - snap-networks-utils: withEndpointFallback / shouldUseFallbackEndpoint repeat a read against a fallback endpoint only after the primary failed at the transport level (bare Failed to fetch, timeout, open circuit, 5xx or 429); a readable 4xx is never sent elsewhere; if both fail the primary error is rethrown - bitcoin: ESPLORA_BITCOIN_FALLBACK (mainnet reads only; broadcasts never fall back) - stellar: STELLAR_HORIZON_FALLBACK_URL_* / STELLAR_RPC_FALLBACK_URL_* - unset by default: sending wallet addresses to a third-party endpoint has privacy implications --- packages/bitcoin-wallet-snap/.env.example | 6 ++ packages/bitcoin-wallet-snap/snap.config.ts | 1 + packages/bitcoin-wallet-snap/src/config.ts | 16 +++ .../src/entities/config.ts | 3 + .../src/infra/EsploraClientAdapter.test.ts | 60 +++++++---- .../src/infra/EsploraClientAdapter.ts | 45 ++++++-- packages/snap-networks-utils/src/index.ts | 5 + .../fetchPolicy/withEndpointFallback.test.ts | 90 ++++++++++++++++ .../utils/fetchPolicy/withEndpointFallback.ts | 100 ++++++++++++++++++ packages/stellar-wallet-snap/.env.example | 10 +- packages/stellar-wallet-snap/snap.config.ts | 11 ++ packages/stellar-wallet-snap/src/config.ts | 16 ++- .../services/network/NetworkService.test.ts | 93 +++++++++++++++- .../src/services/network/NetworkService.ts | 54 +++++++++- 14 files changed, 472 insertions(+), 38 deletions(-) create mode 100644 packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.test.ts create mode 100644 packages/snap-networks-utils/src/utils/fetchPolicy/withEndpointFallback.ts 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/snap.config.ts b/packages/bitcoin-wallet-snap/snap.config.ts index 6237b2112..3e0016d83 100644 --- a/packages/bitcoin-wallet-snap/snap.config.ts +++ b/packages/bitcoin-wallet-snap/snap.config.ts @@ -19,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, 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/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/infra/EsploraClientAdapter.test.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts index fa1699efd..f5e3499d4 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.test.ts @@ -140,14 +140,24 @@ describe('EsploraClientAdapter', () => { }); }); - describe('read retries', () => { - const setupRetryTest = (): { + describe('fallback endpoint', () => { + const setupFallbackTest = ( + fallbackUrl?: string, + ): { adapter: EsploraClientAdapter; - client: ReturnType>; + primary: ReturnType>; + fallback: ReturnType>; account: BitcoinAccount; } => { - const client = mock(); - jest.mocked(EsploraClient).mockImplementation(() => client); + 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, @@ -159,12 +169,14 @@ describe('EsploraClientAdapter', () => { 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), - client, + primary, + fallback, account, }; }; @@ -177,24 +189,27 @@ describe('EsploraClientAdapter', () => { jest.useRealTimers(); }); - it('retries a scan that fails with a network error once', async () => { - const { adapter, client, account } = setupRetryTest(); + 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 }; - client.full_scan - .mockRejectedValueOnce(new TypeError('Failed to fetch')) - .mockResolvedValue(update as never); + fallback.full_scan.mockResolvedValue(update as never); const pending = adapter.fullScan(account); await jest.runAllTimersAsync(); await pending; - expect(client.full_scan).toHaveBeenCalledTimes(2); + // 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 once the retry also fails with a network error', async () => { - const { adapter, client, account } = setupRetryTest(); - client.full_scan.mockRejectedValue(new TypeError('Failed to fetch')); + 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) @@ -204,14 +219,14 @@ describe('EsploraClientAdapter', () => { message: 'Failed to perform initial full scan', }); - // 1 attempt + 1 retry. - expect(client.full_scan).toHaveBeenCalledTimes(2); - expect(account.applyUpdate).not.toHaveBeenCalled(); + expect(fallback.full_scan).not.toHaveBeenCalled(); }); - it('never retries a broadcast', async () => { - const { adapter, client } = setupRetryTest(); - client.broadcast.mockRejectedValue(new TypeError('Failed to fetch')); + 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({ @@ -226,7 +241,8 @@ describe('EsploraClientAdapter', () => { message: 'Failed to broadcast transaction', }); - expect(client.broadcast).toHaveBeenCalledTimes(1); + expect(primary.broadcast).toHaveBeenCalledTimes(1); + expect(fallback.broadcast).not.toHaveBeenCalled(); }); }); diff --git a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 27e5b8e8d..debf11d91 100644 --- a/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -11,6 +11,7 @@ import { createFetchPolicy, createFetchPolicyError, executeWithFetchPolicy, + withEndpointFallback, } from '@metamask/snap-networks-utils'; import { ExternalServiceError } from '../entities'; @@ -86,7 +87,7 @@ export async function toScripthash(script: Uint8Array): Promise { } /** - * Upper bound on a whole sync (all its requests and retries). + * 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. @@ -134,7 +135,7 @@ export async function withOperationTimeout( * policies guarding them. */ type EsploraEndpoint = { - label: 'primary'; + label: 'primary' | 'fallback'; client: EsploraClient; restUrl: string; /** Guards `full_scan` / `sync` (the WASM client already retries each request). */ @@ -167,6 +168,8 @@ 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 #endpoints: Record; + readonly #fallbackEndpoints: Partial>; + readonly #config: ChainConfig; readonly #logger: Pick; @@ -181,14 +184,24 @@ export class EsploraClientAdapter implements BlockchainClient { 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; } #createEndpoint( @@ -216,8 +229,8 @@ export class EsploraClientAdapter implements BlockchainClient { } /** - * Runs a read against the endpoint under its fetch policy (bounded retries - * with backoff on transient failures, circuit breaker). + * 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. @@ -229,9 +242,24 @@ export class EsploraClientAdapter implements BlockchainClient { policy: 'scanPolicy' | 'restPolicy', operation: (endpoint: EsploraEndpoint) => Promise, ): Promise { - const endpoint = this.#endpoints[network]; - return executeWithFetchPolicy(endpoint[policy], async () => - operation(endpoint), + 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, + ), + }, ); } @@ -379,7 +407,8 @@ export class EsploraClientAdapter implements BlockchainClient { async broadcast(network: Network, transaction: Transaction): Promise { try { - // Never retried: a retry could broadcast twice. + // 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( 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 ace1de1eb..5d9091a6d 100644 --- a/packages/stellar-wallet-snap/.env.example +++ b/packages/stellar-wallet-snap/.env.example @@ -69,4 +69,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/snap.config.ts b/packages/stellar-wallet-snap/snap.config.ts index b2fb367bb..c9135517c 100644 --- a/packages/stellar-wallet-snap/snap.config.ts +++ b/packages/stellar-wallet-snap/snap.config.ts @@ -26,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 ?? '', diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 16b57489b..3fe15b3e2 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), }); /** @@ -138,6 +146,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, @@ -145,6 +156,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/services/network/NetworkService.test.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index f24be6252..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,9 +376,31 @@ describe('NetworkService', () => { }); }); - describe('read retries', () => { + 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, @@ -421,6 +443,75 @@ describe('NetworkService', () => { ).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', () => { diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 7de074762..379cb276d 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -8,6 +8,7 @@ import { createFetchPolicy, executeWithFetchPolicy, useCache, + withEndpointFallback, } from '@metamask/snap-networks-utils'; import { Address, @@ -67,6 +68,7 @@ type ReadEndpoint = { type ReadEndpoints = { primary: ReadEndpoint; + fallback?: ReadEndpoint; }; /** @@ -132,6 +134,7 @@ export class NetworkService { ): ReadEndpoints { let endpoints = this.#horizonReadEndpoints.get(scope); if (!endpoints) { + const { horizonUrl, horizonFallbackUrl } = this.#getNetworkConfig(scope); endpoints = { primary: { client: this.#getHorizonClient(scope), @@ -141,6 +144,18 @@ export class NetworkService { 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); } @@ -150,6 +165,7 @@ export class NetworkService { #getRpcReadEndpoints(scope: KnownCaip2ChainId): ReadEndpoints { let endpoints = this.#rpcReadEndpoints.get(scope); if (!endpoints) { + const { rpcUrl, rpcFallbackUrl } = this.#getNetworkConfig(scope); endpoints = { primary: { client: this.#getRpcClient(scope), @@ -159,6 +175,18 @@ export class NetworkService { 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); } @@ -167,9 +195,10 @@ export class NetworkService { /** * Runs an idempotent read under the endpoint's fetch policy (retry with backoff on transient - * failures, circuit breaker). Rethrows the original SDK error so callers keep classifying it. + * 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 endpoints to read from. + * @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. @@ -179,13 +208,28 @@ export class NetworkService { read: (client: Client) => Promise, accountAddress?: string, ): Promise { - const { client, policy } = endpoints.primary; - try { - return await executeWithFetchPolicy( + 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); }