diff --git a/packages/solana-wallet-snap/package.json b/packages/solana-wallet-snap/package.json index f8d11e635..e0b681dec 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": "^24.1.0", "@metamask/keyring-snap-sdk": "^10.0.0", + "@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.1", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 3db6ea623..4cde29eee 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": "12.0.1", "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 5cf6ccf95..ab8bf7408 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,20 @@ describe('AssetsService', () => { nftApiClient: mockNftApiClient, }); + const coreAdapter = new CoreAssetsAdapter({ + logger: mockLogger, + getAccountAssetByID: jest.fn().mockResolvedValue(null), + getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + findAccountById: mockAccountsService.findById.bind(mockAccountsService), + getActiveNetworks: + mockConfigProvider.getActiveNetworks.bind(mockConfigProvider), + fetchMint: mockConnection.fetchMint.bind(mockConnection), + }); + 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..7ade6fc86 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.test.ts @@ -0,0 +1,574 @@ +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. + * + * @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 - Display-formatted 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 = '1', + 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 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; + mockFetchMint: jest.MockedFunction; +} { + 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 mockFetchMint = jest + .fn() + .mockResolvedValue(MOCK_MINT_ACCOUNT) as jest.MockedFunction< + SolanaConnection['fetchMint'] + >; + + const adapter = new CoreAssetsAdapter({ + logger: mockLogger, + getAccountAssetByID: mockAssetsProvider.getAccountAssetByID, + getAccountAssetsByIDs: mockAssetsProvider.getAccountAssetsByIDs, + getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, + findAccountById: mockFindAccountById, + getActiveNetworks: mockGetActiveNetworks, + fetchMint: mockFetchMint, + }); + + return { + adapter, + mockAssetsProvider, + mockFindAccountById, + mockGetActiveNetworks, + mockFetchMint, + }; +} + +/** + * 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 native controller asset without deriving an ATA', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockFetchMint }) => { + const controllerAsset = createControllerAsset({ + id: MAINNET_ASSET_ID, + }); + mockAssetsProvider.getAccountAssetByID.mockResolvedValue( + controllerAsset, + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); + + expect(mockAssetsProvider.getAccountAssetByID).toHaveBeenCalledWith( + ACCOUNT_ID, + MAINNET_ASSET_ID, + ); + expect(mockFetchMint).not.toHaveBeenCalled(); + expect(asset).toStrictEqual({ + assetType: MAINNET_ASSET_ID, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + address: ACCOUNT_ADDRESS, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + }, + ); + }); + + it('maps an SPL token and derives its associated token account pubkey', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockFetchMint }) => { + const controllerAsset = createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1.234567', + }); + mockAssetsProvider.getAccountAssetByID.mockResolvedValue( + controllerAsset, + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); + + expect(mockFetchMint).toHaveBeenCalledWith( + USDC_MINT, + Network.Mainnet, + ); + expect(asset).toStrictEqual({ + assetType: USDC_ASSET_ID, + keyringAccountId: ACCOUNT_ID, + network: Network.Mainnet, + mint: USDC_MINT, + pubkey: await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + ), + symbol: 'USDC', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + }); + }, + ); + }); + + it('derives the ATA with the Token-2022 program when the mint is Token-2022', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockFetchMint }) => { + mockFetchMint.mockResolvedValue({ + ...MOCK_MINT_ACCOUNT, + programAddress: TOKEN_2022_PROGRAM_ADDRESS, + }); + mockAssetsProvider.getAccountAssetByID.mockResolvedValue( + createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1.234567', + }), + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); + + 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: '1.234567', + }), + ); + + const asset = await adapter.getAccountAssetByID( + ACCOUNT_ID, + USDC_ASSET_ID, + ); + + expect(asset).toBeNull(); + }, + ); + }); + + 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('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: '1.234567', + }), + }); + + 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, []); + + expect(assets).toStrictEqual({}); + 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', () => { + 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: '1.234567', + }); + 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); + const usdc = assets.find((asset) => asset.assetType === USDC_ASSET_ID); + expect(usdc).toMatchObject({ + mint: USDC_MINT, + pubkey: await expectedAssociatedTokenAccount( + USDC_MINT, + ACCOUNT_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + ), + }); + }); + }); + + it('omits SPL tokens whose ATA cannot be derived', async () => { + await withCoreAssetsAdapter( + async ({ adapter, mockAssetsProvider, mockFetchMint }) => { + mockFetchMint.mockRejectedValue(new Error('mint missing')); + mockAssetsProvider.getAccountAssetsByScope.mockResolvedValue({ + [MAINNET_ASSET_ID]: createControllerAsset({ id: MAINNET_ASSET_ID }), + [USDC_ASSET_ID]: createControllerAsset({ + id: USDC_ASSET_ID, + symbol: 'USDC', + decimals: 6, + amount: '1.234567', + }), + }); + + const assets = await adapter.getAccountAssetsByScope( + Network.Mainnet, + ACCOUNT_ID, + ); + + expect(assets).toHaveLength(1); + expect(assets[0]?.assetType).toBe(MAINNET_ASSET_ID); + }, + ); + }); + + 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 }) => { + 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: CaipChainId) => { + 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: CaipChainId) => { + if (scope === Network.Devnet) { + throw new Error('devnet failed'); + } + return {}; + }, + ); + + await expect(adapter.getAccountAssets(ACCOUNT_ID)).rejects.toThrow( + '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 new file mode 100644 index 000000000..2b4f75479 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/CoreAssetsAdapter.ts @@ -0,0 +1,245 @@ +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 = { + logger: Logger; + getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + findAccountById: AccountsService['findById']; + getActiveNetworks: ConfigProvider['getActiveNetworks']; + fetchMint: SolanaConnection['fetchMint']; +}; + +/** + * 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. + * + * 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; + + readonly #getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + + readonly #getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + + readonly #getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + + readonly #findAccountById: AccountsService['findById']; + + readonly #getActiveNetworks: ConfigProvider['getActiveNetworks']; + + readonly #fetchMint: SolanaConnection['fetchMint']; + + constructor(options: CoreAssetsAdapterOptions) { + const { + logger, + getAccountAssetByID, + getAccountAssetsByIDs, + getAccountAssetsByScope, + findAccountById, + getActiveNetworks, + fetchMint, + } = options; + + this.#logger = logger.withPrefix('[🪙 CoreAssetsAdapter]'); + this.#getAccountAssetByID = getAccountAssetByID; + this.#getAccountAssetsByIDs = getAccountAssetsByIDs; + this.#getAccountAssetsByScope = getAccountAssetsByScope; + this.#findAccountById = findAccountById; + this.#getActiveNetworks = getActiveNetworks; + this.#fetchMint = fetchMint; + } + + async #resolveAccountAddress(accountId: string): Promise { + const account = await this.#findAccountById(accountId); + 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, + ): 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); + + if (!asset) { + return null; + } + + return this.#mapAsset(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); + + const entries = await Promise.all( + assetIds.map(async (assetId) => { + const asset = assets[assetId]; + return [ + assetId, + asset ? await this.#mapAsset(accountId, accountAddress, asset) : null, + ] as const; + }), + ); + + 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, + ); + + 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 { + const activeNetworks = await this.#getActiveNetworks(); + const assetsByScope = await Promise.all( + activeNetworks.map(async (scope) => + this.getAccountAssetsByScope(scope, accountId), + ), + ); + + return assetsByScope.flat(); + } +} 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/mapControllerAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts new file mode 100644 index 000000000..7c22ff371 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.test.ts @@ -0,0 +1,130 @@ +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 - Display-formatted balance amount. + * @param metadata - Symbol and decimals. + * @param metadata.symbol - Asset symbol. + * @param metadata.decimals - Asset 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', () => { + const asset = buildControllerAsset(KnownCaip19Id.SolMainnet, '1', { + symbol: 'SOL', + decimals: 9, + }); + + const entity = 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 the provided associated token account pubkey', () => { + const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1.234567', { + 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({ + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: tokenAccountPubkey, + symbol: 'USDC', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + }); + }); + + 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', () => { + const assetId = KnownCaip19Id.UsdcMainnet; + 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 = mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + ); + + 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..8b008ca1a --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts @@ -0,0 +1,76 @@ +import type { Asset } from '@metamask/assets-controller'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { AssetEntity } from '../../../../entities'; +import type { + NativeCaipAssetType, + Network, + TokenCaipAssetType, +} from '../../../constants/solana'; +import { SolanaCaip19Tokens } from '../../../constants/solana'; +import { toTokenUnits } from '../../../utils/toTokenUnit'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * AssetsController returns balances in display format, so the source amount + * is used directly as the UI amount and converted to raw token units. The + * mapper does not derive token account addresses; the adapter supplies them. + * + * Native SOL uses the account address. SPL tokens use the mint from the + * CAIP-19 ID and require the associated token account pubkey — Core does not + * store ATAs, so the Core assets adapter derives them before calling this + * mapper. + * + * @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); + const decimals = asset.metadata.decimals ?? 0; + const symbol = asset.metadata.symbol ?? 'UNKNOWN'; + const uiAmount = asset.balance.amount; + const rawAmount = toTokenUnits(uiAmount, decimals).toString(); + const network = chainId as Network; + + if (assetId.endsWith(SolanaCaip19Tokens.SOL)) { + return { + assetType: assetId as NativeCaipAssetType, + keyringAccountId: accountId, + network, + address: accountAddress, + symbol, + decimals, + rawAmount, + uiAmount, + }; + } + + 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, + uiAmount, + }; +} diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 28e86afd6..73b892c90 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,3 +1,12 @@ +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 { Serializable } from '@metamask/snap-networks-utils'; import type { ICache } from './core/caching/ICache'; @@ -14,6 +23,7 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, + CoreAssetsAdapter, SnapAssetsAdapter, AssetsRepository, AssetsService, @@ -48,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. @@ -79,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(); @@ -162,8 +182,32 @@ 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({ + logger, + getAccountAssetByID: assetsProvider.getAccountAssetByID.bind(assetsProvider), + getAccountAssetsByIDs: + assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), + getAccountAssetsByScope: + assetsProvider.getAccountAssetsByScope.bind(assetsProvider), + fetchMint: connection.fetchMint.bind(connection), + findAccountById: accountsService.findById.bind(accountsService), + getActiveNetworks: configProvider.getActiveNetworks.bind(configProvider), +}); + const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, + coreAdapter: coreAssetsAdapter, }); const transactionsRepository = new TransactionsRepository(state); @@ -295,22 +339,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 9d49d909e..338c44f4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3710,10 +3710,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:^24.1.0" "@metamask/keyring-snap-sdk": "npm:^10.0.0" + "@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.1"