From b0d7fcbec4aa5cd1e00b921a08e5ac2f52bb5081 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:44:32 +0000 Subject: [PATCH 1/4] chore(WPN-1652): align Solana AssetsService read API with snap-networks-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, and getAccountAssetsForAllActiveScopes. Update Keyring, Send, send render, and refreshSend to use the new API. No behavior change — still reads from Snap-owned assetEntities via AssetsRepository. Migrated from MetaMask/snap-solana-wallet#635. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 4 + .../handlers/onKeyringRequest/Keyring.test.ts | 56 +++++++---- .../core/handlers/onKeyringRequest/Keyring.ts | 16 +++- .../services/assets/AssetsService.test.ts | 84 ++++++++++++++++ .../src/core/services/assets/AssetsService.ts | 96 ++++++++++++++++++- .../core/services/send/SendService.test.ts | 79 ++++++++++----- .../src/core/services/send/SendService.ts | 12 +-- .../solana-wallet-snap/src/snapContext.ts | 8 +- 8 files changed, 295 insertions(+), 60 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index bffccba6..5d28fd6e 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] +### Changed + +- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssetsForAllActiveScopes`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) + ### Removed - **BREAKING:** Removed the legacy snap-hosted send dialog (`startSendTransactionFlow` RPC and `features/send` UI). Send is now handled exclusively through the unified send flow client methods (`confirmSend`, `onAmountInput`, `onAddressInput`). ([#130](https://github.com/MetaMask/internal-snaps/pull/130)) 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 5c27fe3e..3fc76e52 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 @@ -103,7 +103,8 @@ describe('SolanaKeyring', () => { mockAssetsService = { fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), saveMany: jest.fn(), - findByAccount: jest.fn(), + getAccountAssetsForAllActiveScopes: jest.fn(), + getAccountAssetsByIDs: jest.fn(), getNativeAssetTypes: jest .fn() .mockReturnValue([KnownCaip19Id.SolMainnet]), @@ -143,7 +144,7 @@ describe('SolanaKeyring', () => { describe('getAccountAssets', () => { it('calls the assets service', async () => { jest - .spyOn(mockAssetsService, 'findByAccount') + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') .mockResolvedValue(MOCK_ASSET_ENTITIES); const result = await keyring.getAccountAssets( @@ -158,10 +159,12 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance - ]); + jest + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValue([ + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance + ]); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -171,10 +174,12 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance - { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance - ]); + jest + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValue([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance + { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance + ]); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -343,9 +348,9 @@ describe('SolanaKeyring', () => { symbol: 4, } as unknown as AssetEntity; - jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue([invalidAsset]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.SolMainnet]: invalidAsset, + }); await expect( keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ @@ -355,10 +360,13 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + [MOCK_ASSET_ENTITY_2.assetType]: { + ...MOCK_ASSET_ENTITY_2, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -374,10 +382,16 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance - { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '0', + }, + [MOCK_ASSET_ENTITY_1.assetType]: { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, 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 9dc6a985..785496a6 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -413,9 +413,10 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId }, ListAccountAssetsStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); - const assetEntities = await this.#assetsService.findByAccount(account); + const assetEntities = + await this.#assetsService.getAccountAssetsForAllActiveScopes(accountId); const result = assetEntities // Remove token assets with zero balance @@ -448,10 +449,15 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId, assets }, GetAccountBalancesStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); + + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + assets, + ); - const assetsToUse = (await this.#assetsService.findByAccount(account)) - .filter((asset) => assets.includes(asset.assetType)) + const assetsToUse = Object.values(assetsById) + .filter((asset): asset is NonNullable => asset !== null) // Remove token assets with zero balance .filter( (asset) => 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 d7e7f672..fd888023 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 @@ -17,6 +17,7 @@ import { SOLANA_MOCK_TOKEN_METADATA, } from '../../test/mocks/asset-entities'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import { mockLogger } from '../mocks/logger'; @@ -35,6 +36,7 @@ describe('AssetsService', () => { let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; + let mockAccountsService: AccountsService; let mockTokenApiClient: TokenApiClient; let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; @@ -81,11 +83,16 @@ describe('AssetsService', () => { saveMany: jest.fn(), } as unknown as AssetsRepository; + mockAccountsService = { + findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), + } as unknown as AccountsService; + assetsService = new AssetsService({ connection: mockConnection, logger: mockLogger, configProvider: mockConfigProvider, assetsRepository: mockAssetsRepository, + accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, tokenPricesService: mockTokenPricesService, cache: mockCache, @@ -604,4 +611,81 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('getAccountAssetByID', () => { + it('returns the matching asset when present', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + + it('returns null when the asset is missing', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('returns a record keyed by asset ID', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + }); + }); + + it('returns null entries for missing assets', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('filters account assets to the requested scope', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + }); }); 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 a2778e7b..7d5bd7f0 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -11,7 +11,7 @@ import type { FungibleAssetMarketData, FungibleAssetMetadata, } from '@metamask/snaps-sdk'; -import type { CaipAssetType } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import { Duration, parseCaipAssetType } from '@metamask/utils'; import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; @@ -46,6 +46,7 @@ import { getNetworkFromToken } from '../../utils/getNetworkFromToken'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; @@ -71,6 +72,8 @@ export class AssetsService { readonly #assetsRepository: AssetsRepository; + readonly #accountsService: AccountsService; + readonly #tokenPricesService: TokenPricesService; readonly #tokenApiClient: TokenApiClient; @@ -88,6 +91,7 @@ export class AssetsService { logger, configProvider, assetsRepository, + accountsService, tokenApiClient, tokenPricesService, cache, @@ -97,6 +101,7 @@ export class AssetsService { logger: ILogger; configProvider: ConfigProvider; assetsRepository: AssetsRepository; + accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; cache: ICache; @@ -106,6 +111,7 @@ export class AssetsService { this.#connection = connection; this.#configProvider = configProvider; this.#assetsRepository = assetsRepository; + this.#accountsService = accountsService; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; this.#cache = cache; @@ -640,6 +646,94 @@ export class AssetsService { return this.#assetsRepository.getAll(); } + /** + * Returns a single account asset by CAIP-19 ID, or `null` if missing. + * + * @param accountId - Keyring account ID. + * @param assetId - CAIP-19 asset ID. + */ + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + + const assets = await this.getAccountAssetsByScope(chainId, accountId); + + return assets.find((asset) => asset.assetType === assetId) ?? null; + } + + /** + * Returns account assets for the given CAIP-19 IDs, keyed by asset ID. + * Missing assets are `null`. + * + * @param accountId - Keyring account ID. + * @param assetIds - CAIP-19 asset IDs to resolve. + */ + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise> { + if (assetIds.length === 0) { + return {}; + } + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); + } + + const accountAssets = await this.findByAccount(account); + + return Object.fromEntries( + assetIds.map((assetId) => [ + assetId, + accountAssets.find((asset) => asset.assetType === assetId) ?? null, + ]), + ); + } + + /** + * Returns controller-backed assets for an account on the given Solana scope. + * + * @param scope - CAIP-2 chain ID to filter results. + * @param accountId - Keyring account ID. + */ + async getAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + ): Promise { + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + const accountAssets = await this.findByAccount(account); + + return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); + } + + /** + * Returns assets for an account across all active Solana networks. + * + * @param accountId - Keyring account ID. + */ + async getAccountAssetsForAllActiveScopes( + accountId: string, + ): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + + const assetsByScope = await Promise.all( + activeNetworks.map((network) => + this.getAccountAssetsByScope(network, accountId), + ), + ); + + return assetsByScope.flat(); + } + async findByAccount(account: SolanaKeyringAccount): Promise { const { id: keyringAccountId, address } = account; diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index bf47c833..7ef7660c 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts @@ -108,7 +108,7 @@ describe('SendService', () => { } as unknown as SendSplTokenBuilder; mockAssetsService = { - findByAccount: jest.fn(), + getAccountAssetsByIDs: jest.fn(), } as unknown as AssetsService; (fromTransactionToBase64String as jest.Mock).mockReturnValue( @@ -291,8 +291,16 @@ describe('SendService', () => { beforeEach(() => { jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue(mockAssetBalances); + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockImplementation(async (_accountId, assetIds) => + Object.fromEntries( + assetIds.map((assetId) => [ + assetId, + mockAssetBalances.find((asset) => asset.assetType === assetId) ?? + null, + ]), + ), + ); jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ @@ -325,7 +333,10 @@ describe('SendService', () => { }); it('rejects when asset balance not found', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [mockRequest.params.assetId]: null, + [Networks[Network.Mainnet].nativeToken.caip19Id]: null, + }); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( `Balance not found for asset ${mockRequest.params.assetId} and account ${mockAccount.id}`, @@ -338,8 +349,8 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.000001' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.00001', keyringAccountId: mockAccount.id, @@ -349,7 +360,17 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '999999999999999999', }, - ]); + [mockRequest.params.assetId]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.00001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '999999999999999999', + }, + }); const result = await sendService.onAmountInput(lowBalanceRequest); @@ -397,8 +418,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.1', keyringAccountId: mockAccount.id, @@ -408,7 +429,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - { + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '0.001', keyringAccountId: mockAccount.id, @@ -419,7 +440,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '1000000', }, - ]); + }); const result = await sendService.onAmountInput(zeroBalanceRequest); @@ -435,8 +456,18 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.1' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '0', + }, + [mockRequest.params.assetId]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0', keyringAccountId: mockAccount.id, @@ -446,7 +477,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '0', }, - ]); + }); const result = await sendService.onAmountInput(zeroSolRequest); @@ -465,8 +496,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -477,7 +508,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '1.0', keyringAccountId: mockAccount.id, @@ -487,7 +518,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -506,8 +537,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -518,7 +549,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.0001', keyringAccountId: mockAccount.id, @@ -528,7 +559,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -549,7 +580,9 @@ describe('SendService', () => { it('handles errors if balances are not found', async () => { const error = new Error('Failed to fetch balances'); - jest.spyOn(mockAssetsService, 'findByAccount').mockRejectedValue(error); + jest + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockRejectedValue(error); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( 'Failed to fetch balances', diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.ts index cbfea69f..14593aaf 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -225,15 +225,13 @@ export class SendService { const isNativeToken = assetId === nativeAssetType; - const accountBalances = await this.#assetsService.findByAccount(account); - - const assetEntry = accountBalances.find( - (asset) => asset.assetType === assetId, + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + [assetId, nativeAssetType], ); - const nativeAsset = accountBalances.find( - (asset) => asset.assetType === nativeAssetType, - ); + const assetEntry = assetsById[assetId]; + const nativeAsset = assetsById[nativeAssetType]; if (!assetEntry) { throw new Error( diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 58e48856..32fc38f6 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -144,20 +144,22 @@ const tokenPricesService = new TokenPricesService({ const nameResolutionService = new NameResolutionService(connection, logger); const assetsRepository = new AssetsRepository(state); + +const accountsRepository = new AccountsRepository(state); +const accountsService = new AccountsService(accountsRepository); + const assetsService = new AssetsService({ connection, logger, configProvider, assetsRepository, + accountsService, tokenApiClient, cache: inMemoryCache, tokenPricesService, nftApiClient, }); -const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService(accountsRepository); - const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper( tokenHelper, From 6f3c8b301bb2a6940b913ec83061fb7650fffee8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 15:45:04 +0000 Subject: [PATCH 2/4] fix: resolve SendService unused-vars eslint failures Drop unused catch binding and account assignment left after the AssetsService read API migration, and prune the stale suppression. Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 5 ----- .../solana-wallet-snap/src/core/services/send/SendService.ts | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5b13d4f1..5c0b1f37 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -519,11 +519,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/services/send/SendService.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.test.ts": { "import-x/no-named-as-default": { "count": 1 diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.ts index 14593aaf..242ba86c 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -189,7 +189,7 @@ export class SendService { valid: true, errors: [], }; - } catch (error) { + } catch { return { valid: false, errors: [{ code: SendErrorCodes.Invalid }], @@ -215,7 +215,7 @@ export class SendService { params: { value, accountId, assetId }, } = request; - const account = await this.#keyring.getAccountOrThrow(accountId); + await this.#keyring.getAccountOrThrow(accountId); const { chainId } = parseCaipAssetType(assetId); From eef1038cb25f6ee0768db9eeec1951be2785068e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 12:39:32 +0000 Subject: [PATCH 3/4] refactor(solana-wallet-snap): route asset reads through scoped read APIs Remove getAccountAssetsForAllActiveScopes and use Promise.all with getAccountAssetsByScope per active scope in Keyring. Refactor getAccountAssetsByIDs to resolve assets via getAccountAssetsByScope. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- .../handlers/onKeyringRequest/Keyring.test.ts | 19 +++++++--- .../core/handlers/onKeyringRequest/Keyring.ts | 17 +++++++-- .../src/core/services/assets/AssetsService.ts | 35 ++++++------------- .../solana-wallet-snap/src/snapContext.ts | 1 + 5 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 5d28fd6e..8adebf62 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssetsForAllActiveScopes`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) +- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, and `getAccountAssetsByScope`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) ### Removed 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 3fc76e52..308ae2c3 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 @@ -15,6 +15,7 @@ import type { KeyringAccountMonitor, TransactionsService, } from '../../services'; +import type { ConfigProvider } from '../../services/config'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; import { InMemoryState } from '../../services/state/InMemoryState'; import type { IStateManager } from '../../services/state/IStateManager'; @@ -84,6 +85,7 @@ describe('SolanaKeyring', () => { let mockConfirmationHandler: ConfirmationHandler; let mockTransactionsService: jest.Mocked; let mockKeyringAccountMonitor: KeyringAccountMonitor; + let mockConfigProvider: ConfigProvider; beforeEach(() => { jest.clearAllMocks(); @@ -103,7 +105,7 @@ describe('SolanaKeyring', () => { mockAssetsService = { fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), saveMany: jest.fn(), - getAccountAssetsForAllActiveScopes: jest.fn(), + getAccountAssetsByScope: jest.fn(), getAccountAssetsByIDs: jest.fn(), getNativeAssetTypes: jest .fn() @@ -130,6 +132,10 @@ describe('SolanaKeyring', () => { setMonitoredAccounts: jest.fn(), } as unknown as KeyringAccountMonitor; + mockConfigProvider = { + getActiveNetworks: jest.fn().mockResolvedValue([Network.Mainnet]), + } as unknown as ConfigProvider; + keyring = new SolanaKeyring({ state: mockState, logger, @@ -138,19 +144,24 @@ describe('SolanaKeyring', () => { walletService: mockWalletService, confirmationHandler: mockConfirmationHandler, keyringAccountMonitor: mockKeyringAccountMonitor, + configProvider: mockConfigProvider, }); }); describe('getAccountAssets', () => { it('calls the assets service', async () => { jest - .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .spyOn(mockAssetsService, 'getAccountAssetsByScope') .mockResolvedValue(MOCK_ASSET_ENTITIES); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, ); + expect(mockAssetsService.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); expect(result).toStrictEqual([ MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType, @@ -160,7 +171,7 @@ describe('SolanaKeyring', () => { it('removes token assets with zero balance', async () => { jest - .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .spyOn(mockAssetsService, 'getAccountAssetsByScope') .mockResolvedValue([ MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance @@ -175,7 +186,7 @@ describe('SolanaKeyring', () => { it('keeps the native asset even if it has zero balance', async () => { jest - .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .spyOn(mockAssetsService, 'getAccountAssetsByScope') .mockResolvedValue([ { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance 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 785496a6..c6bd2b79 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -48,6 +48,7 @@ import type { KeyringAccountMonitor, TransactionsService, } from '../../services'; +import type { ConfigProvider } from '../../services/config'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; import type { IStateManager } from '../../services/state/IStateManager'; import type { UnencryptedStateValue } from '../../services/state/State'; @@ -117,6 +118,8 @@ export class SolanaKeyring implements KeyringSnapRpc { readonly #keyringAccountMonitor: KeyringAccountMonitor; + readonly #configProvider: ConfigProvider; + readonly #traceName: string = 'Create Solana Account'; readonly #traceNameBatch: string = 'Create Solana Account Batch'; @@ -129,6 +132,7 @@ export class SolanaKeyring implements KeyringSnapRpc { walletService, confirmationHandler, keyringAccountMonitor, + configProvider, }: { state: IStateManager; logger: ILogger; @@ -137,6 +141,7 @@ export class SolanaKeyring implements KeyringSnapRpc { walletService: WalletService; confirmationHandler: ConfirmationHandler; keyringAccountMonitor: KeyringAccountMonitor; + configProvider: ConfigProvider; }) { this.#state = state; this.#logger = createPrefixedLogger(logger, '[🔑 Keyring]'); @@ -145,6 +150,7 @@ export class SolanaKeyring implements KeyringSnapRpc { this.#walletService = walletService; this.#confirmationHandler = confirmationHandler; this.#keyringAccountMonitor = keyringAccountMonitor; + this.#configProvider = configProvider; } async #listAccounts(): Promise { @@ -415,8 +421,15 @@ export class SolanaKeyring implements KeyringSnapRpc { await this.getAccountOrThrow(accountId); - const assetEntities = - await this.#assetsService.getAccountAssetsForAllActiveScopes(accountId); + const activeScopes = await this.#configProvider.getActiveNetworks(); + + const assetsByScope = await Promise.all( + activeScopes.map((scope) => + this.#assetsService.getAccountAssetsByScope(scope, accountId), + ), + ); + + const assetEntities = assetsByScope.flat(); const result = assetEntities // Remove token assets with zero balance 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 7d5bd7f0..5fde3472 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -678,13 +678,19 @@ export class AssetsService { return {}; } - const account = await this.#accountsService.findById(accountId); + const scopes = [ + ...new Set( + assetIds.map( + (assetId) => parseCaipAssetType(assetId as CaipAssetType).chainId, + ), + ), + ]; - if (!account) { - return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); - } + const assetsByScope = await Promise.all( + scopes.map((scope) => this.getAccountAssetsByScope(scope, accountId)), + ); - const accountAssets = await this.findByAccount(account); + const accountAssets = assetsByScope.flat(); return Object.fromEntries( assetIds.map((assetId) => [ @@ -715,25 +721,6 @@ export class AssetsService { return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); } - /** - * Returns assets for an account across all active Solana networks. - * - * @param accountId - Keyring account ID. - */ - async getAccountAssetsForAllActiveScopes( - accountId: string, - ): Promise { - const activeNetworks = await this.#configProvider.getActiveNetworks(); - - const assetsByScope = await Promise.all( - activeNetworks.map((network) => - this.getAccountAssetsByScope(network, accountId), - ), - ); - - return assetsByScope.flat(); - } - async findByAccount(account: SolanaKeyringAccount): Promise { const { id: keyringAccountId, address } = account; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 32fc38f6..4ef7e935 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -234,6 +234,7 @@ const keyring = new SolanaKeyring({ walletService, confirmationHandler, keyringAccountMonitor, + configProvider, }); const nftService = new NftService(connection, logger); From 937f5b7469b8814d1b114602b290503c3585311d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 15:33:53 +0000 Subject: [PATCH 4/4] chore: retrigger CI after rebase onto main Co-authored-by: Ulisses Ferreira