From 0e57d8fd1297484aa5faf7a6c3a76cab10a4b431 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:14:01 +0100 Subject: [PATCH] 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