From bd77bdd99ff0d87c52f743b972ad14ca94a0f9cf Mon Sep 17 00:00:00 2001 From: local Date: Mon, 26 Oct 2026 10:05:23 +0100 Subject: [PATCH 1/6] fix(solana): fail getAccountBalances when an RPC fetch for a requested network fails keyring_getAccountBalances passes failOnNetworks for the networks of the requested assets. If a native or token-account fetch for one of them fails, SnapAssetsAdapter throws AccountAssetsFetchError instead of returning a partial or empty result, so the client flags the balance unavailable instead of keeping a stale one. Other callers keep the skip-and-report behaviour. --- .../handlers/onKeyringRequest/Keyring.test.ts | 25 ++++++ .../core/handlers/onKeyringRequest/Keyring.ts | 13 ++- .../services/assets/AssetsService.test.ts | 17 +++- .../src/core/services/assets/AssetsService.ts | 7 +- .../assets/adapters/SnapAssetsAdapter.test.ts | 85 ++++++++++++++++++- .../assets/adapters/SnapAssetsAdapter.ts | 81 +++++++++++++++++- 6 files changed, 222 insertions(+), 6 deletions(-) diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index 4f82e7138..3b9bd17b7 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -48,6 +48,7 @@ import { import { trackError } from '../../utils/errors'; import { getBip32Entropy } from '../../utils/getBip32Entropy'; import logger from '../../utils/logger'; +import { AccountAssetsFetchError } from '../../services/assets/adapters/SnapAssetsAdapter'; import { SolanaKeyring } from './Keyring'; jest.mock('@metamask/keyring-snap-sdk', () => ({ @@ -396,6 +397,7 @@ describe('SolanaKeyring', () => { expect(mockAssetsService.fetchAccountAssetsFromRpc).toHaveBeenCalledWith( MOCK_SOLANA_KEYRING_ACCOUNT_0, + { failOnNetworks: [MOCK_ASSET_ENTITY_1.assetType.split('/')[0]] }, ); expect(result).toStrictEqual({ [MOCK_ASSET_ENTITY_1.assetType]: { @@ -417,6 +419,29 @@ describe('SolanaKeyring', () => { ).rejects.toThrow('network error'); }); + it('fails instead of answering with a partial result when an RPC fetch for a requested network failed (v3)', async () => { + jest + .spyOn(mockAssetsService, 'fetchAccountAssetsFromRpc') + .mockRejectedValue( + new AccountAssetsFetchError([ + { + network: Network.Mainnet, + reason: new TypeError('Failed to fetch'), + }, + ]), + ); + + await expect( + keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ + KnownCaip19Id.SolMainnet, + ]), + ).rejects.toThrow('Failed to fetch account assets (1 failed)'); + expect(mockAssetsService.fetchAccountAssetsFromRpc).toHaveBeenCalledWith( + expect.anything(), + { failOnNetworks: [KnownCaip19Id.SolMainnet.split('/')[0]] }, + ); + }); + it('removes token assets with zero balance', async () => { jest .spyOn(mockAssetsService, 'fetchAccountAssetsFromRpc') diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index c78730f88..24aed5201 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -71,6 +71,7 @@ import { } from '../../utils/deriveSolanaKeypair'; import { trackError } from '../../utils/errors'; import { getBip32Entropy } from '../../utils/getBip32Entropy'; +import { getNetworkFromToken } from '../../utils/getNetworkFromToken'; import { endTrace, listEntropySources, @@ -457,8 +458,16 @@ export class SolanaKeyring implements KeyringSnapRpc { const account = await this.getAccountOrThrow(accountId); - const assetsList = - await this.#assetsService.fetchAccountAssetsFromRpc(account); + // A failed RPC fetch for a requested network must fail the request: + // answering with the partial (or empty) result would make the client + // keep showing a stale balance with no error. + const failOnNetworks = [ + ...new Set(assets.map((asset) => getNetworkFromToken(asset))), + ]; + const assetsList = await this.#assetsService.fetchAccountAssetsFromRpc( + account, + { failOnNetworks }, + ); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index 8cc5edc7d..4f871549c 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -224,9 +224,24 @@ describe('AssetsService', () => { MOCK_SOLANA_KEYRING_ACCOUNT_0, ); - expect(fetchSpy).toHaveBeenCalledWith(MOCK_SOLANA_KEYRING_ACCOUNT_0); + expect(fetchSpy).toHaveBeenCalledWith(MOCK_SOLANA_KEYRING_ACCOUNT_0, {}); expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); + + it('forwards failOnNetworks to the Snap adapter', async () => { + const fetchSpy = jest + .spyOn(snapAssetsAdapter, 'fetchAccountAssets') + .mockResolvedValue(MOCK_ASSET_ENTITIES); + + await assetsService.fetchAccountAssetsFromRpc( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + { failOnNetworks: [Network.Mainnet] }, + ); + + expect(fetchSpy).toHaveBeenCalledWith(MOCK_SOLANA_KEYRING_ACCOUNT_0, { + failOnNetworks: [Network.Mainnet], + }); + }); }); describe('getAssetsMetadata', () => { diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts index 824d3f2e8..75406f732 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -10,6 +10,7 @@ import type { /* eslint-disable jsdoc/require-returns */ import type { CaipAssetType, CaipChainId } from '@metamask/utils'; +import type { Network } from '../../constants/solana'; import type { AssetEntity } from '../../../entities'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; @@ -92,12 +93,16 @@ export class AssetsService { * asset fetching: it never consults the assets migration feature flag. * * @param account - The account to fetch live assets for. + * @param options - Options. + * @param options.failOnNetworks - Networks whose fetch failures must throw + * instead of being skipped (see the adapter's `fetchAccountAssets`). * @returns The live assets. */ async fetchAccountAssetsFromRpc( account: ExtendedKeyringAccount, + options: { failOnNetworks?: Network[] } = {}, ): Promise { - return this.#snapAdapter.fetchAccountAssets(account); + return this.#snapAdapter.fetchAccountAssets(account, options); } async save(asset: AssetEntity): Promise { diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts index 40e7dad9d..18c382c4b 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -30,7 +30,10 @@ import type { AccountsService } from '../../accounts/AccountsService'; import type { ConfigProvider } from '../../config'; import type { SolanaConnection } from '../../connection'; import type { AssetsRepository } from '../AssetsRepository'; -import { SnapAssetsAdapter } from './SnapAssetsAdapter'; +import { + AccountAssetsFetchError, + SnapAssetsAdapter, +} from './SnapAssetsAdapter'; jest.mock('../../../utils/errors'); @@ -261,4 +264,84 @@ describe('SnapAssetsAdapter', () => { ); }); }); + + describe('fetchAccountAssets with failOnNetworks (v3: SOL must not fail silently)', () => { + beforeEach(() => { + (mockConfigProvider.getActiveNetworks as jest.Mock).mockResolvedValue([ + Network.Mainnet, + ]); + }); + + const mockRpc = ({ + balanceFails = false, + tokensFail = false, + }: { + balanceFails?: boolean; + tokensFail?: boolean; + }): void => { + (mockConnection.getRpc as jest.Mock).mockReturnValue({ + getBalance: jest.fn().mockReturnValue({ + send: balanceFails + ? jest.fn().mockRejectedValue(new TypeError('Failed to fetch')) + : jest + .fn() + .mockResolvedValue(MOCK_SOLANA_RPC_GET_BALANCE_AS_SDK_RESPONSE), + }), + getTokenAccountsByOwner: jest.fn().mockReturnValue({ + send: tokensFail + ? jest.fn().mockRejectedValue(new TypeError('Failed to fetch')) + : jest + .fn() + .mockResolvedValue( + MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_AS_SDK_RESPONSE, + ), + }), + }); + }; + + it('throws AccountAssetsFetchError when the native balance fetch of a required network fails', async () => { + mockRpc({ balanceFails: true }); + + const promise = snapAssetsAdapter.fetchAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + { failOnNetworks: [Network.Mainnet] }, + ); + + await expect(promise).rejects.toBeInstanceOf(AccountAssetsFetchError); + await expect(promise).rejects.toThrow( + `Failed to fetch account assets (1 failed): ${Network.Mainnet}: Failed to fetch`, + ); + }); + + it('throws when token accounts of a required network cannot be fetched', async () => { + mockRpc({ tokensFail: true }); + + await expect( + snapAssetsAdapter.fetchAccountAssets(MOCK_SOLANA_KEYRING_ACCOUNT_0, { + failOnNetworks: [Network.Mainnet], + }), + ).rejects.toThrow('Failed to fetch account assets (2 failed)'); + }); + + it('ignores failures on networks that are not required', async () => { + mockRpc({ balanceFails: true }); + + const assets = await snapAssetsAdapter.fetchAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + { failOnNetworks: [Network.Devnet] }, + ); + + expect(Array.isArray(assets)).toBe(true); + }); + + it('keeps the lenient behaviour without failOnNetworks (sync path)', async () => { + mockRpc({ balanceFails: true, tokensFail: true }); + + const assets = await snapAssetsAdapter.fetchAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + + expect(assets).toStrictEqual([]); + }); + }); }); diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts index 99dc41460..4d52197c5 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts @@ -65,6 +65,38 @@ type TokenAccountWithMetadata = { keyringAccount: ExtendedKeyringAccount; } & Serializable; +/** + * A failed RPC fetch while collecting an account's assets. + */ +export type AccountAssetsFetchFailure = { + network: Network; + reason: unknown; +}; + +/** + * Thrown when an account's assets cannot be fetched completely for the + * networks a caller needs (see `fetchAccountAssets`'s `failOnNetworks`). + */ +export class AccountAssetsFetchError extends Error { + readonly failures: AccountAssetsFetchFailure[]; + + constructor(failures: AccountAssetsFetchFailure[]) { + const reasons = failures + .map(({ network, reason }) => { + const message = + reason instanceof Error ? reason.message : String(reason); + return `${network}: ${message}`; + }) + .join('; '); + super( + `Failed to fetch account assets (${failures.length} failed): ${reasons}`, + { cause: failures[0]?.reason }, + ); + this.name = 'AccountAssetsFetchError'; + this.failures = failures; + } +} + export class SnapAssetsAdapter { readonly #logger: Logger; @@ -280,12 +312,14 @@ export class SnapAssetsAdapter { * @param accounts - The owners of the token accounts. * @param programIds - The program ids to fetch the token accounts for. * @param scopes - The networks to fetch the token accounts for. + * @param failures - Collects the per-network fetch failures, if given. * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. */ async #fetchTokenAccountsMultiple( accounts: ExtendedKeyringAccount[], programIds: Address[] = [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], scopes: Network[] = [Network.Mainnet], + failures?: AccountAssetsFetchFailure[], ): Promise { if (programIds.length === 0 || scopes.length === 0) { return []; @@ -332,6 +366,13 @@ export class SnapAssetsAdapter { combinations.map(({ account }) => account.id), ); + responses.forEach((item, index) => { + const combination = combinations[index]; + if (item.status === 'rejected' && combination) { + failures?.push({ network: combination.scope, reason: item.reason }); + } + }); + return responses.flatMap((item) => item.status === 'fulfilled' ? item.value : [], ); @@ -383,18 +424,45 @@ export class SnapAssetsAdapter { * @param account - The account to get the assets and balances for. * @returns The live assets and balances of the account. */ + /** + * Fetches the native and token assets of an account from the RPC. + * + * Individual fetch failures are reported to Sentry and left out of the + * result. Pass `failOnNetworks` when a partial result must not be used as a + * balance (e.g. `keyring_getAccountBalances`): if any fetch for one of those + * networks failed, an {@link AccountAssetsFetchError} is thrown instead, so + * the client flags the balance unavailable rather than keeping a stale one. + * + * @param account - The account. + * @param options - Options. + * @param options.failOnNetworks - Networks whose fetch failures must throw. + * @returns The account assets. + */ async fetchAccountAssets( account: ExtendedKeyringAccount, + options: { failOnNetworks?: Network[] } = {}, ): Promise { + const failures: AccountAssetsFetchFailure[] = []; const [nativeAssets, tokenAccounts] = await Promise.all([ - this.#fetchNativeAssets(account), + this.#fetchNativeAssets(account, failures), this.#fetchTokenAccountsMultiple( [account], [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], await this.#configProvider.getActiveNetworks(), + failures, ), ]); + const { failOnNetworks } = options; + if (failOnNetworks?.length) { + const blocking = failures.filter(({ network }) => + failOnNetworks.includes(network), + ); + if (blocking.length > 0) { + throw new AccountAssetsFetchError(blocking); + } + } + const assetTypes = tokenAccounts.map( (tokenAccount) => tokenAccount.assetType, ); @@ -458,6 +526,7 @@ export class SnapAssetsAdapter { async #fetchNativeAssets( account: ExtendedKeyringAccount, + failures?: AccountAssetsFetchFailure[], ): Promise { const nativeAssetsTypes = await this.getNativeAssetTypes(); @@ -489,6 +558,16 @@ export class SnapAssetsAdapter { results.map(() => account.id), ); + results.forEach((item, index) => { + const assetType = nativeAssetsTypes[index]; + if (item.status === 'rejected' && assetType) { + failures?.push({ + network: getNetworkFromToken(assetType), + reason: item.reason, + }); + } + }); + return results.flatMap((item) => item.status === 'fulfilled' ? item.value : [], ); From 20a638378bde1155818d1e291e5f8b9f83decef8 Mon Sep 17 00:00:00 2001 From: Raul Velaz <11723452+rvelaz@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:47:54 +0000 Subject: [PATCH 2/6] chore(solana): vitest mocks after main merge; refresh manifest Co-authored-by: Raul Velaz --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../handlers/onKeyringRequest/Keyring.test.ts | 2 +- .../core/services/assets/AssetsService.test.ts | 2 +- .../assets/adapters/SnapAssetsAdapter.test.ts | 16 ++++++++-------- .../core/test/mocks/startMockSolanaRpc.test.ts | 2 +- .../src/core/validation/structs.test.ts | 2 +- packages/solana-wallet-snap/src/index.test.ts | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index f3051dc35..e07f6fb32 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "Dr868Npsp/5wyPjq3W8nLkJF4MDZupuJO8yV8z8aJz4=", + "shasum": "5juA4eLDLJwq36TEKUVSaLNRG5dMuSCMTPLir8kJ1js=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index a27164536..c43f75a62 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -421,7 +421,7 @@ describe('SolanaKeyring', () => { }); it('fails instead of answering with a partial result when an RPC fetch for a requested network failed (v3)', async () => { - jest + vi .spyOn(mockAssetsService, 'fetchAccountAssetsFromRpc') .mockRejectedValue( new AccountAssetsFetchError([ diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index 9bbf78bdb..096fb0e8f 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -226,7 +226,7 @@ describe('AssetsService', () => { }); it('forwards failOnNetworks to the Snap adapter', async () => { - const fetchSpy = jest + const fetchSpy = vi .spyOn(snapAssetsAdapter, 'fetchAccountAssets') .mockResolvedValue(MOCK_ASSET_ENTITIES); diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts index eb523ee55..ad7d51249 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -264,7 +264,7 @@ describe('SnapAssetsAdapter', () => { describe('fetchAccountAssets with failOnNetworks (v3: SOL must not fail silently)', () => { beforeEach(() => { - (mockConfigProvider.getActiveNetworks as jest.Mock).mockResolvedValue([ + (mockConfigProvider.getActiveNetworks as vi.Mock).mockResolvedValue([ Network.Mainnet, ]); }); @@ -276,18 +276,18 @@ describe('SnapAssetsAdapter', () => { balanceFails?: boolean; tokensFail?: boolean; }): void => { - (mockConnection.getRpc as jest.Mock).mockReturnValue({ - getBalance: jest.fn().mockReturnValue({ + (mockConnection.getRpc as vi.Mock).mockReturnValue({ + getBalance: vi.fn().mockReturnValue({ send: balanceFails - ? jest.fn().mockRejectedValue(new TypeError('Failed to fetch')) - : jest + ? vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) + : vi .fn() .mockResolvedValue(MOCK_SOLANA_RPC_GET_BALANCE_AS_SDK_RESPONSE), }), - getTokenAccountsByOwner: jest.fn().mockReturnValue({ + getTokenAccountsByOwner: vi.fn().mockReturnValue({ send: tokensFail - ? jest.fn().mockRejectedValue(new TypeError('Failed to fetch')) - : jest + ? vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) + : vi .fn() .mockResolvedValue( MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_AS_SDK_RESPONSE, diff --git a/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts b/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts index 0248c41c1..b2616d762 100644 --- a/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts +++ b/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable vi/prefer-strict-equal */ import type { MockSolanaRpc } from './startMockSolanaRpc'; import { startMockSolanaRpc } from './startMockSolanaRpc'; diff --git a/packages/solana-wallet-snap/src/core/validation/structs.test.ts b/packages/solana-wallet-snap/src/core/validation/structs.test.ts index 88752fb02..a93f34348 100644 --- a/packages/solana-wallet-snap/src/core/validation/structs.test.ts +++ b/packages/solana-wallet-snap/src/core/validation/structs.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable jest/require-to-throw-message */ +/* eslint-disable vi/require-to-throw-message */ import { assert, is } from '@metamask/superstruct'; import { Base58Struct, Base64Struct } from './structs'; diff --git a/packages/solana-wallet-snap/src/index.test.ts b/packages/solana-wallet-snap/src/index.test.ts index a526a77be..411821be9 100644 --- a/packages/solana-wallet-snap/src/index.test.ts +++ b/packages/solana-wallet-snap/src/index.test.ts @@ -1,4 +1,4 @@ -import { installSnap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-vi'; import { SnapError } from '@metamask/snaps-sdk'; import { From ec45cfb49da55664688da5c3f320e2e786d6482f Mon Sep 17 00:00:00 2001 From: Raul Velaz <11723452+rvelaz@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:58:23 +0000 Subject: [PATCH 3/6] chore(solana): oxfmt sources and add Unreleased changelog for #527 Co-authored-by: Raul Velaz --- packages/solana-wallet-snap/CHANGELOG.md | 4 ++++ .../handlers/onKeyringRequest/Keyring.test.ts | 23 ++++++++++--------- .../src/core/services/assets/AssetsService.ts | 2 +- packages/solana-wallet-snap/src/index.test.ts | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 6ea94cf33..c818baea2 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `keyring_getAccountBalances` now fails when an RPC fetch for a requested network fails, instead of returning a partial or empty balance, so the wallet shows the balance as unavailable rather than stale ([#527](https://github.com/MetaMask/internal-snaps/pull/527)) + ## [7.3.0] ### Changed diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index c43f75a62..a1d3b9ca4 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -20,6 +20,7 @@ import type { KeyringAccountMonitor, TransactionsService, } from '../../services'; +import { AccountAssetsFetchError } from '../../services/assets/adapters/SnapAssetsAdapter'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; import { DEFAULT_UNENCRYPTED_STATE } from '../../services/state/stateTypes'; import type { UnencryptedStateValue } from '../../services/state/stateTypes'; @@ -50,7 +51,6 @@ import { trackError } from '../../utils/errors'; import { getBip32Entropy } from '../../utils/getBip32Entropy'; import * as Bip32Entropy from '../../utils/getBip32Entropy'; import logger from '../../utils/logger'; -import { AccountAssetsFetchError } from '../../services/assets/adapters/SnapAssetsAdapter'; import { SolanaKeyring } from './Keyring'; const NON_EXISTENT_ACCOUNT_ID = '123e4567-e89b-42d3-a456-426614174009'; @@ -421,16 +421,17 @@ describe('SolanaKeyring', () => { }); it('fails instead of answering with a partial result when an RPC fetch for a requested network failed (v3)', async () => { - vi - .spyOn(mockAssetsService, 'fetchAccountAssetsFromRpc') - .mockRejectedValue( - new AccountAssetsFetchError([ - { - network: Network.Mainnet, - reason: new TypeError('Failed to fetch'), - }, - ]), - ); + vi.spyOn( + mockAssetsService, + 'fetchAccountAssetsFromRpc', + ).mockRejectedValue( + new AccountAssetsFetchError([ + { + network: Network.Mainnet, + reason: new TypeError('Failed to fetch'), + }, + ]), + ); await expect( keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts index 75406f732..6c9566899 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -10,8 +10,8 @@ import type { /* eslint-disable jsdoc/require-returns */ import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import type { Network } from '../../constants/solana'; import type { AssetEntity } from '../../../entities'; +import type { Network } from '../../constants/solana'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; diff --git a/packages/solana-wallet-snap/src/index.test.ts b/packages/solana-wallet-snap/src/index.test.ts index 411821be9..65fa1518f 100644 --- a/packages/solana-wallet-snap/src/index.test.ts +++ b/packages/solana-wallet-snap/src/index.test.ts @@ -1,5 +1,5 @@ -import { installSnap } from '@metamask/snaps-vi'; import { SnapError } from '@metamask/snaps-sdk'; +import { installSnap } from '@metamask/snaps-vi'; import { onActive, From a8486f9b955ca1c143ccb8fb08b18913aa5fa197 Mon Sep 17 00:00:00 2001 From: Raul Velaz <11723452+rvelaz@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:19:31 +0000 Subject: [PATCH 4/6] ci: retrigger workflows after CI hygiene Co-authored-by: Raul Velaz From b868dc654df794451d5b721e483cea0cfcf6b8f6 Mon Sep 17 00:00:00 2001 From: Raul Velaz <11723452+rvelaz@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:20:43 +0000 Subject: [PATCH 5/6] fix(solana): restore snaps-jest import and vitest Mock types Co-authored-by: Raul Velaz --- .../core/services/assets/adapters/SnapAssetsAdapter.test.ts | 4 ++-- packages/solana-wallet-snap/src/index.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts index ad7d51249..278874eba 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -264,7 +264,7 @@ describe('SnapAssetsAdapter', () => { describe('fetchAccountAssets with failOnNetworks (v3: SOL must not fail silently)', () => { beforeEach(() => { - (mockConfigProvider.getActiveNetworks as vi.Mock).mockResolvedValue([ + (mockConfigProvider.getActiveNetworks as Mock).mockResolvedValue([ Network.Mainnet, ]); }); @@ -276,7 +276,7 @@ describe('SnapAssetsAdapter', () => { balanceFails?: boolean; tokensFail?: boolean; }): void => { - (mockConnection.getRpc as vi.Mock).mockReturnValue({ + (mockConnection.getRpc as Mock).mockReturnValue({ getBalance: vi.fn().mockReturnValue({ send: balanceFails ? vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) diff --git a/packages/solana-wallet-snap/src/index.test.ts b/packages/solana-wallet-snap/src/index.test.ts index 65fa1518f..a526a77be 100644 --- a/packages/solana-wallet-snap/src/index.test.ts +++ b/packages/solana-wallet-snap/src/index.test.ts @@ -1,5 +1,5 @@ +import { installSnap } from '@metamask/snaps-jest'; import { SnapError } from '@metamask/snaps-sdk'; -import { installSnap } from '@metamask/snaps-vi'; import { onActive, From 316ad17362e37559b44ab38ec178e800e3cab944 Mon Sep 17 00:00:00 2001 From: Raul Velaz <11723452+rvelaz@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:31:48 +0000 Subject: [PATCH 6/6] chore(solana): restore jest eslint rule disables after merge Co-authored-by: Raul Velaz --- .../src/core/test/mocks/startMockSolanaRpc.test.ts | 2 +- packages/solana-wallet-snap/src/core/validation/structs.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts b/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts index b2616d762..0248c41c1 100644 --- a/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts +++ b/packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable vi/prefer-strict-equal */ +/* eslint-disable jest/prefer-strict-equal */ import type { MockSolanaRpc } from './startMockSolanaRpc'; import { startMockSolanaRpc } from './startMockSolanaRpc'; diff --git a/packages/solana-wallet-snap/src/core/validation/structs.test.ts b/packages/solana-wallet-snap/src/core/validation/structs.test.ts index a93f34348..88752fb02 100644 --- a/packages/solana-wallet-snap/src/core/validation/structs.test.ts +++ b/packages/solana-wallet-snap/src/core/validation/structs.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable vi/require-to-throw-message */ +/* eslint-disable jest/require-to-throw-message */ import { assert, is } from '@metamask/superstruct'; import { Base58Struct, Base64Struct } from './structs';