From 8d40593fc54ec4fa307f5cf3a3313c1f5fb928f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 12:12:14 +0000 Subject: [PATCH 01/12] refactor(solana-wallet-snap): add AssetsService read API aligned with snap-networks-utils Add getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, and getAccountAssets on AssetsService, wire accountsService for account lookup, and cover the read helpers with unit tests. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 4 + .../services/assets/AssetsService.test.ts | 98 +++++++++++++++++ .../src/core/services/assets/AssetsService.ts | 104 +++++++++++++++++- .../solana-wallet-snap/src/snapContext.ts | 8 +- 4 files changed, 210 insertions(+), 4 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index b7acba42f..58816430c 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 `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) + ## [6.0.0] ### Changed 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 d7e7f6726..bbb9d65f7 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,95 @@ 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); + }); + }); + + describe('getAccountAssets', () => { + it('returns assets across all active networks', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssets( + 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 a2778e7b1..5e390652e 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,102 @@ 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 scopes = [ + ...new Set( + assetIds.map( + (assetId) => parseCaipAssetType(assetId as CaipAssetType).chainId, + ), + ), + ]; + + const assetsByType = new Map( + ( + await Promise.all( + scopes.map((scope) => this.getAccountAssetsByScope(scope, accountId)), + ) + ) + .flat() + .map((asset) => [asset.assetType, asset]), + ); + + return Object.fromEntries( + assetIds.map((assetId) => [assetId, assetsByType.get(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 getAccountAssets(accountId: string): Promise { + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + const activeNetworks = await this.#configProvider.getActiveNetworks(); + const accountAssets = await this.findByAccount(account); + + return accountAssets.filter((asset) => + activeNetworks.some((scope) => asset.assetType.startsWith(scope)), + ); + } + 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 58e488561..32fc38f63 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 85432f8ad3c3684d1167ac5d1278aca065d8f82e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 12:12:18 +0000 Subject: [PATCH 02/12] refactor(solana-wallet-snap): route Keyring and Send through AssetsService read API Switch Keyring asset listing/balances and Send balance lookups to the new read methods, remove unused SendService variables, and prune stale eslint suppressions. Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 8 -- .../handlers/onKeyringRequest/Keyring.test.ts | 43 ++++++---- .../core/handlers/onKeyringRequest/Keyring.ts | 18 +++-- .../core/services/send/SendService.test.ts | 79 +++++++++++++------ .../src/core/services/send/SendService.ts | 16 ++-- 5 files changed, 102 insertions(+), 62 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5b13d4f15..00f4c2538 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -287,9 +287,6 @@ }, "no-restricted-syntax": { "count": 1 - }, - "no-unused-private-class-members": { - "count": 1 } }, "packages/solana-wallet-snap/src/core/handlers/onRpcRequest/index.ts": { @@ -519,11 +516,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/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index 5c27fe3eb..2a8284d9c 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(), + getAccountAssets: jest.fn(), + getAccountAssetsByIDs: jest.fn(), getNativeAssetTypes: jest .fn() .mockReturnValue([KnownCaip19Id.SolMainnet]), @@ -143,13 +144,16 @@ describe('SolanaKeyring', () => { describe('getAccountAssets', () => { it('calls the assets service', async () => { jest - .spyOn(mockAssetsService, 'findByAccount') + .spyOn(mockAssetsService, 'getAccountAssets') .mockResolvedValue(MOCK_ASSET_ENTITIES); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, ); + expect(mockAssetsService.getAccountAssets).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); expect(result).toStrictEqual([ MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType, @@ -158,7 +162,7 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + jest.spyOn(mockAssetsService, 'getAccountAssets').mockResolvedValue([ MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance ]); @@ -171,7 +175,7 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + jest.spyOn(mockAssetsService, 'getAccountAssets').mockResolvedValue([ { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance ]); @@ -343,9 +347,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 +359,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 +381,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 9dc6a9858..4feadc899 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -117,8 +117,6 @@ export class SolanaKeyring implements KeyringSnapRpc { readonly #keyringAccountMonitor: KeyringAccountMonitor; - readonly #traceName: string = 'Create Solana Account'; - readonly #traceNameBatch: string = 'Create Solana Account Batch'; constructor({ @@ -413,9 +411,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.getAccountAssets(accountId); const result = assetEntities // Remove token assets with zero balance @@ -448,10 +447,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/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index bf47c8336..7ef7660cc 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 cbfea69f5..242ba86cc 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); @@ -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( From 5cd24007fe5b78e654d8ccb24d7d18ddf65897d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 12:12:34 +0000 Subject: [PATCH 03/12] chore(solana-wallet-snap): sync snap.manifest.json shasum after build 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 790f97ff1..d6491eca6 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": "Zj/AFb6WtXcDvQqAvfHjjEBTdWVdP0tVGUzLnPhAhQ4=", + "shasum": "0k6rJpLJaUfdbDVaSQ0w4I8K8Jp1lMmVoA+bECw6DnQ=", "location": { "npm": { "filePath": "dist/bundle.js", From afeea48cc17bb4a53e3a572e2ebf8086ab538169 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 12:40:23 +0000 Subject: [PATCH 04/12] refactor(solana-wallet-snap): simplify AssetsService read API and fix typecheck - Add AssetsRepository.findByKeyringAccountIdAndAssetType for direct lookups - getAccountAssetByID: repository lookup + native placeholder only when needed - getAccountAssetsByIDs: single repository read, no Promise.all scope fan-out - Extract #createNativePlaceholder shared with findByAccount - Pin @solana/addresses to 2.1.0 to fix Address brand mismatches - Build snap-networks-utils before typecheck (dist required by tron-wallet-snap) - Remove @jest/globals expect import from index.test.ts (use snaps-jest types) - Sync snap.manifest.json shasum after build Co-authored-by: Ulisses Ferreira --- package.json | 3 +- .../solana-wallet-snap/snap.manifest.json | 2 +- .../services/assets/AssetsRepository.test.ts | 22 ++++ .../core/services/assets/AssetsRepository.ts | 9 ++ .../services/assets/AssetsService.test.ts | 9 +- .../src/core/services/assets/AssetsService.ts | 103 ++++++++++++------ packages/solana-wallet-snap/src/index.test.ts | 1 - yarn.lock | 95 +--------------- 8 files changed, 108 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index b4ac811b6..8c9afc43a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", "test:scripts": "NODE_OPTIONS=--experimental-vm-modules yarn jest --config ./jest.config.scripts.js --silent", "test:verbose": "yarn workspaces foreach --all --exclude @metamask/sample-snap --parallel --verbose run test:verbose", - "typecheck": "yarn workspaces foreach --all --parallel --verbose exec tsc --noEmit", + "typecheck": "yarn workspace @metamask/snap-networks-utils run build && yarn workspaces foreach --all --parallel --verbose exec tsc --noEmit", "workspaces:list-versions": "./scripts/list-workspace-versions.sh" }, "devDependencies": { @@ -91,6 +91,7 @@ "resolutions": { "@metamask/snaps-execution-environments": "11.2.0", "@metamask/snaps-sdk": "11.2.0", + "@solana/addresses": "2.1.0", "@solana/kit": "2.1.0", "@types/react": "18.2.4", "@types/react-dom": "18.2.4", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index d6491eca6..ffaedce01 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": "0k6rJpLJaUfdbDVaSQ0w4I8K8Jp1lMmVoA+bECw6DnQ=", + "shasum": "UXZPzVyEtVoPSebekUtMOTiRWYaZFZTPHNyd3s1qSHU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts index 0c74ce76b..06462af95 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts @@ -71,6 +71,28 @@ describe('AssetsRepository', () => { }); }); + describe('findByKeyringAccountIdAndAssetType', () => { + it('returns the matching asset when present', async () => { + await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); + + const asset = await repository.findByKeyringAccountIdAndAssetType( + 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 () => { + const asset = await repository.findByKeyringAccountIdAndAssetType( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + }); + describe('getAll', () => { it('returns empty array when no assets exist', async () => { const assets = await repository.getAll(); diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts index 3ed632cc5..7617b4749 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts @@ -21,6 +21,15 @@ export class AssetsRepository { return assets ?? []; } + async findByKeyringAccountIdAndAssetType( + keyringAccountId: string, + assetType: string, + ): Promise { + const assets = await this.findByKeyringAccountId(keyringAccountId); + + return assets.find((asset) => asset.assetType === assetType) ?? null; + } + async getAll(): Promise { const assetsByAccount = (await this.#state.getKey( 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 bbb9d65f7..15632f022 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 @@ -79,6 +79,7 @@ describe('AssetsService', () => { mockAssetsRepository = { findByKeyringAccountId: jest.fn(), + findByKeyringAccountIdAndAssetType: jest.fn(), getAll: jest.fn(), saveMany: jest.fn(), } as unknown as AssetsRepository; @@ -615,8 +616,8 @@ describe('AssetsService', () => { describe('getAccountAssetByID', () => { it('returns the matching asset when present', async () => { jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + .spyOn(mockAssetsRepository, 'findByKeyringAccountIdAndAssetType') + .mockResolvedValueOnce(MOCK_ASSET_ENTITY_1); const asset = await assetsService.getAccountAssetByID( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -628,8 +629,8 @@ describe('AssetsService', () => { it('returns null when the asset is missing', async () => { jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce([]); + .spyOn(mockAssetsRepository, 'findByKeyringAccountIdAndAssetType') + .mockResolvedValueOnce(null); const asset = await assetsService.getAccountAssetByID( MOCK_SOLANA_KEYRING_ACCOUNT_0.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 5e390652e..2d1df1c25 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -221,7 +221,7 @@ export class AssetsService { ): Promise> { this.#logger.log('Fetching metadata for assets', assetTypes); - const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = + const { nativeAssetTypes, tokenAssetTypes } = this.#splitAssetsByType(assetTypes); const [ @@ -656,11 +656,32 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { - const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return null; + } - const assets = await this.getAccountAssetsByScope(chainId, accountId); + const savedAsset = + await this.#assetsRepository.findByKeyringAccountIdAndAssetType( + accountId, + assetId, + ); + + if (savedAsset) { + return savedAsset; + } - return assets.find((asset) => asset.assetType === assetId) ?? null; + const nativeAssetTypes = await this.getNativeAssetTypes(); + + if (!nativeAssetTypes.includes(assetId as NativeCaipAssetType)) { + return null; + } + + return this.#createNativePlaceholder( + account, + assetId as NativeCaipAssetType, + ); } /** @@ -678,26 +699,31 @@ export class AssetsService { return {}; } - const scopes = [ - ...new Set( - assetIds.map( - (assetId) => parseCaipAssetType(assetId as CaipAssetType).chainId, - ), - ), - ]; + const account = await this.#accountsService.findById(accountId); - const assetsByType = new Map( - ( - await Promise.all( - scopes.map((scope) => this.getAccountAssetsByScope(scope, accountId)), - ) - ) - .flat() - .map((asset) => [asset.assetType, asset]), + if (!account) { + return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); + } + + const savedAssets = + await this.#assetsRepository.findByKeyringAccountId(accountId); + const assetsByType = new Map( + savedAssets.map((asset) => [asset.assetType, asset]), ); + const nativeAssetTypes = await this.getNativeAssetTypes(); + const nativeAssetTypeSet = new Set(nativeAssetTypes); return Object.fromEntries( - assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), + assetIds.map((assetId) => [ + assetId, + assetsByType.get(assetId) ?? + (nativeAssetTypeSet.has(assetId as NativeCaipAssetType) + ? this.#createNativePlaceholder( + account, + assetId as NativeCaipAssetType, + ) + : null), + ]), ); } @@ -743,7 +769,7 @@ export class AssetsService { } async findByAccount(account: SolanaKeyringAccount): Promise { - const { id: keyringAccountId, address } = account; + const { id: keyringAccountId } = account; const savedAssets = await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); @@ -758,23 +784,30 @@ export class AssetsService { ); if (!hasNativeAsset) { - // Create a placeholder native asset with zero balance - // This will be updated when assets are actually fetched - const network = getNetworkFromToken(nativeAssetType); - - missingNativeAssets.push({ - assetType: nativeAssetType, - keyringAccountId: account.id, - network, - address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }); + missingNativeAssets.push( + this.#createNativePlaceholder(account, nativeAssetType), + ); } } return [...savedAssets, ...missingNativeAssets]; } + + #createNativePlaceholder( + account: SolanaKeyringAccount, + nativeAssetType: NativeCaipAssetType, + ): NativeAsset { + const network = getNetworkFromToken(nativeAssetType); + + return { + assetType: nativeAssetType, + keyringAccountId: account.id, + network, + address: account.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }; + } } diff --git a/packages/solana-wallet-snap/src/index.test.ts b/packages/solana-wallet-snap/src/index.test.ts index 11814354e..32eac1280 100644 --- a/packages/solana-wallet-snap/src/index.test.ts +++ b/packages/solana-wallet-snap/src/index.test.ts @@ -1,4 +1,3 @@ -import { expect } from '@jest/globals'; import { installSnap } from '@metamask/snaps-jest'; import { onCronjob } from '.'; diff --git a/yarn.lock b/yarn.lock index 1a044bd76..0287253a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4563,21 +4563,6 @@ __metadata: languageName: node linkType: hard -"@solana/addresses@npm:^2.0.0": - version: 2.3.0 - resolution: "@solana/addresses@npm:2.3.0" - dependencies: - "@solana/assertions": "npm:2.3.0" - "@solana/codecs-core": "npm:2.3.0" - "@solana/codecs-strings": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - "@solana/nominal-types": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/11ee6774f938dd2fe5174375b02c4e52063173ce80a895dd35beb84635dcf3e2ba2e3fe7c74a614b2322da10906abceab758c91a13415a9cda53faf91a8ea722 - languageName: node - linkType: hard - "@solana/assertions@npm:2.1.0": version: 2.1.0 resolution: "@solana/assertions@npm:2.1.0" @@ -4589,17 +4574,6 @@ __metadata: languageName: node linkType: hard -"@solana/assertions@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/assertions@npm:2.3.0" - dependencies: - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/3b430f01d5991569059fea3e6679a4548a624ff5da6db0c2eb2eba757a9ff774c9d90941a0a2335148599aaef8e8fc004a9c5996eb3627b25dca108b05591d02 - languageName: node - linkType: hard - "@solana/codecs-core@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-core@npm:2.1.0" @@ -4611,17 +4585,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-core@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-core@npm:2.3.0" - dependencies: - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/d9bba1eaa3ee38fef04e1cbfa43defeea16729a1cf1628a71cb72340558a3f2296279899680e1dda4b1756ab2b280b5f1502330c21c35e167e554f3d0c9d193d - languageName: node - linkType: hard - "@solana/codecs-data-structures@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-data-structures@npm:2.1.0" @@ -4647,18 +4610,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-numbers@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-numbers@npm:2.3.0" - dependencies: - "@solana/codecs-core": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/e661338b5eb04268a104ff2189b5d001bd2f99e1a3726deaa7157d5acbc3b24740bc25ca03b2028c52ad21fd71d5d5aa64957411c895a9dc1c132aa3bc97b336 - languageName: node - linkType: hard - "@solana/codecs-strings@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-strings@npm:2.1.0" @@ -4673,20 +4624,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-strings@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-strings@npm:2.3.0" - dependencies: - "@solana/codecs-core": "npm:2.3.0" - "@solana/codecs-numbers": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - peerDependencies: - fastestsmallesttextencoderdecoder: ^1.0.22 - typescript: ">=5.3.3" - checksum: 10/a553a891ce149a87f37ff338587980e81f9752e13039cb0d02f0fc6f21ce27af8548ad10f4c9e63f9ef74786ea0e498c91a2f5e3e996a930f581f2977bb1c215 - languageName: node - linkType: hard - "@solana/codecs@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs@npm:2.1.0" @@ -4716,20 +4653,6 @@ __metadata: languageName: node linkType: hard -"@solana/errors@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/errors@npm:2.3.0" - dependencies: - chalk: "npm:^5.4.1" - commander: "npm:^14.0.0" - peerDependencies: - typescript: ">=5.3.3" - bin: - errors: bin/cli.mjs - checksum: 10/0e8a329790b7d38b4bfe1fa6ec2ac60be20562a610d992031395fe9886da28b578a9d0aebb318f5357ae0d4cbc8f3d323c12b9520da2cf6adc9038f96afc3fe1 - languageName: node - linkType: hard - "@solana/fast-stable-stringify@npm:2.1.0": version: 2.1.0 resolution: "@solana/fast-stable-stringify@npm:2.1.0" @@ -4802,15 +4725,6 @@ __metadata: languageName: node linkType: hard -"@solana/nominal-types@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/nominal-types@npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/0594893661f4ff2f8587689cd4b61ee15c38c455fe5cbaa7ae7e416f3a483fac97cc3f5a5b3d0a7526bfb89d7da91bc2c72e7b1790bbe59b986579ef2f76689b - languageName: node - linkType: hard - "@solana/options@npm:2.1.0": version: 2.1.0 resolution: "@solana/options@npm:2.1.0" @@ -7479,7 +7393,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.6.2, chalk@npm:^5.3.0, chalk@npm:^5.4.1": +"chalk@npm:5.6.2, chalk@npm:^5.3.0": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 @@ -7713,13 +7627,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^14.0.0": - version: 14.0.3 - resolution: "commander@npm:14.0.3" - checksum: 10/dfa9ebe2a433d277de5cb0252d23b10a543d245d892db858d23b516336a835c50fd4f52bee4cd13c705cc8acb6f03dc632c73dd806f7d06d3353eb09953dd17a - languageName: node - linkType: hard - "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" From 1dc933e29d5f44c5d95e65a582c17c6f6890ed14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 13:00:52 +0000 Subject: [PATCH 05/12] refactor(solana-wallet-snap): drop repository read helpers, keep AssetsService read API Remove findByKeyringAccountIdAndAssetType from AssetsRepository. Read paths stay on AssetsService (getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, getAccountAssets) using findByAccount and in-memory filtering. Co-authored-by: Ulisses Ferreira --- .../services/assets/AssetsRepository.test.ts | 22 --------- .../core/services/assets/AssetsRepository.ts | 9 ---- .../services/assets/AssetsService.test.ts | 9 ++-- .../src/core/services/assets/AssetsService.ts | 45 +++---------------- 4 files changed, 10 insertions(+), 75 deletions(-) diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts index 06462af95..0c74ce76b 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts @@ -71,28 +71,6 @@ describe('AssetsRepository', () => { }); }); - describe('findByKeyringAccountIdAndAssetType', () => { - it('returns the matching asset when present', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - - const asset = await repository.findByKeyringAccountIdAndAssetType( - 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 () => { - const asset = await repository.findByKeyringAccountIdAndAssetType( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - MOCK_ASSET_ENTITY_1.assetType, - ); - - expect(asset).toBeNull(); - }); - }); - describe('getAll', () => { it('returns empty array when no assets exist', async () => { const assets = await repository.getAll(); diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts index 7617b4749..3ed632cc5 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts @@ -21,15 +21,6 @@ export class AssetsRepository { return assets ?? []; } - async findByKeyringAccountIdAndAssetType( - keyringAccountId: string, - assetType: string, - ): Promise { - const assets = await this.findByKeyringAccountId(keyringAccountId); - - return assets.find((asset) => asset.assetType === assetType) ?? null; - } - async getAll(): Promise { const assetsByAccount = (await this.#state.getKey( 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 15632f022..bbb9d65f7 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 @@ -79,7 +79,6 @@ describe('AssetsService', () => { mockAssetsRepository = { findByKeyringAccountId: jest.fn(), - findByKeyringAccountIdAndAssetType: jest.fn(), getAll: jest.fn(), saveMany: jest.fn(), } as unknown as AssetsRepository; @@ -616,8 +615,8 @@ describe('AssetsService', () => { describe('getAccountAssetByID', () => { it('returns the matching asset when present', async () => { jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountIdAndAssetType') - .mockResolvedValueOnce(MOCK_ASSET_ENTITY_1); + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); const asset = await assetsService.getAccountAssetByID( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -629,8 +628,8 @@ describe('AssetsService', () => { it('returns null when the asset is missing', async () => { jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountIdAndAssetType') - .mockResolvedValueOnce(null); + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); const asset = await assetsService.getAccountAssetByID( MOCK_SOLANA_KEYRING_ACCOUNT_0.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 2d1df1c25..2b1c77faf 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -656,32 +656,11 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return null; - } + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); - const savedAsset = - await this.#assetsRepository.findByKeyringAccountIdAndAssetType( - accountId, - assetId, - ); - - if (savedAsset) { - return savedAsset; - } + const assets = await this.getAccountAssetsByScope(chainId, accountId); - const nativeAssetTypes = await this.getNativeAssetTypes(); - - if (!nativeAssetTypes.includes(assetId as NativeCaipAssetType)) { - return null; - } - - return this.#createNativePlaceholder( - account, - assetId as NativeCaipAssetType, - ); + return assets.find((asset) => asset.assetType === assetId) ?? null; } /** @@ -705,25 +684,13 @@ export class AssetsService { return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); } - const savedAssets = - await this.#assetsRepository.findByKeyringAccountId(accountId); + const accountAssets = await this.findByAccount(account); const assetsByType = new Map( - savedAssets.map((asset) => [asset.assetType, asset]), + accountAssets.map((asset) => [asset.assetType, asset]), ); - const nativeAssetTypes = await this.getNativeAssetTypes(); - const nativeAssetTypeSet = new Set(nativeAssetTypes); return Object.fromEntries( - assetIds.map((assetId) => [ - assetId, - assetsByType.get(assetId) ?? - (nativeAssetTypeSet.has(assetId as NativeCaipAssetType) - ? this.#createNativePlaceholder( - account, - assetId as NativeCaipAssetType, - ) - : null), - ]), + assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), ); } From 517827db4e06e57c52b19d2271b838f60bae6b1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 14:07:38 +0000 Subject: [PATCH 06/12] refactor(solana-wallet-snap): inline native asset placeholder in findByAccount Co-authored-by: Ulisses Ferreira --- .../src/core/services/assets/AssetsService.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) 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 2b1c77faf..0c956606d 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -751,30 +751,21 @@ export class AssetsService { ); if (!hasNativeAsset) { - missingNativeAssets.push( - this.#createNativePlaceholder(account, nativeAssetType), - ); + const network = getNetworkFromToken(nativeAssetType); + + missingNativeAssets.push({ + assetType: nativeAssetType, + keyringAccountId: account.id, + network, + address: account.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }); } } return [...savedAssets, ...missingNativeAssets]; } - - #createNativePlaceholder( - account: SolanaKeyringAccount, - nativeAssetType: NativeCaipAssetType, - ): NativeAsset { - const network = getNetworkFromToken(nativeAssetType); - - return { - assetType: nativeAssetType, - keyringAccountId: account.id, - network, - address: account.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }; - } } From fd6f4f676aa20ec8e11af4c1e3ef53e9e6146cc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 14:15:31 +0000 Subject: [PATCH 07/12] refactor(solana-wallet-snap): simplify getAccountAssetByID via by-IDs path Route getAccountAssetByID through getAccountAssetsByIDs instead of getAccountAssetsByScope, and extract #getAccountAssetsOrEmpty so the read API shares one account lookup + findByAccount path. Co-authored-by: Ulisses Ferreira --- .../services/assets/AssetsService.test.ts | 58 +++++++++++++++++++ .../src/core/services/assets/AssetsService.ts | 46 +++++++-------- 2 files changed, 79 insertions(+), 25 deletions(-) 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 bbb9d65f7..84164d623 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 @@ -638,6 +638,18 @@ describe('AssetsService', () => { expect(asset).toBeNull(); }); + + it('returns null when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const asset = await assetsService.getAccountAssetByID( + 'missing-account', + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + }); }); describe('getAccountAssetsByIDs', () => { @@ -672,6 +684,31 @@ describe('AssetsService', () => { [MOCK_ASSET_ENTITY_1.assetType]: null, }); }); + + it('returns an empty record for an empty asset ID list', async () => { + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [], + ); + + expect(assets).toStrictEqual({}); + expect(mockAccountsService.findById).not.toHaveBeenCalled(); + }); + + it('returns null entries when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssetsByIDs( + 'missing-account', + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: null, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + }); }); describe('getAccountAssetsByScope', () => { @@ -687,6 +724,18 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); + + it('returns an empty array when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + 'missing-account', + ); + + expect(assets).toStrictEqual([]); + expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + }); }); describe('getAccountAssets', () => { @@ -701,5 +750,14 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); + + it('returns an empty array when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssets('missing-account'); + + expect(assets).toStrictEqual([]); + expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + }); }); }); 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 0c956606d..15742c60c 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -646,6 +646,22 @@ export class AssetsService { return this.#assetsRepository.getAll(); } + /** + * Resolves account assets via {@link findByAccount}, or `[]` if the account + * is missing. Centralizes the account lookup shared by the read API. + * + * @param accountId - Keyring account ID. + */ + async #getAccountAssetsOrEmpty(accountId: string): Promise { + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + return this.findByAccount(account); + } + /** * Returns a single account asset by CAIP-19 ID, or `null` if missing. * @@ -656,11 +672,9 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { - const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + const assets = await this.getAccountAssetsByIDs(accountId, [assetId]); - const assets = await this.getAccountAssetsByScope(chainId, accountId); - - return assets.find((asset) => asset.assetType === assetId) ?? null; + return assets[assetId] ?? null; } /** @@ -678,13 +692,7 @@ export class AssetsService { return {}; } - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); - } - - const accountAssets = await this.findByAccount(account); + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); const assetsByType = new Map( accountAssets.map((asset) => [asset.assetType, asset]), ); @@ -704,13 +712,7 @@ export class AssetsService { scope: CaipChainId, accountId: string, ): Promise { - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return []; - } - - const accountAssets = await this.findByAccount(account); + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); } @@ -721,14 +723,8 @@ export class AssetsService { * @param accountId - Keyring account ID. */ async getAccountAssets(accountId: string): Promise { - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return []; - } - const activeNetworks = await this.#configProvider.getActiveNetworks(); - const accountAssets = await this.findByAccount(account); + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); return accountAssets.filter((asset) => activeNetworks.some((scope) => asset.assetType.startsWith(scope)), From 8c37f1031379b27c7dd0b69dc70b2a9aa503ef76 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:27:21 +0100 Subject: [PATCH 08/12] fix(solana-wallet-snap): sync manifest shasum from CI and fix formatting Apply the production bundle shasum computed by CI and fix oxfmt formatting in AssetsService.test.ts. --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../core/services/assets/AssetsService.test.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index ffaedce01..0632d4d28 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": "UXZPzVyEtVoPSebekUtMOTiRWYaZFZTPHNyd3s1qSHU=", + "shasum": "YaPEFBNuMbbASorQCj4MFJKRSlqOjSOqJoOGUB9ET2I=", "location": { "npm": { "filePath": "dist/bundle.js", 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 84164d623..3bb5fca22 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 @@ -648,7 +648,9 @@ describe('AssetsService', () => { ); expect(asset).toBeNull(); - expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); }); }); @@ -707,7 +709,9 @@ describe('AssetsService', () => { [MOCK_ASSET_ENTITY_0.assetType]: null, [MOCK_ASSET_ENTITY_1.assetType]: null, }); - expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); }); }); @@ -734,7 +738,9 @@ describe('AssetsService', () => { ); expect(assets).toStrictEqual([]); - expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); }); }); @@ -757,7 +763,9 @@ describe('AssetsService', () => { const assets = await assetsService.getAccountAssets('missing-account'); expect(assets).toStrictEqual([]); - expect(mockAssetsRepository.findByKeyringAccountId).not.toHaveBeenCalled(); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); }); }); }); From 91fed1ffe6f477fe9c870afc695be3fe0e905458 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:33:55 +0100 Subject: [PATCH 09/12] chore: prune stale eslint suppressions Remove suppressions that no longer apply after the AssetsService refactor. --- eslint-suppressions.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 00f4c2538..d434d6790 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -360,9 +360,6 @@ "@typescript-eslint/explicit-function-return-type": { "count": 10 }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-unused-private-class-members": { "count": 2 } @@ -1248,9 +1245,6 @@ "@typescript-eslint/no-explicit-any": { "count": 1 }, - "@typescript-eslint/no-shadow": { - "count": 1 - }, "jest/unbound-method": { "count": 2 } @@ -1626,4 +1620,4 @@ "count": 2 } } -} +} \ No newline at end of file From a882518cbeb492dfd5084b1ddf80482b7c9842ed Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:39:37 +0100 Subject: [PATCH 10/12] chore: format eslint-suppressions.json --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d434d6790..651267199 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1620,4 +1620,4 @@ "count": 2 } } -} \ No newline at end of file +} From 23f18db36ef62eae2ce1805d6c790960b7f37666 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:48:27 +0100 Subject: [PATCH 11/12] refactor(solana-wallet-snap): type AssetsService read API with CaipAssetType Use CaipAssetType for asset ID parameters and return keys in getAccountAssetByID and getAccountAssetsByIDs per review feedback. --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../src/core/services/assets/AssetsService.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 0632d4d28..b9492ba12 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": "YaPEFBNuMbbASorQCj4MFJKRSlqOjSOqJoOGUB9ET2I=", + "shasum": "2qLoORoV086YIzuSFzNvy+szd7X7QiZkoFtlY68C67w=", "location": { "npm": { "filePath": "dist/bundle.js", 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 15742c60c..7a58bfeb0 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -670,7 +670,7 @@ export class AssetsService { */ async getAccountAssetByID( accountId: string, - assetId: string, + assetId: CaipAssetType, ): Promise { const assets = await this.getAccountAssetsByIDs(accountId, [assetId]); @@ -686,20 +686,20 @@ export class AssetsService { */ async getAccountAssetsByIDs( accountId: string, - assetIds: string[], - ): Promise> { + assetIds: CaipAssetType[], + ): Promise> { if (assetIds.length === 0) { - return {}; + return {} as Record; } const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); - const assetsByType = new Map( + const assetsByType = new Map( accountAssets.map((asset) => [asset.assetType, asset]), ); return Object.fromEntries( assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), - ); + ) as Record; } /** From 35696649330fd38e74fba7278bb376024fccce63 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:52:42 +0100 Subject: [PATCH 12/12] chore(solana-wallet-snap): sync production manifest shasum after build The previous commit used a test-environment shasum; CI builds with ENVIRONMENT=production and requires a clean working tree. --- 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 b9492ba12..0632d4d28 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": "2qLoORoV086YIzuSFzNvy+szd7X7QiZkoFtlY68C67w=", + "shasum": "YaPEFBNuMbbASorQCj4MFJKRSlqOjSOqJoOGUB9ET2I=", "location": { "npm": { "filePath": "dist/bundle.js",