Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/stellar-wallet-snap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions packages/stellar-wallet-snap/snap.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -70,6 +71,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',
}),
],
}),
};

Expand Down
16 changes: 16 additions & 0 deletions packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { HttpException } from '../../utils';
import {
BALANCE_NOT_SYNCHRONIZED_MESSAGE,
BALANCE_STALE_MESSAGE,
StellarSnapException,
} from '../../utils/errors';

Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
Duration,
} from '../../utils';
import { bufferToUint8Array } from '../../utils/buffer';
import { BALANCE_STALE_MESSAGE } from '../../utils/errors';
import { logger } from '../../utils/logger';
import { SyncAccountsHandler } from '../cronjob/syncAccounts';
import {
Expand All @@ -55,6 +56,7 @@ import {
} from './api';
import type { IKeyringRequestHandler } from './base';
import {
AccountBalanceStaleException,
AccountBalanceUnavailableException,
ExportAccountException,
} from './exceptions';
Expand Down Expand Up @@ -856,6 +858,91 @@ 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, 'getOnChainAccountSnapshot')
.mockResolvedValue(
createTestOnChainAccount(mockAccount.address, {
...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES,
nativeBalance: 2.000001,
}),
);
};

it('throws the untracked AccountBalanceStaleException and schedules a sync when the balance is stale', async () => {
const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet);
setUpSyncedAccount();
const staleSpy = jest
.spyOn(OnChainAccountService.prototype, 'isBalanceStale')
.mockResolvedValue(true);
const syncSpy = jest
.spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent')
.mockResolvedValue(undefined);

const promise = keyringHandler.getAccountBalances(mockAccountId, [
slipId,
]);
await expect(promise).rejects.toThrow(AccountBalanceStaleException);
await expect(promise).rejects.toThrow(BALANCE_STALE_MESSAGE);
expect(staleSpy).toHaveBeenCalledWith(
mockAccountId,
KnownCaip2ChainId.Mainnet,
);
expect(syncSpy).toHaveBeenCalledWith(
{ accountIds: [mockAccountId] },
Duration.OneSecond,
);
});

it('also fails for a confirmed-unfunded account whose syncs keep failing', async () => {
const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet);
const { resolveAccountSpy } = getAccountServiceSpies();
resolveAccountSpy.mockResolvedValue({ account: mockAccount });
jest
.spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot')
.mockResolvedValue(null);
jest
.spyOn(OnChainAccountService.prototype, 'isConfirmedUnfunded')
.mockResolvedValue(true);
jest
.spyOn(OnChainAccountService.prototype, 'isBalanceStale')
.mockResolvedValue(true);
jest
.spyOn(SyncAccountsHandler, 'scheduleBackgroundEvent')
.mockResolvedValue(undefined);

await expect(
keyringHandler.getAccountBalances(mockAccountId, [slipId]),
).rejects.toThrow(AccountBalanceStaleException);
});

it('answers from the snapshot when the balance is not stale', async () => {
const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet);
setUpSyncedAccount();
jest
.spyOn(OnChainAccountService.prototype, 'isBalanceStale')
.mockResolvedValue(false);
const syncSpy = jest.spyOn(
SyncAccountsHandler,
'scheduleBackgroundEvent',
);

const result = await keyringHandler.getAccountBalances(mockAccountId, [
slipId,
]);

expect(result[slipId]?.amount).toBe('2.000001');
expect(syncSpy).not.toHaveBeenCalled();
});
});

describe('on-demand sync for never-synchronized accounts', () => {
const setUpUnsyncedAccount = (): void => {
const { resolveAccountSpy } = getAccountServiceSpies();
Expand Down Expand Up @@ -993,11 +1080,17 @@ describe('KeyringHandler', () => {
const resolveOnChainAccountSpy = jest
.spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount')
.mockResolvedValue(onChainAccount);
// A live read is current: the snap's sync health is not consulted.
const staleSpy = jest.spyOn(
OnChainAccountService.prototype,
'isBalanceStale',
);

const result = await keyringHandler.getAccountBalances(mockAccountId, [
slipId,
]);

expect(staleSpy).not.toHaveBeenCalled();
expect(resolveOnChainAccountSpy).toHaveBeenCalledWith(
mockAccount.address,
KnownCaip2ChainId.Mainnet,
Expand Down
30 changes: 29 additions & 1 deletion packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
} from './api';
import type { IKeyringRequestHandler } from './base';
import {
AccountBalanceStaleException,
AccountBalanceUnavailableException,
ExportAccountException,
} from './exceptions';
Expand Down Expand Up @@ -412,13 +413,21 @@ export class KeyringHandler implements KeyringSnapRpc {
await this.#requestSyncForUnsyncedAccount(accountId);
throw new AccountBalanceUnavailableException(accountId);
}
if (!enableAssetMigration) {
await this.#assertBalanceNotStale(accountId, scope);
}
const nativeAssetId = knownAssets.find(isSlip44Id);
if (nativeAssetId !== undefined) {
assetBalances[nativeAssetId] = getDefaultBalanceEntry();
}
return assetBalances;
}

// A live fetch is current by definition; a stored snapshot may be out of date.
if (!enableAssetMigration) {
await this.#assertBalanceNotStale(accountId, scope);
}

// Indexed snapshots and the Tokens API watchlist can both omit a SEP-41 id
// the client asked for. Fetch those balances when nothing was stored.
const missingSep41AssetIds = knownAssets.filter(
Expand Down Expand Up @@ -573,7 +582,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<void> {
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading