diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index ab6dd1dbf..51f1e5a1d 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 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 - 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..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": "/PhLDvRV3M/0N7rlAqg7pypEkZAHTfFNP9/TNxi8G80=", + "shasum": "YAhMrNcEE5rIa14naIJWiLwWLZSFaR87z/4HqjxR+8A=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -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..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 @@ -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..99cf56824 --- /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 - 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 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: '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 () => { + 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: '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, []); + + 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: '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); + 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: '1234567', + }), + }); + + 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..aa542726e --- /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 - Raw 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, '1000000000', { + 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, '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({ + 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..073dacb14 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/utils/mapControllerAsset.ts @@ -0,0 +1,72 @@ +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 { 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 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 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, + }; + } + + 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/core/services/transactions/TransactionsService.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts index 6a80ed81e..b1a442dfb 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,12 @@ export class TransactionsService { asset: AssetEntity, ): Promise => { const { network } = asset; - const addressOrPubkey = 'pubkey' in asset ? asset.pubkey : asset.address; + let addressOrPubkey: string; + if ('pubkey' in asset) { + addressOrPubkey = asset.pubkey; + } else { + addressOrPubkey = 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..9abc8b747 100644 --- a/packages/solana-wallet-snap/src/entities/assets.ts +++ b/packages/solana-wallet-snap/src/entities/assets.ts @@ -21,6 +21,11 @@ export type TokenAsset = { keyringAccountId: string; network: Network; mint: string; + /** + * 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; symbol: string; decimals: number; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 15f7b5cf0..ee5f631a6 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,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); @@ -294,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 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"