From 0a7e634d96c900737a135f42906ba2e8f3bec9bc 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 + .../backgroundEvents/refreshSend.test.tsx | 24 ++++- .../backgroundEvents/refreshSend.tsx | 34 +++++-- .../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 +-- .../src/features/send/render.tsx | 20 ++-- .../solana-wallet-snap/src/snapContext.ts | 8 +- 11 files changed, 355 insertions(+), 78 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 712d91b0..208dbfc2 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, Send, send render, and `refreshSend` through them (still Snap-owned storage). + ## [5.0.0] ### Changed diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx index a0eb261f..64877134 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx @@ -1,4 +1,10 @@ -import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import { + accountsService, + assetsService, + configProvider, + priceApiClient, + state, +} from '../../../../snapContext'; import { KnownCaip19Id } from '../../../constants/solana'; import { trackError } from '../../../utils/errors'; import { @@ -33,9 +39,15 @@ jest.mock('../../../../features/send/Send', () => ({ })); jest.mock('../../../../snapContext', () => ({ - assetsService: { + accountsService: { getAll: jest.fn(), }, + assetsService: { + getAccountAssetsByScope: jest.fn(), + }, + configProvider: { + getActiveNetworks: jest.fn(), + }, priceApiClient: { getMultipleSpotPrices: jest.fn(), }, @@ -50,7 +62,13 @@ const setupTest = () => { request: jest.fn(), }; - (assetsService.getAll as jest.Mock).mockResolvedValue([ + (accountsService.getAll as jest.Mock).mockResolvedValue([ + { id: 'account-1' }, + ]); + (configProvider.getActiveNetworks as jest.Mock).mockResolvedValue([ + 'solana:mainnet', + ]); + (assetsService.getAccountAssetsByScope as jest.Mock).mockResolvedValue([ { assetType: KnownCaip19Id.SolMainnet, }, diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx index 68e38712..8efdd9fb 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx @@ -3,7 +3,13 @@ import type { OnCronjobHandler } from '@metamask/snaps-sdk'; import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; import { Send } from '../../../../features/send/Send'; import type { SendContext } from '../../../../features/send/types'; -import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import { + assetsService, + configProvider, + priceApiClient, + state, + accountsService, +} from '../../../../snapContext'; import type { UnencryptedStateValue } from '../../../services/state/State'; import { trackError } from '../../../utils/errors'; import { @@ -19,13 +25,25 @@ export const refreshSend: OnCronjobHandler = async () => { logger.info(`Background event triggered`); - const [assets, mapInterfaceNameToId, preferences] = await Promise.all([ - assetsService.getAll(), - state.getKey( - 'mapInterfaceNameToId', - ), - getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), - ]); + const [accounts, activeNetworks, mapInterfaceNameToId, preferences] = + await Promise.all([ + accountsService.getAll(), + configProvider.getActiveNetworks(), + state.getKey( + 'mapInterfaceNameToId', + ), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assets = ( + await Promise.all( + accounts.flatMap((account) => + activeNetworks.map((network) => + assetsService.getAccountAssetsByScope(network, account.id), + ), + ), + ) + ).flat(); const assetTypes = assets.flatMap((asset) => asset.assetType); 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/features/send/render.tsx b/packages/solana-wallet-snap/src/features/send/render.tsx index dc1524d9..2974b8fa 100644 --- a/packages/solana-wallet-snap/src/features/send/render.tsx +++ b/packages/solana-wallet-snap/src/features/send/render.tsx @@ -91,13 +91,19 @@ export const renderSend: OnRpcRequestHandler = async ({ request }) => { loading: true, }; - const [assetEntities, keyringAccounts, tokenPrices, preferences] = - await Promise.all([ - assetsService.getAll(), - accountsService.getAll(), - state.getKey('tokenPrices'), - getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), - ]); + const [keyringAccounts, tokenPrices, preferences] = await Promise.all([ + accountsService.getAll(), + state.getKey('tokenPrices'), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assetEntities = ( + await Promise.all( + keyringAccounts.map((keyringAccount) => + assetsService.getAccountAssetsByScope(scope, keyringAccount.id), + ), + ) + ).flat(); context.balances = getBalancesInScope(scope, assetEntities); context.assets = assetEntities.map((asset) => asset.assetType); 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 9c786c3cd1857695dc8ea3fdfa8e8de59299bcbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:45:22 +0000 Subject: [PATCH 2/4] chore(WPN-1652): link changelog entry to #120 Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 208dbfc2..4d9e348f 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, Send, send render, and `refreshSend` through them (still Snap-owned storage). +- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssetsForAllActiveScopes`, and routing Keyring, Send, send render, and `refreshSend` through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) ## [5.0.0] From ed3340cd4a5056ab5d6bcdc9ac58c73d45fca066 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 15:45:04 +0000 Subject: [PATCH 3/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 311a6cb5..cfdfa189 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -532,11 +532,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 a12e35915b1e06e896a6966b68b52ae5e3a98af9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:13:33 +0000 Subject: [PATCH 4/4] chore: sync solana snap.manifest shasum after rebase onto 5.0.0 Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index dfb50021..57535c1d 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": "x1ofZt8S0KIVHIQv3AJyARRJNjs2dvQ8E44IW8mnugU=", + "shasum": "FsnRDiZzvrIkgP6+ieA6ti4GkrHHw5KYWxVWqTjyCnE=", "location": { "npm": { "filePath": "dist/bundle.js",