From 5d794f180f0b0d50d039e77d3b136a2e53319f6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:38:58 +0000 Subject: [PATCH 01/11] feat(solana-wallet-snap): add CoreAssetsAdapter and mapControllerAsset Introduce CoreAssetsAdapter with AssetsController reads and snap-owned publish helpers, plus mapControllerAsset / isSnapOwnedAsset. Wire Core messenger plumbing and store the adapter on AssetsService unused pending routing. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 4 + packages/solana-wallet-snap/package.json | 3 + .../solana-wallet-snap/snap.manifest.json | 10 +- .../services/assets/AssetsService.test.ts | 12 + .../src/core/services/assets/AssetsService.ts | 17 +- .../assets/adapters/CoreAssetsAdapter.test.ts | 430 ++++++++++++++++++ .../assets/adapters/CoreAssetsAdapter.ts | 242 ++++++++++ .../src/core/services/assets/index.ts | 1 + .../assets/utils/isSnapOwnedAsset.test.ts | 20 + .../services/assets/utils/isSnapOwnedAsset.ts | 13 + .../assets/utils/mapControllerAsset.test.ts | 111 +++++ .../assets/utils/mapControllerAsset.ts | 73 +++ .../solana-wallet-snap/src/snapContext.ts | 49 ++ .../src/types/core-messenger.ts | 39 ++ yarn.lock | 3 + 15 files changed, 1024 insertions(+), 3 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts create mode 100644 packages/solana-wallet-snap/src/types/core-messenger.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index ab6dd1dbf..204e08d34 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] +### Added + +- Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands), including Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`). ([#122](https://github.com/MetaMask/internal-snaps/pull/122)) + ### Changed - Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads through the adapter (no Core routing yet). ([#121](https://github.com/MetaMask/internal-snaps/pull/121)) diff --git a/packages/solana-wallet-snap/package.json b/packages/solana-wallet-snap/package.json index 5a3f22cbd..b09158874 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -52,10 +52,13 @@ }, "devDependencies": { "@jest/globals": "^29.5.0", + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/messenger": "^2.0.0", + "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/snap-networks-utils": "^1.0.0", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 67d45a9d0..28ac38d5f 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -88,7 +88,15 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:messenger": { + "actions": [ + "RemoteFeatureFlagController:getState", + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope" + ] + } }, "platformVersion": "11.2.0", "manifestVersion": "0.1" 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 7ddfc10c0..68915a71d 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 @@ -24,6 +24,7 @@ import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; +import { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import { AssetsService } from './AssetsService'; @@ -101,8 +102,19 @@ describe('AssetsService', () => { nftApiClient: mockNftApiClient, }); + const coreAdapter = new CoreAssetsAdapter({ + getAccountAssetByID: jest.fn().mockResolvedValue(null), + getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + findAccountById: mockAccountsService.findById.bind(mockAccountsService), + getActiveNetworks: mockConfigProvider.getActiveNetworks.bind( + mockConfigProvider, + ), + }); + assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, + coreAdapter, }); }); 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 5a1faabf3..af43b6f0b 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -3,20 +3,33 @@ import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; +import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; /** * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). + * (legacy snap-owned reads/writes). Core adapter is initialized for upcoming + * routing without changing callers. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; + // Initialized for upcoming Core routing; not read until the migration PR lands. + // eslint-disable-next-line no-unused-private-class-members -- reserved adapter slot + readonly #coreAdapter: CoreAssetsAdapter; + readonly cacheTtlsMilliseconds: typeof SnapAssetsAdapter.cacheTtlsMilliseconds; - constructor({ snapAdapter }: { snapAdapter: SnapAssetsAdapter }) { + constructor({ + snapAdapter, + coreAdapter, + }: { + snapAdapter: SnapAssetsAdapter; + coreAdapter: CoreAssetsAdapter; + }) { this.#snapAdapter = snapAdapter; + this.#coreAdapter = coreAdapter; this.cacheTtlsMilliseconds = SnapAssetsAdapter.cacheTtlsMilliseconds; } 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 new file mode 100644 index 000000000..933a0b083 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts @@ -0,0 +1,430 @@ +import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; + +import type { AssetEntity, NftAsset } from '../../../../entities'; +import { KnownCaip19Id, Network } from '../../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; +import { CoreAssetsAdapter } from './CoreAssetsAdapter'; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +(globalThis as { snap?: unknown }).snap = {}; + +const ACCOUNT_ID = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; +const MAINNET_ASSET_ID = KnownCaip19Id.SolMainnet as Caip19AssetId; +const USDC_ASSET_ID = KnownCaip19Id.UsdcMainnet as Caip19AssetId; +const NFT_ASSET_ID = `${Network.Mainnet}/nft:NftMintAddress`; + +/** + * Builds a controller asset for adapter mapping tests. + * + * @param options - Fields to set on the controller asset. + * @param options.id - CAIP-19 asset ID. + * @param options.chainId - Chain ID. Defaults to Mainnet. + * @param options.amount - Raw balance amount. + * @param options.symbol - Asset symbol. + * @param options.decimals - Asset decimals. + * @returns A controller `Asset`. + */ +function createControllerAsset(options: { + id: Caip19AssetId; + chainId?: Network; + amount?: string; + symbol?: string; + decimals?: number; +}): Asset { + const { + id, + chainId = Network.Mainnet, + amount = '1000000000', + symbol = 'SOL', + decimals = 9, + } = options; + + return { + id, + chainId, + balance: { amount }, + metadata: { + type: 'fungible', + symbol, + name: symbol, + decimals, + }, + price: { + assetPriceType: 'fungible', + price: 0, + lastUpdated: 0, + usdPrice: 0, + }, + fiatValue: 0, + } as Asset; +} + +/** + * Builds a snap-owned NFT asset entity for `saveMany` tests. + * + * @param overrides - Fields to override on the asset entity. + * @returns An `NftAsset`. + */ +function createNftAsset(overrides: Partial = {}): NftAsset { + return { + assetType: NFT_ASSET_ID as NftAsset['assetType'], + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + mint: 'NftMintAddress', + pubkey: 'NftTokenAccount', + symbol: 'NFT', + rawAmount: '1', + uiAmount: '1', + ...overrides, + }; +} + +/** + * Builds a fresh CoreAssetsAdapter and the mocks it is constructed with. + * + * @returns The adapter and its mock dependencies. + */ +function createCoreAssetsAdapterContext(): { + adapter: CoreAssetsAdapter; + mockAssetsProvider: jest.Mocked< + Pick< + AssetsProvider, + | 'getAccountAssetByID' + | 'getAccountAssetsByIDs' + | 'getAccountAssetsByScope' + > + >; + mockFindAccountById: jest.Mock; + mockGetActiveNetworks: jest.Mock; +} { + const mockAssetsProvider = { + getAccountAssetByID: jest.fn().mockResolvedValue(undefined), + getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + }; + + const mockFindAccountById = jest + .fn() + .mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0); + const mockGetActiveNetworks = jest.fn().mockResolvedValue([Network.Mainnet]); + + const adapter = new CoreAssetsAdapter({ + getAccountAssetByID: mockAssetsProvider.getAccountAssetByID, + getAccountAssetsByIDs: mockAssetsProvider.getAccountAssetsByIDs, + getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, + findAccountById: mockFindAccountById, + getActiveNetworks: mockGetActiveNetworks, + }); + + return { + adapter, + mockAssetsProvider, + mockFindAccountById, + mockGetActiveNetworks, + }; +} + +/** + * Wraps CoreAssetsAdapter tests with a fresh adapter and mocks. + * + * @param testFunction - The test body. + * @returns The return value of the callback. + */ +async function withCoreAssetsAdapter( + testFunction: ( + payload: ReturnType, + ) => Promise | ReturnValue, +): Promise { + return await testFunction(createCoreAssetsAdapterContext()); +} + +describe('CoreAssetsAdapter', () => { + describe('getAccountAssetByID', () => { + it('maps a controller asset to an AssetEntity', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + 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(asset).toStrictEqual({ + assetType: MAINNET_ASSET_ID, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + }); + }); + + it('returns null when the controller has no matching asset', async () => { + await withCoreAssetsAdapter(async ({ adapter }) => { + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); + + expect(asset).toBeNull(); + }); + }); + + it('returns null when the account is missing', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockFindAccountById, mockAssetsProvider }) => { + mockFindAccountById.mockResolvedValue(null); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); + + expect(asset).toBeNull(); + expect(mockAssetsProvider.getAccountAssetByID).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('returns mapped assets keyed by ID and null for missing IDs', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const mainnetAsset = createControllerAsset({ id: MAINNET_ASSET_ID }); + mockAssetsProvider.getAccountAssetsByIDs.mockResolvedValue({ + [MAINNET_ASSET_ID]: mainnetAsset, + }); + + const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, [ + MAINNET_ASSET_ID, + USDC_ASSET_ID, + ]); + + expect(mockAssetsProvider.getAccountAssetsByIDs).toHaveBeenCalledWith( + ACCOUNT_ID, + [MAINNET_ASSET_ID, USDC_ASSET_ID], + ); + expect(assets[MAINNET_ASSET_ID]?.assetType).toBe(MAINNET_ASSET_ID); + expect(assets[USDC_ASSET_ID]).toBeNull(); + }); + }); + + it('returns an empty record for an empty ID list', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, []); + + expect(assets).toStrictEqual({}); + expect( + mockAssetsProvider.getAccountAssetsByIDs, + ).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('maps every controller asset for the requested scope', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const mainnetAsset = createControllerAsset({ id: MAINNET_ASSET_ID }); + const usdcAsset = createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1234567', + }); + mockAssetsProvider.getAccountAssetsByScope.mockResolvedValue({ + [MAINNET_ASSET_ID]: mainnetAsset, + [USDC_ASSET_ID]: usdcAsset, + }); + + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); + + expect(mockAssetsProvider.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + ACCOUNT_ID, + ); + expect(assets.map((asset) => asset.assetType).sort()).toStrictEqual( + [MAINNET_ASSET_ID, USDC_ASSET_ID].sort(), + ); + expect( + assets.every((asset) => asset.keyringAccountId === ACCOUNT_ID), + ).toBe(true); + }); + }); + + it('returns an empty list when the account is missing', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockFindAccountById, mockAssetsProvider }) => { + mockFindAccountById.mockResolvedValue(null); + + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); + + expect(assets).toStrictEqual([]); + expect( + mockAssetsProvider.getAccountAssetsByScope, + ).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('getAccountAssets', () => { + it('concatenates mapped assets from each active network', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockGetActiveNetworks }) => { + mockGetActiveNetworks.mockResolvedValue([ + Network.Mainnet, + Network.Devnet, + ]); + const mainnetAsset = createControllerAsset({ id: MAINNET_ASSET_ID }); + const devnetAsset = createControllerAsset({ + id: KnownCaip19Id.SolDevnet as Caip19AssetId, + chainId: Network.Devnet, + }); + mockAssetsProvider.getAccountAssetsByScope.mockImplementation( + async (scope) => { + if (scope === Network.Mainnet) { + return { [MAINNET_ASSET_ID]: mainnetAsset }; + } + if (scope === Network.Devnet) { + return { + [KnownCaip19Id.SolDevnet as Caip19AssetId]: devnetAsset, + }; + } + return {}; + }, + ); + + const assets = await adapter.getAccountAssets(ACCOUNT_ID); + + expect( + mockAssetsProvider.getAccountAssetsByScope, + ).toHaveBeenCalledTimes(2); + expect(assets.map((asset) => asset.assetType)).toStrictEqual([ + MAINNET_ASSET_ID, + KnownCaip19Id.SolDevnet, + ]); + }, + ); + }); + + it('rejects when any scope request fails', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockGetActiveNetworks }) => { + mockGetActiveNetworks.mockResolvedValue([ + Network.Mainnet, + Network.Devnet, + ]); + mockAssetsProvider.getAccountAssetsByScope.mockImplementation( + async (scope) => { + if (scope === Network.Devnet) { + throw new Error('devnet failed'); + } + return {}; + }, + ); + + await expect(adapter.getAccountAssets(ACCOUNT_ID)).rejects.toThrow( + 'devnet failed', + ); + }, + ); + }); + }); + + describe('fetch', () => { + it('returns no assets because snap-owned NFT fetch is not produced', async () => { + await withCoreAssetsAdapter(async ({ adapter }) => { + const assets = await adapter.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(assets).toStrictEqual([]); + }); + }); + }); + + describe('saveMany', () => { + it('does nothing when there are no snap-owned assets', async () => { + await withCoreAssetsAdapter(async ({ adapter }) => { + await adapter.saveMany([ + { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }, + ]); + + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }); + }); + + it('publishes only snap-owned assets as added with balance updates', async () => { + await withCoreAssetsAdapter(async ({ adapter }) => { + const fungibleAsset: AssetEntity = { + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }; + + await adapter.saveMany([fungibleAsset, createNftAsset()]); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [ACCOUNT_ID]: { + added: [NFT_ASSET_ID], + removed: [], + }, + }, + }, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [ACCOUNT_ID]: { + [NFT_ASSET_ID]: { + unit: 'NFT', + amount: '1', + }, + }, + }, + }, + ); + }); + }); + }); +}); 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 new file mode 100644 index 000000000..f70b3670b --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts @@ -0,0 +1,242 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import type { AssetEntity, SolanaKeyringAccount } from '../../../../entities'; +import logger, { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import type { AccountsService } from '../../accounts/AccountsService'; +import type { ConfigProvider } from '../../config'; +import { isSnapOwnedAsset } from '../utils/isSnapOwnedAsset'; +import { mapControllerAsset } from '../utils/mapControllerAsset'; + +export type CoreAssetsAdapterOptions = { + getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + findAccountById: AccountsService['findById']; + getActiveNetworks: ConfigProvider['getActiveNetworks']; +}; + +/** + * Uses the AssetsController for fungible reads. Snap-owned (NFT) assets are + * published via keyring events without local persistence when migration is active. + */ +export class CoreAssetsAdapter { + readonly #logger: ILogger; + + readonly #getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + + readonly #getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + + readonly #getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + + readonly #findAccountById: AccountsService['findById']; + + readonly #getActiveNetworks: ConfigProvider['getActiveNetworks']; + + constructor(options: CoreAssetsAdapterOptions) { + const { + getAccountAssetByID, + getAccountAssetsByIDs, + getAccountAssetsByScope, + findAccountById, + getActiveNetworks, + } = options; + + this.#logger = createPrefixedLogger(logger, '[🪙 CoreAssetsAdapter]'); + this.#getAccountAssetByID = getAccountAssetByID; + this.#getAccountAssetsByIDs = getAccountAssetsByIDs; + this.#getAccountAssetsByScope = getAccountAssetsByScope; + this.#findAccountById = findAccountById; + this.#getActiveNetworks = getActiveNetworks; + } + + async #resolveAccountAddress(accountId: string): Promise { + const account = await this.#findAccountById(accountId); + return account?.address ?? null; + } + + async getAccountAssetByID( + accountId: string, + assetId: CaipAssetType, + ): Promise { + this.#logger.info('Getting account asset by ID', { accountId, assetId }); + + const accountAddress = await this.#resolveAccountAddress(accountId); + if (!accountAddress) { + return null; + } + + const asset = await this.#getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, accountAddress, asset); + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: CaipAssetType[], + ): Promise> { + this.#logger.info('Getting account assets by IDs', { accountId, assetIds }); + + if (assetIds.length === 0) { + return {} as Record; + } + + const accountAddress = await this.#resolveAccountAddress(accountId); + if (!accountAddress) { + return Object.fromEntries( + assetIds.map((assetId) => [assetId, null]), + ) as Record; + } + + const assets = await this.#getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + const entries = await Promise.all( + assetIds.map(async (assetId) => { + const asset = assets[assetId as Caip19AssetId]; + if (!asset) { + return [assetId, null] as const; + } + + const entity = await mapControllerAsset( + accountId, + accountAddress, + asset, + ); + return [assetId, entity] as const; + }), + ); + + return Object.fromEntries(entries) as Record< + CaipAssetType, + AssetEntity | null + >; + } + + async getAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + ): Promise { + this.#logger.info('Getting account assets by scope', { + scope, + accountId, + }); + + const accountAddress = await this.#resolveAccountAddress(accountId); + if (!accountAddress) { + return []; + } + + const controllerAssets = await this.#getAccountAssetsByScope( + scope, + accountId, + ); + + return Promise.all( + Object.values(controllerAssets).map(async (asset) => + mapControllerAsset(accountId, accountAddress, asset), + ), + ); + } + + async getAccountAssets(accountId: string): Promise { + const activeNetworks = await this.#getActiveNetworks(); + const assetsByScope = await Promise.all( + activeNetworks.map(async (scope) => + this.getAccountAssetsByScope(scope, accountId), + ), + ); + + return assetsByScope.flat(); + } + + /** + * Fungible balances come from AssetsController once migration is active. + * Snap-owned NFT fetch is not produced here (matching the Snap adapter, + * which currently does not return NFT balances from `fetch`). + * + * @param account - The keyring account. + * @returns Snap-owned assets for the account (currently none). + */ + async fetch(account: SolanaKeyringAccount): Promise { + this.#logger.info('Fetching snap-owned assets for account', { account }); + return []; + } + + /** + * Publishes snap-owned assets to the extension without persisting locally. + * + * Filters to snap-owned assets, reports each as `added`, and emits balance + * updates for those assets. + * + * @param assets - Assets to publish (non snap-owned entries are ignored). + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Publishing snap-owned assets', assets); + + const snapOwnedAssets = assets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + + if (snapOwnedAssets.length === 0) { + return; + } + + const assetListUpdatedPayload = snapOwnedAssets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [ + ...(acc[asset.keyringAccountId]?.added ?? []), + asset.assetType, + ], + removed: [], + }, + }), + {}, + ); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + + const balancesUpdatedPayload = snapOwnedAssets.reduce< + AccountBalancesUpdatedEvent['params']['balances'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } +} diff --git a/packages/solana-wallet-snap/src/core/services/assets/index.ts b/packages/solana-wallet-snap/src/core/services/assets/index.ts index 494c206ba..533f3d697 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/index.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/index.ts @@ -1,3 +1,4 @@ +export * from './adapters/CoreAssetsAdapter'; export * from './adapters/SnapAssetsAdapter'; export * from './AssetsRepository'; export * from './AssetsService'; diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts new file mode 100644 index 000000000..c7cdd9b1d --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts @@ -0,0 +1,20 @@ +import { KnownCaip19Id, Network } from '../../../constants/solana'; +import { isSnapOwnedAsset } from './isSnapOwnedAsset'; + +describe('isSnapOwnedAsset', () => { + it('returns true for NFT asset IDs', () => { + expect( + isSnapOwnedAsset(`${Network.Mainnet}/nft:SomeNftMintAddress`), + ).toBe(true); + }); + + it('returns false for native SOL', () => { + expect(isSnapOwnedAsset(KnownCaip19Id.SolMainnet)).toBe(false); + expect(isSnapOwnedAsset(KnownCaip19Id.SolDevnet)).toBe(false); + }); + + it('returns false for SPL tokens', () => { + expect(isSnapOwnedAsset(KnownCaip19Id.UsdcMainnet)).toBe(false); + expect(isSnapOwnedAsset(KnownCaip19Id.Ai16zMainnet)).toBe(false); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts new file mode 100644 index 000000000..872deb885 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts @@ -0,0 +1,13 @@ +/** + * Returns whether an asset remains exclusively managed by the Snap. + * + * AssetsController does not persist Solana NFT balances. NFT assets must always + * be read, synchronized, persisted, and published by the Snap, regardless of + * the assets migration stage. + * + * @param assetId - CAIP-19 asset ID. + * @returns Whether the asset is exclusively managed by the Snap. + */ +export function isSnapOwnedAsset(assetId: string): boolean { + return assetId.includes('/nft:'); +} 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 new file mode 100644 index 000000000..9eb2207fc --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts @@ -0,0 +1,111 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { KnownCaip19Id, Network } from '../../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; +import { mapControllerAsset } from './mapControllerAsset'; + +/** + * Builds a controller asset for mapping tests. + * + * @param assetId - CAIP-19 asset ID. + * @param amount - Raw balance amount. + * @param metadata - Symbol and decimals. + * @returns A controller `Asset`. + */ +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { symbol: string; decimals: number }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.symbol, + decimals: metadata.decimals, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + +describe('mapControllerAsset', () => { + it('maps native SOL assets', async () => { + const asset = buildControllerAsset(KnownCaip19Id.SolMainnet, '1000000000', { + symbol: 'SOL', + decimals: 9, + }); + + const entity = await mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + ); + + expect(entity).toStrictEqual({ + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + }); + + it('maps SPL token assets with ATA pubkey', async () => { + const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { + symbol: 'USDC', + decimals: 6, + }); + + const entity = await mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + ); + + expect(entity).toMatchObject({ + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + }); + expect(entity).toHaveProperty('pubkey'); + expect(typeof (entity as { pubkey?: string }).pubkey).toBe('string'); + }); + + it('uses UNKNOWN and 0 decimals when metadata is missing', async () => { + const assetId = `${Network.Mainnet}/token:UnknownMint`; + const asset = { + id: assetId, + chainId: Network.Mainnet, + balance: { amount: '42' }, + metadata: { type: 'fungible', name: 'Missing' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + const entity = await mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + ); + + expect(entity).toMatchObject({ + assetType: assetId, + symbol: 'UNKNOWN', + decimals: 0, + rawAmount: '42', + uiAmount: '42', + }); + }); +}); 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 new file mode 100644 index 000000000..1aaa0d7de --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts @@ -0,0 +1,73 @@ +import type { Asset } from '@metamask/assets-controller'; +import { parseCaipAssetType } from '@metamask/utils'; +import { + findAssociatedTokenPda, + TOKEN_PROGRAM_ADDRESS, +} from '@solana-program/token'; +import { address as asAddress } from '@solana/kit'; + +import type { AssetEntity } from '../../../../entities'; +import type { + NativeCaipAssetType, + Network, + TokenCaipAssetType, +} from '../../../constants/solana'; +import { SolanaCaip19Tokens } from '../../../constants/solana'; +import { fromTokenUnits } from '../../../utils/fromTokenUnit'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * Native SOL uses the account address. SPL tokens resolve the associated token + * account (ATA) pubkey so Send and other callers keep a TokenAsset. + * + * @param accountId - Keyring account ID. + * @param accountAddress - Solana account address (owner). + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export async function mapControllerAsset( + accountId: string, + accountAddress: string, + asset: Asset, +): Promise { + const assetId = asset.id; + const { chainId, assetReference } = parseCaipAssetType(assetId); + const decimals = asset.metadata.decimals ?? 0; + const symbol = asset.metadata.symbol ?? 'UNKNOWN'; + const rawAmount = asset.balance.amount; + const uiAmount = fromTokenUnits(rawAmount, decimals); + const network = chainId as Network; + + if (assetId.endsWith(SolanaCaip19Tokens.SOL)) { + return { + assetType: assetId as NativeCaipAssetType, + keyringAccountId: accountId, + network, + address: accountAddress, + symbol, + decimals, + rawAmount, + uiAmount, + }; + } + + const mint = assetReference; + const [pubkey] = await findAssociatedTokenPda({ + mint: asAddress(mint), + owner: asAddress(accountAddress), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + return { + assetType: assetId as TokenCaipAssetType, + keyringAccountId: accountId, + network, + mint, + pubkey, + symbol, + decimals, + rawAmount, + uiAmount, + }; +} diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 15f7b5cf0..46c188531 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,3 +1,13 @@ +import { + AssetsProvider, + RemoteFeatureFlagsProvider, +} from '@metamask/snap-networks-utils'; +import type { + AssetsProviderMessenger, + RemoteFeatureFlagsProviderMessenger, +} from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; + import type { ICache } from './core/caching/ICache'; import { InMemoryCache } from './core/caching/InMemoryCache'; import { StateCache } from './core/caching/StateCache'; @@ -13,6 +23,7 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, + CoreAssetsAdapter, SnapAssetsAdapter, AssetsRepository, AssetsService, @@ -47,6 +58,10 @@ import { TransactionScanService } from './core/services/transaction-scan/Transac import { WalletService } from './core/services/wallet/WalletService'; import logger, { noOpLogger } from './core/utils/logger'; import { EventEmitter } from './infrastructure'; +import type { + CoreMessenger, + CoreMessengerClient, +} from './types/core-messenger'; /** * Initializes all the services using dependency injection. @@ -78,6 +93,12 @@ export type SnapExecutionContext = { accountsService: AccountsService; accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; + /** + * Core messenger plumbing (routing wired in a follow-up PR). + */ + coreMessenger: CoreMessengerClient; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + assetsProvider: AssetsProvider; }; const configProvider = new ConfigProvider(); @@ -161,8 +182,30 @@ const snapAssetsAdapter = new SnapAssetsAdapter({ nftApiClient, }); +/** + * Core controllers plumbing + */ +const coreMessenger = getMessenger(); +const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, +}); +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); + +const coreAssetsAdapter = new CoreAssetsAdapter({ + getAccountAssetByID: assetsProvider.getAccountAssetByID.bind(assetsProvider), + getAccountAssetsByIDs: + assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), + getAccountAssetsByScope: + assetsProvider.getAccountAssetsByScope.bind(assetsProvider), + findAccountById: accountsService.findById.bind(accountsService), + getActiveNetworks: configProvider.getActiveNetworks.bind(configProvider), +}); + const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, + coreAdapter: coreAssetsAdapter, }); const transactionsRepository = new TransactionsRepository(state); @@ -294,22 +337,28 @@ const snapContext: SnapExecutionContext = { accountsService, accountsSynchronizer, tokenHelper, + coreMessenger, + remoteFeatureFlagsProvider, + assetsProvider, }; export { accountsService, accountsSynchronizer, analyticsService, + assetsProvider, assetsService, clientRequestHandler, configProvider, confirmationHandler, connection, + coreMessenger, eventEmitter, keyring, nameResolutionService, nftService, priceApiClient, + remoteFeatureFlagsProvider, sendSolBuilder, sendSplTokenBuilder, signer, diff --git a/packages/solana-wallet-snap/src/types/core-messenger.ts b/packages/solana-wallet-snap/src/types/core-messenger.ts new file mode 100644 index 000000000..92773cc43 --- /dev/null +++ b/packages/solana-wallet-snap/src/types/core-messenger.ts @@ -0,0 +1,39 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE = + 'SolanaWalletSnap' as const; + +export type CoreMessengerActions = + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessenger = Messenger< + typeof SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; + +/** + * Async messenger returned by `getMessenger` for Core controller actions + * available to this Snap via `endowment:messenger`. + */ +export type CoreMessengerClient = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; diff --git a/yarn.lock b/yarn.lock index e50cb4896..7acfa320b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3667,10 +3667,13 @@ __metadata: resolution: "@metamask/solana-wallet-snap@workspace:packages/solana-wallet-snap" dependencies: "@jest/globals": "npm:^29.5.0" + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^23.7.0" "@metamask/keyring-snap-sdk": "npm:^9.2.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snap-networks-utils": "npm:^1.0.0" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.0" From 1df9bfbeb6c6efce09a15ef18e8fbec1828846c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:46:34 +0000 Subject: [PATCH 02/11] fix(solana-wallet-snap): lint CoreAssetsAdapter mapping tests and sync manifest shasum Co-authored-by: Ulisses Ferreira --- .../src/core/services/assets/AssetsService.test.ts | 5 ++--- .../assets/adapters/CoreAssetsAdapter.test.ts | 4 +--- .../services/assets/adapters/CoreAssetsAdapter.ts | 13 +++---------- .../services/assets/utils/isSnapOwnedAsset.test.ts | 6 +++--- .../assets/utils/mapControllerAsset.test.ts | 4 +++- 5 files changed, 12 insertions(+), 20 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 68915a71d..c9a0e85d2 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 @@ -107,9 +107,8 @@ describe('AssetsService', () => { getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.fn().mockResolvedValue({}), findAccountById: mockAccountsService.findById.bind(mockAccountsService), - getActiveNetworks: mockConfigProvider.getActiveNetworks.bind( - mockConfigProvider, - ), + getActiveNetworks: + mockConfigProvider.getActiveNetworks.bind(mockConfigProvider), }); 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 933a0b083..0f1396142 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 @@ -230,9 +230,7 @@ describe('CoreAssetsAdapter', () => { const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, []); expect(assets).toStrictEqual({}); - expect( - mockAssetsProvider.getAccountAssetsByIDs, - ).not.toHaveBeenCalled(); + expect(mockAssetsProvider.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); }); }); 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 f70b3670b..a9d460b4f 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,4 +1,3 @@ -import type { Caip19AssetId } from '@metamask/assets-controller'; import { KeyringEvent } from '@metamask/keyring-api'; import type { AccountAssetListUpdatedEvent, @@ -74,10 +73,7 @@ export class CoreAssetsAdapter { return null; } - const asset = await this.#getAccountAssetByID( - accountId, - assetId as Caip19AssetId, - ); + const asset = await this.#getAccountAssetByID(accountId, assetId); if (!asset) { return null; @@ -103,14 +99,11 @@ export class CoreAssetsAdapter { ) as Record; } - const assets = await this.#getAccountAssetsByIDs( - accountId, - assetIds as Caip19AssetId[], - ); + const assets = await this.#getAccountAssetsByIDs(accountId, assetIds); const entries = await Promise.all( assetIds.map(async (assetId) => { - const asset = assets[assetId as Caip19AssetId]; + const asset = assets[assetId]; if (!asset) { return [assetId, null] as const; } diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts index c7cdd9b1d..4967c7c20 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts @@ -3,9 +3,9 @@ import { isSnapOwnedAsset } from './isSnapOwnedAsset'; describe('isSnapOwnedAsset', () => { it('returns true for NFT asset IDs', () => { - expect( - isSnapOwnedAsset(`${Network.Mainnet}/nft:SomeNftMintAddress`), - ).toBe(true); + expect(isSnapOwnedAsset(`${Network.Mainnet}/nft:SomeNftMintAddress`)).toBe( + true, + ); }); it('returns false for native SOL', () => { 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 9eb2207fc..26ae0a0b7 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 @@ -10,6 +10,8 @@ import { mapControllerAsset } from './mapControllerAsset'; * @param assetId - CAIP-19 asset ID. * @param amount - Raw balance amount. * @param metadata - Symbol and decimals. + * @param metadata.symbol - Asset symbol. + * @param metadata.decimals - Asset decimals. * @returns A controller `Asset`. */ function buildControllerAsset( @@ -84,7 +86,7 @@ describe('mapControllerAsset', () => { }); it('uses UNKNOWN and 0 decimals when metadata is missing', async () => { - const assetId = `${Network.Mainnet}/token:UnknownMint`; + const assetId = KnownCaip19Id.UsdcMainnet; const asset = { id: assetId, chainId: Network.Mainnet, From 5810d3c7a9eec3e09394385abd463af211ce1f33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:10:29 +0000 Subject: [PATCH 03/11] refactor(solana-wallet-snap): drop snap-owned Core paths and ATA mapping Solana has no snap-owned assets, so CoreAssetsAdapter is read-only (no fetch/saveMany/NFT publishing) and mapControllerAsset no longer derives associated token account addresses. TokenAsset.pubkey is optional for Core-mapped balances. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- .../assets/adapters/CoreAssetsAdapter.test.ts | 107 +--------------- .../assets/adapters/CoreAssetsAdapter.ts | 120 +++--------------- .../assets/utils/isSnapOwnedAsset.test.ts | 20 --- .../services/assets/utils/isSnapOwnedAsset.ts | 13 -- .../assets/utils/mapControllerAsset.test.ts | 17 ++- .../assets/utils/mapControllerAsset.ts | 26 ++-- .../transactions/TransactionsService.ts | 3 +- .../solana-wallet-snap/src/entities/assets.ts | 7 +- 9 files changed, 42 insertions(+), 273 deletions(-) delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 204e08d34..5215762bb 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands), including Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`). ([#122](https://github.com/MetaMask/internal-snaps/pull/122)) +- Add a read-only `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands), including Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`). Solana has no snap-owned assets, so the adapter does not fetch, persist, or publish balances, and the mapper does not derive associated token account addresses. ([#122](https://github.com/MetaMask/internal-snaps/pull/122)) ### Changed 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 0f1396142..3f26fc853 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,23 +1,13 @@ import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; -import { KeyringEvent } from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; -import type { AssetEntity, NftAsset } from '../../../../entities'; import { KnownCaip19Id, Network } from '../../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; import { CoreAssetsAdapter } from './CoreAssetsAdapter'; -jest.mock('@metamask/keyring-snap-sdk', () => ({ - emitSnapKeyringEvent: jest.fn(), -})); - -(globalThis as { snap?: unknown }).snap = {}; - const ACCOUNT_ID = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; const MAINNET_ASSET_ID = KnownCaip19Id.SolMainnet as Caip19AssetId; const USDC_ASSET_ID = KnownCaip19Id.UsdcMainnet as Caip19AssetId; -const NFT_ASSET_ID = `${Network.Mainnet}/nft:NftMintAddress`; /** * Builds a controller asset for adapter mapping tests. @@ -65,26 +55,6 @@ function createControllerAsset(options: { } as Asset; } -/** - * Builds a snap-owned NFT asset entity for `saveMany` tests. - * - * @param overrides - Fields to override on the asset entity. - * @returns An `NftAsset`. - */ -function createNftAsset(overrides: Partial = {}): NftAsset { - return { - assetType: NFT_ASSET_ID as NftAsset['assetType'], - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - mint: 'NftMintAddress', - pubkey: 'NftTokenAccount', - symbol: 'NFT', - rawAmount: '1', - uiAmount: '1', - ...overrides, - }; -} - /** * Builds a fresh CoreAssetsAdapter and the mocks it is constructed with. * @@ -265,6 +235,8 @@ describe('CoreAssetsAdapter', () => { expect( assets.every((asset) => asset.keyringAccountId === ACCOUNT_ID), ).toBe(true); + const usdc = assets.find((asset) => asset.assetType === USDC_ASSET_ID); + expect(usdc).not.toHaveProperty('pubkey'); }); }); @@ -350,79 +322,4 @@ describe('CoreAssetsAdapter', () => { ); }); }); - - describe('fetch', () => { - it('returns no assets because snap-owned NFT fetch is not produced', async () => { - await withCoreAssetsAdapter(async ({ adapter }) => { - const assets = await adapter.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - - expect(assets).toStrictEqual([]); - }); - }); - }); - - describe('saveMany', () => { - it('does nothing when there are no snap-owned assets', async () => { - await withCoreAssetsAdapter(async ({ adapter }) => { - await adapter.saveMany([ - { - assetType: KnownCaip19Id.SolMainnet, - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '1000000000', - uiAmount: '1', - }, - ]); - - expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); - }); - }); - - it('publishes only snap-owned assets as added with balance updates', async () => { - await withCoreAssetsAdapter(async ({ adapter }) => { - const fungibleAsset: AssetEntity = { - assetType: KnownCaip19Id.SolMainnet, - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '1000000000', - uiAmount: '1', - }; - - await adapter.saveMany([fungibleAsset, createNftAsset()]); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [ACCOUNT_ID]: { - added: [NFT_ASSET_ID], - removed: [], - }, - }, - }, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [ACCOUNT_ID]: { - [NFT_ASSET_ID]: { - unit: 'NFT', - amount: '1', - }, - }, - }, - }, - ); - }); - }); - }); }); 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 a9d460b4f..096872ca5 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,18 +1,11 @@ -import { KeyringEvent } from '@metamask/keyring-api'; -import type { - AccountAssetListUpdatedEvent, - AccountBalancesUpdatedEvent, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import type { AssetEntity, SolanaKeyringAccount } from '../../../../entities'; +import type { AssetEntity } from '../../../../entities'; import logger, { createPrefixedLogger } from '../../../utils/logger'; import type { ILogger } from '../../../utils/logger'; import type { AccountsService } from '../../accounts/AccountsService'; import type { ConfigProvider } from '../../config'; -import { isSnapOwnedAsset } from '../utils/isSnapOwnedAsset'; import { mapControllerAsset } from '../utils/mapControllerAsset'; export type CoreAssetsAdapterOptions = { @@ -24,8 +17,11 @@ export type CoreAssetsAdapterOptions = { }; /** - * Uses the AssetsController for fungible reads. Snap-owned (NFT) assets are - * published via keyring events without local persistence when migration is active. + * Reads fungible balances from AssetsController. + * + * Solana has no snap-owned assets (unlike Tron staking/energy/bandwidth), so + * this adapter does not fetch, persist, or publish balances, and does not + * monitor addresses for snap-owned changes. */ export class CoreAssetsAdapter { readonly #logger: ILogger; @@ -101,26 +97,15 @@ export class CoreAssetsAdapter { const assets = await this.#getAccountAssetsByIDs(accountId, assetIds); - const entries = await Promise.all( - assetIds.map(async (assetId) => { + return Object.fromEntries( + assetIds.map((assetId) => { const asset = assets[assetId]; - if (!asset) { - return [assetId, null] as const; - } - - const entity = await mapControllerAsset( - accountId, - accountAddress, - asset, - ); - return [assetId, entity] as const; + return [ + assetId, + asset ? mapControllerAsset(accountId, accountAddress, asset) : null, + ]; }), - ); - - return Object.fromEntries(entries) as Record< - CaipAssetType, - AssetEntity | null - >; + ) as Record; } async getAccountAssetsByScope( @@ -142,10 +127,8 @@ export class CoreAssetsAdapter { accountId, ); - return Promise.all( - Object.values(controllerAssets).map(async (asset) => - mapControllerAsset(accountId, accountAddress, asset), - ), + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(accountId, accountAddress, asset), ); } @@ -159,77 +142,4 @@ export class CoreAssetsAdapter { return assetsByScope.flat(); } - - /** - * Fungible balances come from AssetsController once migration is active. - * Snap-owned NFT fetch is not produced here (matching the Snap adapter, - * which currently does not return NFT balances from `fetch`). - * - * @param account - The keyring account. - * @returns Snap-owned assets for the account (currently none). - */ - async fetch(account: SolanaKeyringAccount): Promise { - this.#logger.info('Fetching snap-owned assets for account', { account }); - return []; - } - - /** - * Publishes snap-owned assets to the extension without persisting locally. - * - * Filters to snap-owned assets, reports each as `added`, and emits balance - * updates for those assets. - * - * @param assets - Assets to publish (non snap-owned entries are ignored). - */ - async saveMany(assets: AssetEntity[]): Promise { - this.#logger.info('Publishing snap-owned assets', assets); - - const snapOwnedAssets = assets.filter((asset) => - isSnapOwnedAsset(asset.assetType), - ); - - if (snapOwnedAssets.length === 0) { - return; - } - - const assetListUpdatedPayload = snapOwnedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [ - ...(acc[asset.keyringAccountId]?.added ?? []), - asset.assetType, - ], - removed: [], - }, - }), - {}, - ); - - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - - const balancesUpdatedPayload = snapOwnedAssets.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - ...(acc[asset.keyringAccountId] ?? {}), - [asset.assetType]: { - unit: asset.symbol, - amount: asset.uiAmount, - }, - }, - }), - {}, - ); - - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } } diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts deleted file mode 100644 index 4967c7c20..000000000 --- a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { KnownCaip19Id, Network } from '../../../constants/solana'; -import { isSnapOwnedAsset } from './isSnapOwnedAsset'; - -describe('isSnapOwnedAsset', () => { - it('returns true for NFT asset IDs', () => { - expect(isSnapOwnedAsset(`${Network.Mainnet}/nft:SomeNftMintAddress`)).toBe( - true, - ); - }); - - it('returns false for native SOL', () => { - expect(isSnapOwnedAsset(KnownCaip19Id.SolMainnet)).toBe(false); - expect(isSnapOwnedAsset(KnownCaip19Id.SolDevnet)).toBe(false); - }); - - it('returns false for SPL tokens', () => { - expect(isSnapOwnedAsset(KnownCaip19Id.UsdcMainnet)).toBe(false); - expect(isSnapOwnedAsset(KnownCaip19Id.Ai16zMainnet)).toBe(false); - }); -}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts deleted file mode 100644 index 872deb885..000000000 --- a/packages/solana-wallet-snap/src/core/services/assets/utils/isSnapOwnedAsset.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Returns whether an asset remains exclusively managed by the Snap. - * - * AssetsController does not persist Solana NFT balances. NFT assets must always - * be read, synchronized, persisted, and published by the Snap, regardless of - * the assets migration stage. - * - * @param assetId - CAIP-19 asset ID. - * @returns Whether the asset is exclusively managed by the Snap. - */ -export function isSnapOwnedAsset(assetId: string): boolean { - return assetId.includes('/nft:'); -} 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 26ae0a0b7..54ff5de4b 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 @@ -35,13 +35,13 @@ function buildControllerAsset( } describe('mapControllerAsset', () => { - it('maps native SOL assets', async () => { + it('maps native SOL assets', () => { const asset = buildControllerAsset(KnownCaip19Id.SolMainnet, '1000000000', { symbol: 'SOL', decimals: 9, }); - const entity = await mapControllerAsset( + const entity = mapControllerAsset( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, @@ -59,19 +59,19 @@ describe('mapControllerAsset', () => { }); }); - it('maps SPL token assets with ATA pubkey', async () => { + it('maps SPL token assets from the mint without deriving an ATA pubkey', () => { const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { symbol: 'USDC', decimals: 6, }); - const entity = await mapControllerAsset( + const entity = mapControllerAsset( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, ); - expect(entity).toMatchObject({ + expect(entity).toStrictEqual({ assetType: KnownCaip19Id.UsdcMainnet, keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, network: Network.Mainnet, @@ -81,11 +81,10 @@ describe('mapControllerAsset', () => { rawAmount: '1234567', uiAmount: '1.234567', }); - expect(entity).toHaveProperty('pubkey'); - expect(typeof (entity as { pubkey?: string }).pubkey).toBe('string'); + expect(entity).not.toHaveProperty('pubkey'); }); - it('uses UNKNOWN and 0 decimals when metadata is missing', async () => { + it('uses UNKNOWN and 0 decimals when metadata is missing', () => { const assetId = KnownCaip19Id.UsdcMainnet; const asset = { id: assetId, @@ -96,7 +95,7 @@ describe('mapControllerAsset', () => { fiatValue: 0, } as unknown as Asset; - const entity = await mapControllerAsset( + const entity = mapControllerAsset( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, 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 1aaa0d7de..b81935dae 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 @@ -1,10 +1,5 @@ import type { Asset } from '@metamask/assets-controller'; import { parseCaipAssetType } from '@metamask/utils'; -import { - findAssociatedTokenPda, - TOKEN_PROGRAM_ADDRESS, -} from '@solana-program/token'; -import { address as asAddress } from '@solana/kit'; import type { AssetEntity } from '../../../../entities'; import type { @@ -18,19 +13,22 @@ import { fromTokenUnits } from '../../../utils/fromTokenUnit'; /** * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. * - * Native SOL uses the account address. SPL tokens resolve the associated token - * account (ATA) pubkey so Send and other callers keep a TokenAsset. + * Native SOL uses the account address. SPL tokens use the mint from the + * CAIP-19 ID. Associated token account (ATA) pubkeys are not derived here: + * Core does not store them, Send already computes ATAs with the correct token + * program, and Solana has no snap-owned assets that would need address + * monitoring. * * @param accountId - Keyring account ID. * @param accountAddress - Solana account address (owner). * @param asset - Asset returned by AssetsController. * @returns Mapped asset entity. */ -export async function mapControllerAsset( +export function mapControllerAsset( accountId: string, accountAddress: string, asset: Asset, -): Promise { +): AssetEntity { const assetId = asset.id; const { chainId, assetReference } = parseCaipAssetType(assetId); const decimals = asset.metadata.decimals ?? 0; @@ -52,19 +50,11 @@ export async function mapControllerAsset( }; } - const mint = assetReference; - const [pubkey] = await findAssociatedTokenPda({ - mint: asAddress(mint), - owner: asAddress(accountAddress), - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); - return { assetType: assetId as TokenCaipAssetType, keyringAccountId: accountId, network, - mint, - pubkey, + mint: assetReference, symbol, decimals, rawAmount, 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..ca2ce9cf0 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -129,7 +129,8 @@ export class TransactionsService { asset: AssetEntity, ): Promise => { const { network } = asset; - const addressOrPubkey = 'pubkey' in asset ? asset.pubkey : asset.address; + const addressOrPubkey = + 'pubkey' in asset && asset.pubkey ? asset.pubkey : asset.address; const latestTransaction = await findLatestTransactionForAsset(asset); diff --git a/packages/solana-wallet-snap/src/entities/assets.ts b/packages/solana-wallet-snap/src/entities/assets.ts index 06883eaff..d2ab47b4f 100644 --- a/packages/solana-wallet-snap/src/entities/assets.ts +++ b/packages/solana-wallet-snap/src/entities/assets.ts @@ -21,7 +21,12 @@ export type TokenAsset = { keyringAccountId: string; network: Network; mint: string; - pubkey: string; + /** + * Token account address. Present for Snap-fetched balances (RPC token + * accounts). Omitted for Core-mapped assets — AssetsController does not + * store ATAs, and Solana callers that need one (Send) derive it themselves. + */ + pubkey?: string; symbol: string; decimals: number; rawAmount: string; // Without decimals nor multiplier From f4489139377a7c1150779e5f0227720d641dfb4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 23:02:24 +0000 Subject: [PATCH 04/11] fix(solana-wallet-snap): use shared Logger.withPrefix in CoreAssetsAdapter After rebasing onto main, CoreAssetsAdapter still imported the removed createPrefixedLogger/ILogger API. Align it with SnapAssetsAdapter and the shared snap-networks-utils Logger. Co-authored-by: Ulisses Ferreira --- .../core/services/assets/adapters/CoreAssetsAdapter.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 096872ca5..d8c3e4c49 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,9 +1,8 @@ -import type { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import type { AssetEntity } from '../../../../entities'; -import logger, { createPrefixedLogger } from '../../../utils/logger'; -import type { ILogger } from '../../../utils/logger'; +import logger from '../../../utils/logger'; import type { AccountsService } from '../../accounts/AccountsService'; import type { ConfigProvider } from '../../config'; import { mapControllerAsset } from '../utils/mapControllerAsset'; @@ -24,7 +23,7 @@ export type CoreAssetsAdapterOptions = { * monitor addresses for snap-owned changes. */ export class CoreAssetsAdapter { - readonly #logger: ILogger; + readonly #logger: Logger; readonly #getAccountAssetByID: AssetsProvider['getAccountAssetByID']; @@ -45,7 +44,7 @@ export class CoreAssetsAdapter { getActiveNetworks, } = options; - this.#logger = createPrefixedLogger(logger, '[🪙 CoreAssetsAdapter]'); + this.#logger = logger.withPrefix('[🪙 CoreAssetsAdapter]'); this.#getAccountAssetByID = getAccountAssetByID; this.#getAccountAssetsByIDs = getAccountAssetsByIDs; this.#getAccountAssetsByScope = getAccountAssetsByScope; From b9f4942a8f059afbb7332e85b9714c241fbb4b86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 23:09:30 +0000 Subject: [PATCH 05/11] fix(solana-wallet-snap): inject Logger into CoreAssetsAdapter Match SnapAssetsAdapter after the shared logger migration so tests can pass mockLogger instead of constructing ConfigProvider at import time. Co-authored-by: Ulisses Ferreira --- .../src/core/services/assets/AssetsService.test.ts | 1 + .../core/services/assets/adapters/CoreAssetsAdapter.test.ts | 2 ++ .../src/core/services/assets/adapters/CoreAssetsAdapter.ts | 3 ++- packages/solana-wallet-snap/src/snapContext.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) 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 c9a0e85d2..01e4d203a 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 @@ -103,6 +103,7 @@ describe('AssetsService', () => { }); const coreAdapter = new CoreAssetsAdapter({ + logger: mockLogger, getAccountAssetByID: jest.fn().mockResolvedValue(null), getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.fn().mockResolvedValue({}), 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 3f26fc853..22c270c9d 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 @@ -3,6 +3,7 @@ import type { AssetsProvider } from '@metamask/snap-networks-utils'; import { KnownCaip19Id, Network } from '../../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; +import { mockLogger } from '../../__mocks__/logger'; import { CoreAssetsAdapter } from './CoreAssetsAdapter'; const ACCOUNT_ID = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; @@ -85,6 +86,7 @@ function createCoreAssetsAdapterContext(): { const mockGetActiveNetworks = jest.fn().mockResolvedValue([Network.Mainnet]); const adapter = new CoreAssetsAdapter({ + logger: mockLogger, getAccountAssetByID: mockAssetsProvider.getAccountAssetByID, getAccountAssetsByIDs: mockAssetsProvider.getAccountAssetsByIDs, getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, 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 d8c3e4c49..ed9b56e21 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 @@ -2,12 +2,12 @@ import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import type { AssetEntity } from '../../../../entities'; -import logger from '../../../utils/logger'; import type { AccountsService } from '../../accounts/AccountsService'; import type { ConfigProvider } from '../../config'; import { mapControllerAsset } from '../utils/mapControllerAsset'; export type CoreAssetsAdapterOptions = { + logger: Logger; getAccountAssetByID: AssetsProvider['getAccountAssetByID']; getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; @@ -37,6 +37,7 @@ export class CoreAssetsAdapter { constructor(options: CoreAssetsAdapterOptions) { const { + logger, getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 46c188531..94ce277d6 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -194,6 +194,7 @@ const assetsProvider = new AssetsProvider({ }); const coreAssetsAdapter = new CoreAssetsAdapter({ + logger, getAccountAssetByID: assetsProvider.getAccountAssetByID.bind(assetsProvider), getAccountAssetsByIDs: assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), From fd6b42f0321e620d4b07c888cbeb00ec66d3cedc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 23:13:44 +0000 Subject: [PATCH 06/11] fix(solana-wallet-snap): tighten CoreAssetsAdapter and optional-pubkey types Skip signature fetches when a token asset has no pubkey, type the adapter scope mocks, and map only defined controller assets. Co-authored-by: Ulisses Ferreira --- .../assets/adapters/CoreAssetsAdapter.test.ts | 5 +++-- .../services/assets/adapters/CoreAssetsAdapter.ts | 7 +++++-- .../core/services/transactions/TransactionsService.ts | 11 +++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) 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 22c270c9d..e56ab3f19 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,5 +1,6 @@ import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { CaipChainId } from '@metamask/utils'; import { KnownCaip19Id, Network } from '../../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../../test/mocks/solana-keyring-accounts'; @@ -275,7 +276,7 @@ describe('CoreAssetsAdapter', () => { chainId: Network.Devnet, }); mockAssetsProvider.getAccountAssetsByScope.mockImplementation( - async (scope) => { + async (scope: CaipChainId) => { if (scope === Network.Mainnet) { return { [MAINNET_ASSET_ID]: mainnetAsset }; } @@ -309,7 +310,7 @@ describe('CoreAssetsAdapter', () => { Network.Devnet, ]); mockAssetsProvider.getAccountAssetsByScope.mockImplementation( - async (scope) => { + async (scope: CaipChainId) => { if (scope === Network.Devnet) { throw new Error('devnet failed'); } 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 ed9b56e21..6c46476ce 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,3 +1,4 @@ +import type { Asset } from '@metamask/assets-controller'; import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; @@ -127,8 +128,10 @@ export class CoreAssetsAdapter { accountId, ); - return Object.values(controllerAssets).map((asset) => - mapControllerAsset(accountId, accountAddress, asset), + return Object.values(controllerAssets).flatMap((asset) => + asset + ? [mapControllerAsset(accountId, accountAddress, asset as Asset)] + : [], ); } 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 ca2ce9cf0..129e259f6 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -129,8 +129,15 @@ export class TransactionsService { asset: AssetEntity, ): Promise => { const { network } = asset; - const addressOrPubkey = - 'pubkey' in asset && asset.pubkey ? asset.pubkey : asset.address; + let addressOrPubkey: string; + if ('pubkey' in asset) { + if (!asset.pubkey) { + return []; + } + addressOrPubkey = asset.pubkey; + } else { + addressOrPubkey = asset.address; + } const latestTransaction = await findLatestTransactionForAsset(asset); From 46be6601b381ee23c82d026b3a017cac49a4d4f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 23:17:32 +0000 Subject: [PATCH 07/11] chore(solana-wallet-snap): sync snap manifest shasum from CI build The production bundle hashes secrets that are only available in GitHub Actions, so the committed shasum has to match the CI-computed value. 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 28ac38d5f..219930775 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": "/PhLDvRV3M/0N7rlAqg7pypEkZAHTfFNP9/TNxi8G80=", + "shasum": "JetxOe5cWBZ03uo5GFA7mQYSkZwfNw73U7LX/BsOrVI=", "location": { "npm": { "filePath": "dist/bundle.js", From ef8ecdfb2c4bb2f03fb29547a9af517f2e472110 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 10:45:34 +0000 Subject: [PATCH 08/11] feat(solana-wallet-snap): derive ATA pubkeys in CoreAssetsAdapter Transaction history looks up signatures by token-account address, so Core-mapped SPL tokens need a pubkey. Derive each associated token account from the mint's token program (including Token-2022) and keep TokenAsset.pubkey required. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- .../services/assets/AssetsService.test.ts | 1 + .../assets/adapters/CoreAssetsAdapter.test.ts | 257 ++++++++++++++++-- .../assets/adapters/CoreAssetsAdapter.ts | 119 +++++++- .../assets/utils/mapControllerAsset.test.ts | 22 +- .../assets/utils/mapControllerAsset.ts | 17 +- .../transactions/TransactionsService.ts | 3 - .../solana-wallet-snap/src/entities/assets.ts | 8 +- .../solana-wallet-snap/src/snapContext.ts | 1 + 9 files changed, 380 insertions(+), 50 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 5215762bb..51f1e5a1d 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a read-only `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands), including Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`). Solana has no snap-owned assets, so the adapter does not fetch, persist, or publish balances, and the mapper does not derive associated token account addresses. ([#122](https://github.com/MetaMask/internal-snaps/pull/122)) +- Add a read-only `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands), including Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`). Solana has no snap-owned assets, so the adapter does not fetch, persist, or publish balances. When mapping SPL tokens, the adapter derives associated token account (ATA) pubkeys from the mint's token program (including Token-2022) so Transaction History can still call `getSignaturesForAddress`. ([#122](https://github.com/MetaMask/internal-snaps/pull/122)) ### 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 01e4d203a..e0bcbbcdd 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,6 +110,7 @@ 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 e56ab3f19..dbd9d68aa 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,15 +1,47 @@ 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; +const ACCOUNT_ADDRESS = MOCK_SOLANA_KEYRING_ACCOUNT_0.address; 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. @@ -74,6 +106,7 @@ function createCoreAssetsAdapterContext(): { >; mockFindAccountById: jest.Mock; mockGetActiveNetworks: jest.Mock; + mockFetchMint: jest.MockedFunction; } { const mockAssetsProvider = { getAccountAssetByID: jest.fn().mockResolvedValue(undefined), @@ -85,6 +118,11 @@ 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, @@ -93,6 +131,7 @@ function createCoreAssetsAdapterContext(): { getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, findAccountById: mockFindAccountById, getActiveNetworks: mockGetActiveNetworks, + fetchMint: mockFetchMint, }); return { @@ -100,6 +139,7 @@ function createCoreAssetsAdapterContext(): { mockAssetsProvider, mockFindAccountById, mockGetActiveNetworks, + mockFetchMint, }; } @@ -119,33 +159,137 @@ async function withCoreAssetsAdapter( describe('CoreAssetsAdapter', () => { describe('getAccountAssetByID', () => { - it('maps a controller asset to an AssetEntity', async () => { - await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { - const controllerAsset = createControllerAsset({ id: MAINNET_ASSET_ID }); - mockAssetsProvider.getAccountAssetByID.mockResolvedValue( - controllerAsset, - ); + 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, - ); + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); - expect(mockAssetsProvider.getAccountAssetByID).toHaveBeenCalledWith( - ACCOUNT_ID, - MAINNET_ASSET_ID, - ); - expect(asset).toStrictEqual({ - assetType: MAINNET_ASSET_ID, - keyringAccountId: ACCOUNT_ID, - network: Network.Mainnet, - address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '1000000000', - uiAmount: '1', - }); - }); + 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: '1234567', + }); + 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: '1234567', + }), + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); + + const tokenProgramAta = await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + ); + const token2022Ata = await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_2022_PROGRAM_ADDRESS, + ); + + expect(token2022Ata).not.toBe(tokenProgramAta); + expect(asset).toMatchObject({ pubkey: token2022Ata }); + }, + ); + }); + + 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: '1234567', + }), + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); + + expect(asset).toBeNull(); + }, + ); }); it('returns null when the controller has no matching asset', async () => { @@ -198,6 +342,35 @@ describe('CoreAssetsAdapter', () => { }); }); + it('derives 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 }), + [USDC_ASSET_ID]: createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1234567', + }), + }); + + const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, [ + MAINNET_ASSET_ID, + USDC_ASSET_ID, + ]); + + expect(assets[USDC_ASSET_ID]).toMatchObject({ + mint: USDC_MINT, + pubkey: await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + ), + }); + expect(assets[MAINNET_ASSET_ID]).not.toHaveProperty('pubkey'); + }); + }); + it('returns an empty record for an empty ID list', async () => { await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, []); @@ -239,10 +412,42 @@ describe('CoreAssetsAdapter', () => { assets.every((asset) => asset.keyringAccountId === ACCOUNT_ID), ).toBe(true); const usdc = assets.find((asset) => asset.assetType === USDC_ASSET_ID); - expect(usdc).not.toHaveProperty('pubkey'); + expect(usdc).toMatchObject({ + mint: USDC_MINT, + pubkey: await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + ), + }); }); }); + 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: '1234567', + }), + }); + + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); + + expect(assets).toHaveLength(1); + expect(assets[0]?.assetType).toBe(MAINNET_ASSET_ID); + }, + ); + }); + it('returns an empty list when the account is missing', async () => { await withCoreAssetsAdapter( async ({ adapter, mockFindAccountById, mockAssetsProvider }) => { 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 6c46476ce..b75fe3cf4 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,10 +1,16 @@ 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 = { @@ -14,6 +20,7 @@ export type CoreAssetsAdapterOptions = { getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; findAccountById: AccountsService['findById']; getActiveNetworks: ConfigProvider['getActiveNetworks']; + fetchMint: SolanaConnection['fetchMint']; }; /** @@ -22,6 +29,11 @@ export type CoreAssetsAdapterOptions = { * Solana has no snap-owned assets (unlike Tron staking/energy/bandwidth), so * this adapter does not fetch, persist, or publish balances, and does not * 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). */ export class CoreAssetsAdapter { readonly #logger: Logger; @@ -36,6 +48,8 @@ export class CoreAssetsAdapter { readonly #getActiveNetworks: ConfigProvider['getActiveNetworks']; + readonly #fetchMint: SolanaConnection['fetchMint']; + constructor(options: CoreAssetsAdapterOptions) { const { logger, @@ -44,6 +58,7 @@ export class CoreAssetsAdapter { getAccountAssetsByScope, findAccountById, getActiveNetworks, + fetchMint, } = options; this.#logger = logger.withPrefix('[🪙 CoreAssetsAdapter]'); @@ -52,6 +67,7 @@ export class CoreAssetsAdapter { this.#getAccountAssetsByScope = getAccountAssetsByScope; this.#findAccountById = findAccountById; this.#getActiveNetworks = getActiveNetworks; + this.#fetchMint = fetchMint; } async #resolveAccountAddress(accountId: string): Promise { @@ -59,6 +75,77 @@ export class CoreAssetsAdapter { return account?.address ?? null; } + /** + * 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. + * + * @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. + */ + async #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, + ); + } + async getAccountAssetByID( accountId: string, assetId: CaipAssetType, @@ -76,7 +163,7 @@ export class CoreAssetsAdapter { return null; } - return mapControllerAsset(accountId, accountAddress, asset); + return this.#mapAsset(accountId, accountAddress, asset); } async getAccountAssetsByIDs( @@ -98,15 +185,22 @@ export class CoreAssetsAdapter { const assets = await this.#getAccountAssetsByIDs(accountId, assetIds); - return Object.fromEntries( - assetIds.map((assetId) => { + const entries = await Promise.all( + assetIds.map(async (assetId) => { const asset = assets[assetId]; return [ assetId, - asset ? mapControllerAsset(accountId, accountAddress, asset) : null, - ]; + asset + ? await this.#mapAsset(accountId, accountAddress, asset) + : null, + ] as const; }), - ) as Record; + ); + + return Object.fromEntries(entries) as Record< + CaipAssetType, + AssetEntity | null + >; } async getAccountAssetsByScope( @@ -128,11 +222,16 @@ export class CoreAssetsAdapter { accountId, ); - return Object.values(controllerAssets).flatMap((asset) => - asset - ? [mapControllerAsset(accountId, accountAddress, asset as Asset)] - : [], + const mapped = await Promise.all( + Object.values(controllerAssets).map(async (asset) => { + if (!asset) { + return null; + } + return this.#mapAsset(accountId, accountAddress, asset as Asset); + }), ); + + return mapped.filter((asset): asset is AssetEntity => asset !== null); } async getAccountAssets(accountId: string): Promise { 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 54ff5de4b..aa542726e 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 @@ -59,16 +59,18 @@ describe('mapControllerAsset', () => { }); }); - it('maps SPL token assets from the mint without deriving an ATA pubkey', () => { + it('maps SPL token assets with the provided associated token account pubkey', () => { const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { symbol: 'USDC', decimals: 6, }); + const tokenAccountPubkey = '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg'; const entity = mapControllerAsset( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, + tokenAccountPubkey, ); expect(entity).toStrictEqual({ @@ -76,12 +78,27 @@ describe('mapControllerAsset', () => { keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, network: Network.Mainnet, mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: tokenAccountPubkey, symbol: 'USDC', decimals: 6, rawAmount: '1234567', uiAmount: '1.234567', }); - expect(entity).not.toHaveProperty('pubkey'); + }); + + it('throws when mapping an SPL token without a token account pubkey', () => { + const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { + symbol: 'USDC', + decimals: 6, + }); + + 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'); }); it('uses UNKNOWN and 0 decimals when metadata is missing', () => { @@ -99,6 +116,7 @@ describe('mapControllerAsset', () => { MOCK_SOLANA_KEYRING_ACCOUNT_0.id, MOCK_SOLANA_KEYRING_ACCOUNT_0.address, asset, + '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', ); expect(entity).toMatchObject({ 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 b81935dae..073dacb14 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 @@ -14,20 +14,22 @@ import { fromTokenUnits } from '../../../utils/fromTokenUnit'; * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. * * Native SOL uses the account address. SPL tokens use the mint from the - * CAIP-19 ID. Associated token account (ATA) pubkeys are not derived here: - * Core does not store them, Send already computes ATAs with the correct token - * program, and Solana has no snap-owned assets that would need address - * monitoring. + * 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. * * @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. * @returns Mapped asset entity. */ export function mapControllerAsset( accountId: string, accountAddress: string, asset: Asset, + tokenAccountPubkey?: string, ): AssetEntity { const assetId = asset.id; const { chainId, assetReference } = parseCaipAssetType(assetId); @@ -50,11 +52,18 @@ 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, symbol, decimals, rawAmount, 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 129e259f6..b1a442dfb 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -131,9 +131,6 @@ export class TransactionsService { const { network } = asset; let addressOrPubkey: string; if ('pubkey' in asset) { - if (!asset.pubkey) { - return []; - } addressOrPubkey = asset.pubkey; } else { addressOrPubkey = asset.address; diff --git a/packages/solana-wallet-snap/src/entities/assets.ts b/packages/solana-wallet-snap/src/entities/assets.ts index d2ab47b4f..9abc8b747 100644 --- a/packages/solana-wallet-snap/src/entities/assets.ts +++ b/packages/solana-wallet-snap/src/entities/assets.ts @@ -22,11 +22,11 @@ export type TokenAsset = { network: Network; mint: string; /** - * Token account address. Present for Snap-fetched balances (RPC token - * accounts). Omitted for Core-mapped assets — AssetsController does not - * store ATAs, and Solana callers that need one (Send) derive it themselves. + * Token account address. Snap-fetched balances use the RPC token account. + * Core-mapped balances use the associated token account derived from the + * mint and owner (with the mint's token program, including Token-2022). */ - pubkey?: string; + 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 94ce277d6..ee5f631a6 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -200,6 +200,7 @@ 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), }); From 94b7e6b0fd2d2531210294e660744da233f4089e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 10:51:20 +0000 Subject: [PATCH 09/11] style(solana-wallet-snap): format CoreAssetsAdapter ATA derivation Co-authored-by: Ulisses Ferreira --- .../core/services/assets/adapters/CoreAssetsAdapter.test.ts | 5 ++++- .../src/core/services/assets/adapters/CoreAssetsAdapter.ts | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 dbd9d68aa..0ead31d78 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 @@ -211,7 +211,10 @@ describe('CoreAssetsAdapter', () => { USDC_ASSET_ID, ); - expect(mockFetchMint).toHaveBeenCalledWith(USDC_MINT, Network.Mainnet); + expect(mockFetchMint).toHaveBeenCalledWith( + USDC_MINT, + Network.Mainnet, + ); expect(asset).toStrictEqual({ assetType: USDC_ASSET_ID, keyringAccountId: ACCOUNT_ID, 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 b75fe3cf4..2b4f75479 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 @@ -190,9 +190,7 @@ export class CoreAssetsAdapter { const asset = assets[assetId]; return [ assetId, - asset - ? await this.#mapAsset(accountId, accountAddress, asset) - : null, + asset ? await this.#mapAsset(accountId, accountAddress, asset) : null, ] as const; }), ); From 5c9f9c08d0c67324d4e065f1c586a0b2973dc913 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 10:52:27 +0000 Subject: [PATCH 10/11] test(solana-wallet-snap): cover CoreAssetsAdapter missing-account and null-asset paths Co-authored-by: Ulisses Ferreira --- .../assets/adapters/CoreAssetsAdapter.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 0ead31d78..99cf56824 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 @@ -382,6 +382,27 @@ describe('CoreAssetsAdapter', () => { expect(mockAssetsProvider.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); }); + + it('returns null entries when the account is missing', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockFindAccountById, mockAssetsProvider }) => { + mockFindAccountById.mockResolvedValue(null); + + const assets = await adapter.getAccountAssetsByIDs(ACCOUNT_ID, [ + MAINNET_ASSET_ID, + USDC_ASSET_ID, + ]); + + expect(assets).toStrictEqual({ + [MAINNET_ASSET_ID]: null, + [USDC_ASSET_ID]: null, + }); + expect( + mockAssetsProvider.getAccountAssetsByIDs, + ).not.toHaveBeenCalled(); + }, + ); + }); }); describe('getAccountAssetsByScope', () => { @@ -451,6 +472,23 @@ describe('CoreAssetsAdapter', () => { ); }); + it('skips null controller assets for the requested scope', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + mockAssetsProvider.getAccountAssetsByScope.mockResolvedValue({ + [MAINNET_ASSET_ID]: createControllerAsset({ id: MAINNET_ASSET_ID }), + [USDC_ASSET_ID]: null, + } as unknown as Record); + + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); + + expect(assets).toHaveLength(1); + expect(assets[0]?.assetType).toBe(MAINNET_ASSET_ID); + }); + }); + it('returns an empty list when the account is missing', async () => { await withCoreAssetsAdapter( async ({ adapter, mockFindAccountById, mockAssetsProvider }) => { From a41425b4b6aed32c4c274b40885a84b7ee6d9a28 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 10:57:43 +0000 Subject: [PATCH 11/11] chore(solana-wallet-snap): sync snap manifest shasum from CI 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 219930775..802586b1c 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": "JetxOe5cWBZ03uo5GFA7mQYSkZwfNw73U7LX/BsOrVI=", + "shasum": "YAhMrNcEE5rIa14naIJWiLwWLZSFaR87z/4HqjxR+8A=", "location": { "npm": { "filePath": "dist/bundle.js",