From 44e9a6febf88cd17e7106ad2f529e0f216ecc6e6 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 1 Sep 2026 11:53:03 +0100 Subject: [PATCH] perf(solana-wallet-snap): resolve Core token accounts lazily --- .../services/assets/AssetsService.test.ts | 1 - .../assets/adapters/CoreAssetsAdapter.test.ts | 280 +++++------------- .../assets/adapters/CoreAssetsAdapter.ts | 111 ++----- .../assets/utils/mapControllerAsset.test.ts | 6 +- .../assets/utils/mapControllerAsset.ts | 19 +- .../transactions/TransactionsService.test.ts | 129 +++++++- .../transactions/TransactionsService.ts | 80 ++++- .../solana-wallet-snap/src/entities/assets.ts | 3 +- .../solana-wallet-snap/src/snapContext.ts | 3 +- 9 files changed, 304 insertions(+), 328 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 ab8bf7408..552f1e922 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 @@ -110,7 +110,6 @@ describe('AssetsService', () => { findAccountById: mockAccountsService.findById.bind(mockAccountsService), getActiveNetworks: mockConfigProvider.getActiveNetworks.bind(mockConfigProvider), - fetchMint: mockConnection.fetchMint.bind(mockConnection), }); assetsService = new AssetsService({ diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts index 7ade6fc86..11eca7ab4 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts @@ -1,19 +1,10 @@ import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; import type { CaipChainId } from '@metamask/utils'; -import { - findAssociatedTokenPda, - TOKEN_PROGRAM_ADDRESS, -} from '@solana-program/token'; -import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; -import type { Address } from '@solana/kit'; -import { address as asAddress } from '@solana/kit'; import { KnownCaip19Id, Network } from '../../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; import { mockLogger } from '../../__mocks__/logger'; -import { MOCK_MINT_ACCOUNT } from '../../__mocks__/mockSolanaRpcResponses'; -import type { SolanaConnection } from '../../connection'; import { CoreAssetsAdapter } from './CoreAssetsAdapter'; const ACCOUNT_ID = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; @@ -22,27 +13,6 @@ const MAINNET_ASSET_ID = KnownCaip19Id.SolMainnet as Caip19AssetId; const USDC_ASSET_ID = KnownCaip19Id.UsdcMainnet as Caip19AssetId; const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -/** - * Derives the associated token account address for a mint/owner/program. - * - * @param mint - Mint address. - * @param owner - Owner address. - * @param tokenProgram - Token program that owns the mint. - * @returns The ATA address. - */ -async function expectedAssociatedTokenAccount( - mint: string, - owner: string, - tokenProgram: Address, -): Promise { - const [ata] = await findAssociatedTokenPda({ - mint: asAddress(mint), - owner: asAddress(owner), - tokenProgram, - }); - return ata; -} - /** * Builds a controller asset for adapter mapping tests. * @@ -106,7 +76,6 @@ function createCoreAssetsAdapterContext(): { >; mockFindAccountById: jest.Mock; mockGetActiveNetworks: jest.Mock; - mockFetchMint: jest.MockedFunction; } { const mockAssetsProvider = { getAccountAssetByID: jest.fn().mockResolvedValue(undefined), @@ -118,11 +87,6 @@ function createCoreAssetsAdapterContext(): { .fn() .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); const mockGetActiveNetworks = jest.fn().mockResolvedValue([Network.Mainnet]); - const mockFetchMint = jest - .fn() - .mockResolvedValue(MOCK_MINT_ACCOUNT) as jest.MockedFunction< - SolanaConnection['fetchMint'] - >; const adapter = new CoreAssetsAdapter({ logger: mockLogger, @@ -131,7 +95,6 @@ function createCoreAssetsAdapterContext(): { getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, findAccountById: mockFindAccountById, getActiveNetworks: mockGetActiveNetworks, - fetchMint: mockFetchMint, }); return { @@ -139,7 +102,6 @@ function createCoreAssetsAdapterContext(): { mockAssetsProvider, mockFindAccountById, mockGetActiveNetworks, - mockFetchMint, }; } @@ -160,139 +122,64 @@ async function withCoreAssetsAdapter( describe('CoreAssetsAdapter', () => { describe('getAccountAssetByID', () => { it('maps a native controller asset without deriving an ATA', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockAssetsProvider, mockFetchMint }) => { - const controllerAsset = createControllerAsset({ - id: MAINNET_ASSET_ID, - }); - mockAssetsProvider.getAccountAssetByID.mockResolvedValue( - controllerAsset, - ); - - const asset = await adapter.getAccountAssetByID( - ACCOUNT_ID, - MAINNET_ASSET_ID, - ); - - expect(mockAssetsProvider.getAccountAssetByID).toHaveBeenCalledWith( - ACCOUNT_ID, - MAINNET_ASSET_ID, - ); - expect(mockFetchMint).not.toHaveBeenCalled(); - expect(asset).toStrictEqual({ - assetType: MAINNET_ASSET_ID, - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - address: ACCOUNT_ADDRESS, - symbol: 'SOL', - decimals: 9, - rawAmount: '1000000000', - uiAmount: '1', - }); - }, - ); - }); - - it('maps an SPL token and derives its associated token account pubkey', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockAssetsProvider, mockFetchMint }) => { - const controllerAsset = createControllerAsset({ - id: USDC_ASSET_ID, - symbol: 'USDC', - decimals: 6, - amount: '1.234567', - }); - mockAssetsProvider.getAccountAssetByID.mockResolvedValue( - controllerAsset, - ); - - const asset = await adapter.getAccountAssetByID( - ACCOUNT_ID, - USDC_ASSET_ID, - ); - - expect(mockFetchMint).toHaveBeenCalledWith( - USDC_MINT, - Network.Mainnet, - ); - expect(asset).toStrictEqual({ - assetType: USDC_ASSET_ID, - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - mint: USDC_MINT, - pubkey: await expectedAssociatedTokenAccount( - USDC_MINT, - ACCOUNT_ADDRESS, - TOKEN_PROGRAM_ADDRESS, - ), - symbol: 'USDC', - decimals: 6, - rawAmount: '1234567', - uiAmount: '1.234567', - }); - }, - ); - }); - - it('derives the ATA with the Token-2022 program when the mint is Token-2022', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockAssetsProvider, mockFetchMint }) => { - mockFetchMint.mockResolvedValue({ - ...MOCK_MINT_ACCOUNT, - programAddress: TOKEN_2022_PROGRAM_ADDRESS, - }); - mockAssetsProvider.getAccountAssetByID.mockResolvedValue( - createControllerAsset({ - id: USDC_ASSET_ID, - symbol: 'USDC', - decimals: 6, - amount: '1.234567', - }), - ); - - const asset = await adapter.getAccountAssetByID( - ACCOUNT_ID, - USDC_ASSET_ID, - ); + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const controllerAsset = createControllerAsset({ + id: MAINNET_ASSET_ID, + }); + mockAssetsProvider.getAccountAssetByID.mockResolvedValue( + controllerAsset, + ); - const tokenProgramAta = await expectedAssociatedTokenAccount( - USDC_MINT, - ACCOUNT_ADDRESS, - TOKEN_PROGRAM_ADDRESS, - ); - const token2022Ata = await expectedAssociatedTokenAccount( - USDC_MINT, - ACCOUNT_ADDRESS, - TOKEN_2022_PROGRAM_ADDRESS, - ); + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); - expect(token2022Ata).not.toBe(tokenProgramAta); - expect(asset).toMatchObject({ pubkey: token2022Ata }); - }, - ); + expect(mockAssetsProvider.getAccountAssetByID).toHaveBeenCalledWith( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); + expect(asset).toStrictEqual({ + assetType: MAINNET_ASSET_ID, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + address: ACCOUNT_ADDRESS, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + }); }); - it('returns null when the ATA cannot be derived', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockAssetsProvider, mockFetchMint }) => { - mockFetchMint.mockRejectedValue(new Error('mint missing')); - mockAssetsProvider.getAccountAssetByID.mockResolvedValue( - createControllerAsset({ - id: USDC_ASSET_ID, - symbol: 'USDC', - decimals: 6, - amount: '1.234567', - }), - ); + it('maps an SPL token without resolving its associated token account pubkey', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const controllerAsset = createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1.234567', + }); + mockAssetsProvider.getAccountAssetByID.mockResolvedValue( + controllerAsset, + ); - const asset = await adapter.getAccountAssetByID( - ACCOUNT_ID, - USDC_ASSET_ID, - ); + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); - expect(asset).toBeNull(); - }, - ); + expect(asset).toStrictEqual({ + assetType: USDC_ASSET_ID, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + mint: USDC_MINT, + symbol: 'USDC', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + }); + }); }); it('returns null when the controller has no matching asset', async () => { @@ -345,7 +232,7 @@ describe('CoreAssetsAdapter', () => { }); }); - it('derives ATA pubkeys for SPL tokens returned by ID', async () => { + it('does not resolve ATA pubkeys for SPL tokens returned by ID', async () => { await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { mockAssetsProvider.getAccountAssetsByIDs.mockResolvedValue({ [MAINNET_ASSET_ID]: createControllerAsset({ id: MAINNET_ASSET_ID }), @@ -362,14 +249,8 @@ describe('CoreAssetsAdapter', () => { USDC_ASSET_ID, ]); - expect(assets[USDC_ASSET_ID]).toMatchObject({ - mint: USDC_MINT, - pubkey: await expectedAssociatedTokenAccount( - USDC_MINT, - ACCOUNT_ADDRESS, - TOKEN_PROGRAM_ADDRESS, - ), - }); + expect(assets[USDC_ASSET_ID]).toMatchObject({ mint: USDC_MINT }); + expect(assets[USDC_ASSET_ID]).not.toHaveProperty('pubkey'); expect(assets[MAINNET_ASSET_ID]).not.toHaveProperty('pubkey'); }); }); @@ -436,40 +317,33 @@ describe('CoreAssetsAdapter', () => { assets.every((asset) => asset.keyringAccountId === ACCOUNT_ID), ).toBe(true); const usdc = assets.find((asset) => asset.assetType === USDC_ASSET_ID); - expect(usdc).toMatchObject({ - mint: USDC_MINT, - pubkey: await expectedAssociatedTokenAccount( - USDC_MINT, - ACCOUNT_ADDRESS, - TOKEN_PROGRAM_ADDRESS, - ), - }); + expect(usdc).toMatchObject({ mint: USDC_MINT }); + expect(usdc).not.toHaveProperty('pubkey'); }); }); - it('omits SPL tokens whose ATA cannot be derived', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockAssetsProvider, mockFetchMint }) => { - mockFetchMint.mockRejectedValue(new Error('mint missing')); - mockAssetsProvider.getAccountAssetsByScope.mockResolvedValue({ - [MAINNET_ASSET_ID]: createControllerAsset({ id: MAINNET_ASSET_ID }), - [USDC_ASSET_ID]: createControllerAsset({ - id: USDC_ASSET_ID, - symbol: 'USDC', - decimals: 6, - amount: '1.234567', - }), - }); + it('does not resolve ATA pubkeys when mapping assets by scope', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + mockAssetsProvider.getAccountAssetsByScope.mockResolvedValue({ + [MAINNET_ASSET_ID]: createControllerAsset({ id: MAINNET_ASSET_ID }), + [USDC_ASSET_ID]: createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1.234567', + }), + }); - const assets = await adapter.getAccountAssetsByScope( - Network.Mainnet, - ACCOUNT_ID, - ); + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); - expect(assets).toHaveLength(1); - expect(assets[0]?.assetType).toBe(MAINNET_ASSET_ID); - }, - ); + expect(assets).toHaveLength(2); + expect(assets.map((asset) => asset.assetType).sort()).toStrictEqual( + [MAINNET_ASSET_ID, USDC_ASSET_ID].sort(), + ); + }); }); it('skips null controller assets for the requested scope', async () => { diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts index 2b4f75479..b84b46dc2 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts @@ -1,16 +1,10 @@ import type { Asset } from '@metamask/assets-controller'; import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; -import { findAssociatedTokenPda } from '@solana-program/token'; -import { address as asAddress } from '@solana/kit'; import type { AssetEntity } from '../../../../entities'; -import type { Network } from '../../../constants/solana'; -import { SolanaCaip19Tokens } from '../../../constants/solana'; import type { AccountsService } from '../../accounts/AccountsService'; import type { ConfigProvider } from '../../config'; -import type { SolanaConnection } from '../../connection'; import { mapControllerAsset } from '../utils/mapControllerAsset'; export type CoreAssetsAdapterOptions = { @@ -20,7 +14,6 @@ export type CoreAssetsAdapterOptions = { getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; findAccountById: AccountsService['findById']; getActiveNetworks: ConfigProvider['getActiveNetworks']; - fetchMint: SolanaConnection['fetchMint']; }; /** @@ -31,9 +24,8 @@ export type CoreAssetsAdapterOptions = { * monitor addresses for snap-owned changes. * * AssetsController stores SPL balances by mint, not by associated token - * account. Transaction history looks up signatures by token-account pubkey, - * so this adapter derives each token's ATA from the mint's token program - * (including Token-2022). + * account. Transaction history resolves the token-account address only when + * it needs to query signatures, so reading balances does not fetch mint data. */ export class CoreAssetsAdapter { readonly #logger: Logger; @@ -48,8 +40,6 @@ export class CoreAssetsAdapter { readonly #getActiveNetworks: ConfigProvider['getActiveNetworks']; - readonly #fetchMint: SolanaConnection['fetchMint']; - constructor(options: CoreAssetsAdapterOptions) { const { logger, @@ -58,7 +48,6 @@ export class CoreAssetsAdapter { getAccountAssetsByScope, findAccountById, getActiveNetworks, - fetchMint, } = options; this.#logger = logger.withPrefix('[🪙 CoreAssetsAdapter]'); @@ -67,7 +56,6 @@ export class CoreAssetsAdapter { this.#getAccountAssetsByScope = getAccountAssetsByScope; this.#findAccountById = findAccountById; this.#getActiveNetworks = getActiveNetworks; - this.#fetchMint = fetchMint; } async #resolveAccountAddress(accountId: string): Promise { @@ -76,74 +64,19 @@ export class CoreAssetsAdapter { } /** - * Derives the associated token account address for an SPL mint. - * - * Fetches the mint so Token-2022 assets use the correct token program. - * Returns `null` when derivation fails so callers can skip the asset - * rather than returning a token without a pubkey. - * - * @param asset - Controller asset whose CAIP-19 ID contains the mint. - * @param owner - Account address that owns the token account. - * @returns ATA address, or `null` if derivation fails. - */ - async #deriveAssociatedTokenAccountPubkey( - asset: Asset, - owner: string, - ): Promise { - const { chainId, assetReference: mint } = parseCaipAssetType(asset.id); - const network = chainId as Network; - - try { - const mintAccount = await this.#fetchMint(mint, network); - const [ata] = await findAssociatedTokenPda({ - mint: asAddress(mint), - owner: asAddress(owner), - tokenProgram: mintAccount.programAddress, - }); - return ata; - } catch (error) { - this.#logger.warn('Failed to derive associated token account', { - mint, - network, - owner, - error, - }); - return null; - } - } - - /** - * Maps a controller asset, deriving an ATA pubkey for SPL tokens. + * Maps a controller asset without resolving token-account addresses. * * @param accountId - Keyring account ID. * @param accountAddress - Solana account address (owner). * @param asset - Asset returned by AssetsController. - * @returns Mapped asset, or `null` if the ATA cannot be derived. + * @returns Mapped asset. */ - async #mapAsset( + #mapAsset( accountId: string, accountAddress: string, asset: Asset, - ): Promise { - if (asset.id.endsWith(SolanaCaip19Tokens.SOL)) { - return mapControllerAsset(accountId, accountAddress, asset); - } - - const tokenAccountPubkey = await this.#deriveAssociatedTokenAccountPubkey( - asset, - accountAddress, - ); - - if (!tokenAccountPubkey) { - return null; - } - - return mapControllerAsset( - accountId, - accountAddress, - asset, - tokenAccountPubkey, - ); + ): AssetEntity { + return mapControllerAsset(accountId, accountAddress, asset); } async getAccountAssetByID( @@ -185,15 +118,13 @@ export class CoreAssetsAdapter { const assets = await this.#getAccountAssetsByIDs(accountId, assetIds); - const entries = await Promise.all( - assetIds.map(async (assetId) => { - const asset = assets[assetId]; - return [ - assetId, - asset ? await this.#mapAsset(accountId, accountAddress, asset) : null, - ] as const; - }), - ); + const entries = assetIds.map((assetId) => { + const asset = assets[assetId]; + return [ + assetId, + asset ? this.#mapAsset(accountId, accountAddress, asset) : null, + ] as const; + }); return Object.fromEntries(entries) as Record< CaipAssetType, @@ -220,14 +151,12 @@ export class CoreAssetsAdapter { accountId, ); - const mapped = await Promise.all( - Object.values(controllerAssets).map(async (asset) => { - if (!asset) { - return null; - } - return this.#mapAsset(accountId, accountAddress, asset as Asset); - }), - ); + const mapped = Object.values(controllerAssets).map((asset) => { + if (!asset) { + return null; + } + return this.#mapAsset(accountId, accountAddress, asset as Asset); + }); return mapped.filter((asset): asset is AssetEntity => asset !== null); } diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts index 7c22ff371..2918f621a 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts @@ -86,19 +86,19 @@ describe('mapControllerAsset', () => { }); }); - it('throws when mapping an SPL token without a token account pubkey', () => { + it('maps an SPL token without a token account pubkey', () => { const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { symbol: 'USDC', decimals: 6, }); - expect(() => + expect( mapControllerAsset( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, ), - ).toThrow('Token account pubkey is required to map token asset'); + ).not.toHaveProperty('pubkey'); }); it('uses UNKNOWN and 0 decimals when metadata is missing', () => { diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts index 8b008ca1a..f74089fcd 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts @@ -15,18 +15,17 @@ import { toTokenUnits } from '../../../utils/toTokenUnit'; * * AssetsController returns balances in display format, so the source amount * is used directly as the UI amount and converted to raw token units. The - * mapper does not derive token account addresses; the adapter supplies them. + * mapper does not derive token account addresses. Core-backed assets resolve + * them lazily when transaction history is requested. * * Native SOL uses the account address. SPL tokens use the mint from the - * CAIP-19 ID and require the associated token account pubkey — Core does not - * store ATAs, so the Core assets adapter derives them before calling this - * mapper. + * CAIP-19 ID. Snap-fetched assets may also include their token account pubkey. * * @param accountId - Keyring account ID. * @param accountAddress - Solana account address (owner). * @param asset - Asset returned by AssetsController. - * @param tokenAccountPubkey - Associated token account address. Required for - * SPL tokens; ignored for native SOL. + * @param tokenAccountPubkey - Associated token account address, when already + * known; ignored for native SOL. * @returns Mapped asset entity. */ export function mapControllerAsset( @@ -56,18 +55,12 @@ export function mapControllerAsset( }; } - if (!tokenAccountPubkey) { - throw new Error( - `Token account pubkey is required to map token asset ${assetId}`, - ); - } - return { assetType: assetId as TokenCaipAssetType, keyringAccountId: accountId, network, mint: assetReference, - pubkey: tokenAccountPubkey, + ...(tokenAccountPubkey ? { pubkey: tokenAccountPubkey } : {}), symbol, decimals, rawAmount, diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts index 6f9ea34ce..e798fde14 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.test.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { Transaction } from '@metamask/keyring-api'; +import { findAssociatedTokenPda } from '@solana-program/token'; import { address as asAddress } from '@solana/kit'; -import type { NativeAsset } from 'src/entities'; +import type { NativeAsset, TokenAsset } from '../../../entities'; import { KnownCaip19Id, Network } from '../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0, @@ -12,6 +13,7 @@ import { MOCK_GET_SIGNATURES_FOR_ADDRESS } from '../../test/mocks/transactions'; import { ADDRESS_1_TRANSACTION_1_DATA } from '../../test/mocks/transactions-data/address-1/transaction-1'; import { trackError } from '../../utils/errors'; import { createMockConnection } from '../__mocks__/mockConnection'; +import { MOCK_MINT_ACCOUNT } from '../__mocks__/mockSolanaRpcResponses'; import type { AccountsService } from '../accounts/AccountsService'; import type { AssetsService } from '../assets/AssetsService'; import type { SolanaConnection } from '../connection/SolanaConnection'; @@ -201,6 +203,131 @@ describe('TransactionsService', () => { expect(trackError).toHaveBeenCalledTimes(1); expect(trackError).toHaveBeenCalledWith(error); }); + + it('uses the existing token account pubkey without fetching the mint', async () => { + const asset = { + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + symbol: 'USDC', + decimals: 6, + rawAmount: '1', + uiAmount: '0.000001', + } as TokenAsset; + + jest + .spyOn(mockAccountsService, 'getAll') + .mockResolvedValue([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + jest.spyOn(mockAssetsService, 'getAssetsMetadata').mockResolvedValue({}); + jest.spyOn(mockTransactionsRepository, 'getAll').mockResolvedValue([]); + const getSignaturesForAddress = jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue([]), + }); + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignaturesForAddress, + } as any); + + await service.fetchAssetsTransactions([asset]); + + expect(mockConnection.fetchMint).not.toHaveBeenCalled(); + expect(getSignaturesForAddress).toHaveBeenCalledWith( + asAddress(asset.pubkey!), + { limit: 5 }, + ); + }); + + it('derives a Core token account pubkey when fetching history', async () => { + const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const asset = { + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint, + symbol: 'USDC', + decimals: 6, + rawAmount: '1', + uiAmount: '0.000001', + } as TokenAsset; + + jest + .spyOn(mockAccountsService, 'getAll') + .mockResolvedValue([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + jest.spyOn(mockAssetsService, 'getAssetsMetadata').mockResolvedValue({}); + jest.spyOn(mockTransactionsRepository, 'getAll').mockResolvedValue([]); + const getSignaturesForAddress = jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue([]), + }); + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignaturesForAddress, + } as any); + + await service.fetchAssetsTransactions([asset]); + + const [expectedPubkey] = await findAssociatedTokenPda({ + mint: asAddress(mint), + owner: asAddress(MOCK_SOLANA_KEYRING_ACCOUNT_0.address), + tokenProgram: MOCK_MINT_ACCOUNT.programAddress, + }); + + expect(getSignaturesForAddress).toHaveBeenCalledWith(expectedPubkey, { + limit: 5, + }); + expect(mockConnection.fetchMint).toHaveBeenCalledWith( + mint, + Network.Mainnet, + ); + }); + + it('resolves token account addresses and local history boundaries in parallel', async () => { + const asset = { + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + decimals: 6, + rawAmount: '1', + uiAmount: '0.000001', + } as TokenAsset; + let releaseMint: () => void = () => undefined; + const mintPending = new Promise((resolve) => { + releaseMint = resolve; + }); + let releaseTransactions: () => void = () => undefined; + const transactionsPending = new Promise((resolve) => { + releaseTransactions = resolve; + }); + + jest + .spyOn(mockAccountsService, 'getAll') + .mockResolvedValue([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + jest.spyOn(mockAssetsService, 'getAssetsMetadata').mockResolvedValue({}); + jest + .spyOn(mockTransactionsRepository, 'getAll') + .mockImplementation(async () => { + await transactionsPending; + return []; + }); + jest.spyOn(mockConnection, 'fetchMint').mockImplementation(async () => { + await mintPending; + return MOCK_MINT_ACCOUNT; + }); + jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ + getSignaturesForAddress: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue([]), + }), + } as any); + + const fetchPromise = service.fetchAssetsTransactions([asset]); + await Promise.resolve(); + expect(mockConnection.fetchMint).toHaveBeenCalled(); + expect(mockTransactionsRepository.getAll).toHaveBeenCalled(); + releaseMint(); + releaseTransactions(); + await fetchPromise; + }); }); describe('save', () => { diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts index 6a80ed81e..efcc31cea 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -1,11 +1,12 @@ import { KeyringEvent } from '@metamask/keyring-api'; import type { Transaction } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { findAssociatedTokenPda } from '@solana-program/token'; import type { Address, Commitment, Signature, Slot } from '@solana/kit'; import { address as asAddress, signature as asSignature } from '@solana/kit'; import { get, groupBy } from 'lodash'; -import type { AssetEntity } from '../../../entities'; +import type { AssetEntity, NativeAsset, TokenAsset } from '../../../entities'; import type { SolanaKeyringAccount } from '../../../entities/keyring-account'; import type { Network } from '../../constants/solana'; import type { SolanaTransaction } from '../../types/solana'; @@ -81,18 +82,27 @@ export class TransactionsService { limit?: number; }, ): Promise { - const accounts = await this.#accountsService.getAll(); - const assetTypes = assets.map((asset) => asset.assetType); - const assetsMetadata = - await this.#assetsService.getAssetsMetadata(assetTypes); + // Start all independent requests before awaiting any of them. Core token + // assets can then resolve their token account while the saved history is + // still being loaded. + const accountsPromise = this.#accountsService.getAll(); + const assetsMetadataPromise = + this.#assetsService.getAssetsMetadata(assetTypes); + const savedTransactionsPromise = this.#transactionsRepository.getAll(); + + const accounts = await accountsPromise; - const savedTransactions = await this.#transactionsRepository.getAll(); + const findAccountById = (id: string) => + accounts.find((account) => account.id === id); const findLatestTransactionForAsset = async (asset: AssetEntity) => { + const savedTransactions = await savedTransactionsPromise; const { network } = asset; - const addressOrMint = 'mint' in asset ? asset.mint : asset.address; + const addressOrMint = asset.assetType.endsWith('/slip44:501') + ? (asset as NativeAsset).address + : (asset as TokenAsset).mint; const existingTransaction = savedTransactions .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) @@ -129,17 +139,27 @@ export class TransactionsService { asset: AssetEntity, ): Promise => { const { network } = asset; - const addressOrPubkey = 'pubkey' in asset ? asset.pubkey : asset.address; + const account = findAccountById(asset.keyringAccountId); + + // The address lookup and local history lookup are independent. This is + // especially important for Core assets, where resolving the token + // account may require a mint RPC request. + const [addressOrPubkey, latestTransaction] = await Promise.all([ + this.#resolveAssetAddress(asset, account?.address), + findLatestTransactionForAsset(asset), + ]); - const latestTransaction = await findLatestTransactionForAsset(asset); + if (!addressOrPubkey) { + return []; + } const latestSignature = latestTransaction - ? asSignature(latestTransaction?.id) + ? asSignature(latestTransaction.id) : undefined; const response = await this.#connection .getRpc(network) - .getSignaturesForAddress(asAddress(addressOrPubkey), { + .getSignaturesForAddress(addressOrPubkey, { limit: 5, ...(latestSignature ? { until: latestSignature } : {}), }) @@ -199,8 +219,7 @@ export class TransactionsService { await Promise.all(signaturesToFetch.map(fetchTransaction)) ).filter((item) => item !== null); - const findAccountById = (id: string) => - accounts.find((account) => account.id === id); + const assetsMetadata = await assetsMetadataPromise; const mapTransaction = async ( transactionWithAsset: TransactionWithAsset, @@ -236,6 +255,41 @@ export class TransactionsService { return mappedTransactions; } + async #resolveAssetAddress( + asset: AssetEntity, + accountAddress?: string, + ): Promise
{ + if (asset.assetType.endsWith('/slip44:501')) { + return asAddress((asset as NativeAsset).address); + } + + const tokenAsset = asset as TokenAsset; + if (tokenAsset.pubkey) { + return asAddress(tokenAsset.pubkey); + } + + if (!accountAddress) { + return null; + } + + try { + const mintAccount = await this.#connection.fetchMint( + tokenAsset.mint, + asset.network, + ); + const [associatedTokenAccount] = await findAssociatedTokenPda({ + mint: asAddress(tokenAsset.mint), + owner: asAddress(accountAddress), + tokenProgram: mintAccount.programAddress, + }); + + return associatedTokenAccount; + } catch (error) { + await trackError(error); + return null; + } + } + async fetchLatestSignatures( scope: Network, address: Address, diff --git a/packages/solana-wallet-snap/src/entities/assets.ts b/packages/solana-wallet-snap/src/entities/assets.ts index 06883eaff..7f6157864 100644 --- a/packages/solana-wallet-snap/src/entities/assets.ts +++ b/packages/solana-wallet-snap/src/entities/assets.ts @@ -21,7 +21,8 @@ export type TokenAsset = { keyringAccountId: string; network: Network; mint: string; - pubkey: string; + /** The token account address, when it was obtained from an RPC response. */ + pubkey?: string; symbol: string; decimals: number; rawAmount: string; // Without decimals nor multiplier diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 73b892c90..1c6377600 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -6,8 +6,8 @@ import type { AssetsProviderMessenger, RemoteFeatureFlagsProviderMessenger, } from '@metamask/snap-networks-utils'; -import { getMessenger } from '@metamask/snaps-sdk'; import type { Serializable } from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; import type { ICache } from './core/caching/ICache'; import { InMemoryCache } from './core/caching/InMemoryCache'; @@ -200,7 +200,6 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), getAccountAssetsByScope: assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - fetchMint: connection.fetchMint.bind(connection), findAccountById: accountsService.findById.bind(accountsService), getActiveNetworks: configProvider.getActiveNetworks.bind(configProvider), });