From c0b1fa838b70052d97d11c5e2bb4bc3357e241c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 14:40:21 +0000 Subject: [PATCH 1/8] refactor(solana-wallet-snap): extract SnapAssetsAdapter from existing AssetsService Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 5 +- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../solana-wallet-snap/snap.manifest.json | 2 +- .../services/assets/AssetsService.test.ts | 47 +- .../src/core/services/assets/AssetsService.ts | 701 +--------------- .../assets/adapters/SnapAssetsAdapter.test.ts | 118 +++ .../assets/adapters/SnapAssetsAdapter.ts | 776 ++++++++++++++++++ .../src/core/services/assets/index.ts | 1 + .../solana-wallet-snap/src/snapContext.ts | 9 +- 9 files changed, 974 insertions(+), 686 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 9492bd3d0..2e3d73832 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -353,13 +353,10 @@ "count": 2 } }, - "packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts": { + "packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts": { "@typescript-eslint/await-thenable": { "count": 1 }, - "@typescript-eslint/explicit-function-return-type": { - "count": 10 - }, "no-unused-private-class-members": { "count": 2 } diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 58816430c..6f652af56 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) ## [6.0.0] diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 0632d4d28..98c696194 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "YaPEFBNuMbbASorQCj4MFJKRSlqOjSOqJoOGUB9ET2I=", + "shasum": "xbiQfxthMCvd75y7AYfX1H2AdaSSsZmSd/oKXvRO6Gw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index 3bb5fca22..b773116ef 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 { mockLogger } from '../mocks/logger'; import { createMockConnection } from '../mocks/mockConnection'; import { MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE } from '../mocks/mockSolanaRpcResponses'; import type { TokenPricesService } from '../token-prices/TokenPrices'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import { AssetsService } from './AssetsService'; @@ -33,6 +34,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; + let snapAssetsAdapter: SnapAssetsAdapter; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; @@ -87,7 +89,7 @@ describe('AssetsService', () => { findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), } as unknown as AccountsService; - assetsService = new AssetsService({ + snapAssetsAdapter = new SnapAssetsAdapter({ connection: mockConnection, logger: mockLogger, configProvider: mockConfigProvider, @@ -98,6 +100,10 @@ describe('AssetsService', () => { cache: mockCache, nftApiClient: mockNftApiClient, }); + + assetsService = new AssetsService({ + snapAdapter: snapAssetsAdapter, + }); }); describe('fetch', () => { @@ -173,6 +179,45 @@ describe('AssetsService', () => { }); }); + describe('getAssetsMetadata', () => { + it('fetches token metadata from the token API client', async () => { + const tokenAssetTypes = [ + MOCK_ASSET_ENTITY_1.assetType, + MOCK_ASSET_ENTITY_2.assetType, + ]; + + const metadata = await assetsService.getAssetsMetadata(tokenAssetTypes); + + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith( + tokenAssetTypes, + ); + expect(metadata).toStrictEqual(SOLANA_MOCK_TOKEN_METADATA); + }); + }); + + describe('fetchAssetsMarketData', () => { + it('delegates to the token prices service', async () => { + const assets = [ + { + asset: MOCK_ASSET_ENTITY_0.assetType, + unit: MOCK_ASSET_ENTITY_0.assetType, + }, + ]; + const expected = { [MOCK_ASSET_ENTITY_0.assetType]: {} }; + + jest + .spyOn(mockTokenPricesService, 'getMultipleTokensMarketData') + .mockResolvedValueOnce(expected as never); + + const result = await assetsService.fetchAssetsMarketData(assets); + + expect( + mockTokenPricesService.getMultipleTokensMarketData, + ).toHaveBeenCalledWith(assets); + expect(result).toStrictEqual(expected); + }); + }); + describe('save', () => { it('saves an asset', async () => { const spy = jest 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 7a58bfeb0..5a1faabf3 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,461 +1,37 @@ /* eslint-disable jsdoc/require-returns */ - -import { KeyringEvent } from '@metamask/keyring-api'; -import type { - AccountAssetListUpdatedEvent, - AccountBalancesUpdatedEvent, - Balance, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { - FungibleAssetMarketData, - FungibleAssetMetadata, -} from '@metamask/snaps-sdk'; +import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import { Duration, parseCaipAssetType } from '@metamask/utils'; -import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; -import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; -import type { - AccountInfoBase, - AccountInfoWithPubkey, - Address, -} from '@solana/kit'; -import { address as asAddress } from '@solana/kit'; -import type { - AssetEntity, - NativeAsset, - SolanaKeyringAccount, - TokenAsset, -} from '../../../entities'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; -import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; -import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; -import type { - Caip10Address, - NativeCaipAssetType, - NftCaipAssetType, - TokenCaipAssetType, -} from '../../constants/solana'; -import { Network, SolanaCaip19Tokens } from '../../constants/solana'; -import type { TokenAccountInfoWithJsonData } from '../../sdk-extensions/rpc-api'; -import type { Serializable } from '../../serialization/types'; -import { fromTokenUnits } from '../../utils/fromTokenUnit'; -import { getNetworkFromToken } from '../../utils/getNetworkFromToken'; -import { createPrefixedLogger } from '../../utils/logger'; -import type { ILogger } from '../../utils/logger'; -import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; -import type { AccountsService } from '../accounts/AccountsService'; -import type { ConfigProvider } from '../config'; -import type { SolanaConnection } from '../connection'; -import type { TokenPricesService } from '../token-prices/TokenPrices'; -import type { AssetsRepository } from './AssetsRepository'; -import type { AssetMetadata, NonFungibleAssetMetadata } from './types'; +import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; +import type { AssetMetadata } from './types'; /** - * Extends a token account as returned by the `getTokenAccountsByOwner` RPC method with the scope and the caip-19 asset type for convenience. + * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter + * (legacy snap-owned reads/writes). */ -type TokenAccountWithMetadata = { - token: AccountInfoWithPubkey; - scope: Network; - assetType: TokenCaipAssetType; - keyringAccount: SolanaKeyringAccount; -} & Serializable; - export class AssetsService { - readonly #logger: ILogger; - - readonly #connection: SolanaConnection; - - readonly #configProvider: ConfigProvider; - - readonly #assetsRepository: AssetsRepository; - - readonly #accountsService: AccountsService; - - readonly #tokenPricesService: TokenPricesService; - - readonly #tokenApiClient: TokenApiClient; - - readonly #cache: ICache; - - readonly #nftApiClient: NftApiClient; - - public static readonly cacheTtlsMilliseconds = { - tokenAccountsByOwner: 5 * Duration.Second, - }; - - constructor({ - connection, - logger, - configProvider, - assetsRepository, - accountsService, - tokenApiClient, - tokenPricesService, - cache, - nftApiClient, - }: { - connection: SolanaConnection; - logger: ILogger; - configProvider: ConfigProvider; - assetsRepository: AssetsRepository; - accountsService: AccountsService; - tokenApiClient: TokenApiClient; - tokenPricesService: TokenPricesService; - cache: ICache; - nftApiClient: NftApiClient; - }) { - this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#connection = connection; - this.#configProvider = configProvider; - this.#assetsRepository = assetsRepository; - this.#accountsService = accountsService; - this.#tokenApiClient = tokenApiClient; - this.#tokenPricesService = tokenPricesService; - this.#cache = cache; - this.#nftApiClient = nftApiClient; - } - - #splitAssetsByType(assetTypes: CaipAssetType[]) { - const nativeAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith(SolanaCaip19Tokens.SOL), - ) as NativeCaipAssetType[]; - const tokenAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/token:'), - ) as TokenCaipAssetType[]; - const nftAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/nft:'), - ) as NftCaipAssetType[]; - - return { nativeAssetTypes, tokenAssetTypes, nftAssetTypes }; - } - - #getNativeTokensMetadata( - assetTypes: NativeCaipAssetType[], - ): Record { - const nativeTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; + readonly #snapAdapter: SnapAssetsAdapter; - for (const assetType of assetTypes) { - const { - chain: { namespace, reference }, - assetNamespace, - assetReference, - } = parseCaipAssetType(assetType); + readonly cacheTtlsMilliseconds: typeof SnapAssetsAdapter.cacheTtlsMilliseconds; - nativeTokensMetadata[assetType] = { - name: 'Solana', - symbol: 'SOL', - fungible: true, - iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/${namespace}/${reference}/${assetNamespace}/${assetReference}.png`, - units: [ - { - name: 'Solana', - symbol: 'SOL', - decimals: 9, - }, - ], - }; - } - - return nativeTokensMetadata; + constructor({ snapAdapter }: { snapAdapter: SnapAssetsAdapter }) { + this.#snapAdapter = snapAdapter; + this.cacheTtlsMilliseconds = SnapAssetsAdapter.cacheTtlsMilliseconds; } - async #getNftsMetadata( - assetTypes: NftCaipAssetType[], - ): Promise> { - const nftsMetadata = await this.#nftApiClient.getNftsMetadata( - assetTypes.map((assetType) => { - const { assetReference } = parseCaipAssetType(assetType); - return assetReference; - }), - ); - - const nftsMetadataMap: Record = - {}; - - assetTypes.forEach((assetType, index) => { - const nftMetadata = nftsMetadata[index]; - - if (!nftMetadata) { - return; - } - - const metadata = { - name: nftMetadata.name, - symbol: nftMetadata.name, - imageUrl: nftMetadata.imageUrl, - description: nftMetadata.description, - fungible: false as const, - isPossibleSpam: false, // FIXME: The isSpam should be part of the NFT item response, not balance, otherwise we can't get it here - attributes: Object.fromEntries( - nftMetadata.attributes.map( - (attr: { key: string; value: string | number }) => [ - attr.key, - attr.value, - ], - ), - ), - collection: { - name: nftMetadata.collectionName, - address: nftMetadata.onchainCollectionAddress as Caip10Address, - symbol: nftMetadata.collectionSymbol, - tokenCount: nftMetadata.collectionCount, - creator: '' as Caip10Address, // FIXME: There can be more than one creator - imageUrl: nftMetadata.collectionImageUrl ?? '', - }, - }; - - nftsMetadataMap[assetType] = metadata; - }); - - return nftsMetadataMap; + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { - this.#logger.log('Fetching metadata for assets', assetTypes); - - const { nativeAssetTypes, tokenAssetTypes } = - this.#splitAssetsByType(assetTypes); - - const [ - nativeTokensMetadata, - tokensMetadata, - // nftMetadata, - ] = await Promise.all([ - this.#getNativeTokensMetadata(nativeAssetTypes), - this.#tokenApiClient.getTokensMetadata(tokenAssetTypes), - // this.#getNftsMetadata(nftAssetTypes), - ]); - - return { - ...nativeTokensMetadata, - ...tokensMetadata, - // ...nftMetadata, - }; - } - - /** - * Matrix-fetches all token accounts owned by the given address on the specified networks and program ids, - * and merges the results into a single array. Each individual token is augmented with the scope and the caip-19 asset type for convenience. - * - * It caches the results for each pair of scope and program id. - * - * @param accounts - The owners of the token accounts. - * @param programIds - The program ids to fetch the token accounts for. - * @param scopes - The networks to fetch the token accounts for. - * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. - */ - async #fetchTokenAccountsMultiple( - accounts: SolanaKeyringAccount[], - programIds: Address[] = [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], - scopes: Network[] = [Network.Mainnet], - ): Promise { - if (programIds.length === 0 || scopes.length === 0) { - return []; - } - - // Create all combinations of account, programId, and scope - const combinations = accounts.flatMap((account) => - programIds.flatMap((programId) => - scopes.map((scope) => ({ account, programId, scope })), - ), - ); - - const fetchTokenAccountsCached = useCache< - [SolanaKeyringAccount, Address, Network], - TokenAccountWithMetadata[] - >(this.#fetchTokenAccounts.bind(this), this.#cache, { - functionName: 'AssetsService:fetchTokenAccounts', - ttlMilliseconds: AssetsService.cacheTtlsMilliseconds.tokenAccountsByOwner, - generateCacheKey: (functionName, args) => { - const [account, programId, scope] = args; - return `${functionName}:${account.id}:${programId}:${scope}`; - }, - }); - - const responses = await Promise.allSettled( - combinations.map(async ({ account, programId, scope }) => { - const response = await fetchTokenAccountsCached( - account, - programId, - scope, - ); - return response; - }), - ); - - return responses.flatMap((item) => - item.status === 'fulfilled' ? item.value : [], - ); - } - - /** - * Fetches the token accounts for the given owner and program id on the specified scope. - * - * @param account - The owner of the token accounts. - * @param programId - The program id to fetch the token accounts for. - * @param scope - The scope to fetch the token accounts for. - * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. - */ - async #fetchTokenAccounts( - account: SolanaKeyringAccount, - programId: Address = TOKEN_PROGRAM_ADDRESS, - scope: Network = Network.Mainnet, - ): Promise { - const response = await this.#connection - .getRpc(scope) - .getTokenAccountsByOwner( - asAddress(account.address), - { programId }, - { encoding: 'jsonParsed' }, - ) - .send(); - - const tokens = response.value; - - // Attach the scope and the caip-19 asset type to each token account for easier future reference - return tokens.map( - (token) => - ({ - token, - scope, - assetType: tokenAddressToCaip19( - scope, - token.account.data.parsed.info.mint, - ), - keyringAccount: account, - }) as TokenAccountWithMetadata, - ); + return this.#snapAdapter.getAssetsMetadata(assetTypes); } - /** - * Fetches all assets for the given account. - * - * @param account - The account to get the balances for. - * @returns The balances and metadata of the account for the given assets. - */ async fetch(account: SolanaKeyringAccount): Promise { - const [nativeAssets, tokenAccounts] = await Promise.all([ - this.#fetchNativeAssets(account), - this.#fetchTokenAccountsMultiple( - [account], - [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], - await this.#configProvider.getActiveNetworks(), - ), - ]); - - const assetTypes = tokenAccounts.map( - (tokenAccount) => tokenAccount.assetType, - ); - - const tokensMetadata = - await this.#tokenApiClient.getTokensMetadata(assetTypes); - - const tokenAssets: TokenAsset[] = tokenAccounts - .filter((tokenAccount) => tokenAccount.assetType.includes('/token:')) - .map((tokenAccount) => { - const { assetType } = tokenAccount; - const { decimals, amount, uiAmountString } = - tokenAccount.token.account.data.parsed.info.tokenAmount; - - return { - assetType, - keyringAccountId: tokenAccount.keyringAccount.id, - network: tokenAccount.scope, - mint: tokenAccount.token.account.data.parsed.info.mint, - pubkey: tokenAccount.token.pubkey, - symbol: tokensMetadata[assetType]?.symbol ?? 'UNKNOWN', - decimals, - rawAmount: amount, - uiAmount: uiAmountString ?? fromTokenUnits(amount, decimals), - }; - }); - - // const nftAssets = await this.#fetchNftAssets(account, tokenAccounts.filter( - // (token) => token.assetType.includes('/nft:'), - // )); - - return [ - ...nativeAssets, - ...tokenAssets, - // ...nftAssets, - ]; - } - - async getNativeAssetTypes(): Promise { - const activeNetworks = await this.#configProvider.getActiveNetworks(); - return activeNetworks.map( - (network) => `${network}/${SolanaCaip19Tokens.SOL}` as const, - ); - } - - async #fetchNativeAssets( - account: SolanaKeyringAccount, - ): Promise { - const nativeAssetsTypes = await this.getNativeAssetTypes(); - - const accountAddress = asAddress(account.address); - - const balancePromises = nativeAssetsTypes.map(async (assetType) => { - const balance = await this.#connection - .getRpc(getNetworkFromToken(assetType)) - .getBalance(accountAddress) - .send(); - - return { - assetType, - keyringAccountId: account.id, - network: getNetworkFromToken(assetType), - address: accountAddress, - symbol: 'SOL', - decimals: 9, - rawAmount: balance.value.toString(), - uiAmount: fromTokenUnits(balance.value, 9), - }; - }); - - const results = (await Promise.allSettled(balancePromises)).flatMap( - (item) => (item.status === 'fulfilled' ? item.value : []), - ); - - return results; - } - - async #fetchNftAssets( - account: SolanaKeyringAccount, - assetIds: NftCaipAssetType[], - ): Promise> { - const accountAddress = asAddress(account.address); - - const nftAssets = - await this.#nftApiClient.listAddressSolanaNfts(accountAddress); - const balances: Record = {}; - - for (const assetId of assetIds) { - const { assetReference } = parseCaipAssetType(assetId); - - const nftAsset = nftAssets.find( - (nft) => nft.tokenAddress === assetReference, - ); - - if (!nftAsset) { - continue; - } - - balances[assetId] = { - unit: nftAsset.nftToken.name, - amount: nftAsset.balance.toString(), - }; - } - - return balances; + return this.#snapAdapter.fetch(account); } async fetchAssetsMarketData( @@ -466,11 +42,7 @@ export class AssetsService { ): Promise< Record> > { - this.#logger.info('Fetching market data for assets', assets); - - const marketData = - await this.#tokenPricesService.getMultipleTokensMarketData(assets); - return marketData; + return this.#snapAdapter.fetchAssetsMarketData(assets); } async save(asset: AssetEntity): Promise { @@ -478,188 +50,11 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { - this.#logger.info('Saving assets', assets); - - /** - * Should we save the assets incrementally? - * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. - * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. - */ - const isIncremental = false; - - const hasZeroAmount = (asset: AssetEntity) => - asset.rawAmount === '0' || asset.uiAmount === '0'; - - const hasNonZeroAmount = (asset: AssetEntity) => !hasZeroAmount(asset); - - const savedAssets = await this.getAll(); - - // Save assets using repository - await this.#assetsRepository.saveMany(assets); - - // Notify the extension about the new assets in a single event - const isNew = (asset: AssetEntity) => - !savedAssets.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - const wasSavedWithZeroAmount = (asset: AssetEntity) => { - const savedAsset = savedAssets.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - return savedAsset && hasZeroAmount(savedAsset); - }; - - const isNativeAsset = (asset: AssetEntity) => - asset.assetType.includes(SolanaCaip19Tokens.SOL); - - const shouldBeInRemovedList = (asset: AssetEntity) => - hasZeroAmount(asset) && !isNativeAsset(asset); // Never remove native assets from the account asset list - - const shouldBeInAddedList = (asset: AssetEntity) => - !shouldBeInRemovedList(asset) && - (!isIncremental || - ((isNew(asset) || wasSavedWithZeroAmount(asset)) && - hasNonZeroAmount(asset))); - - const assetListUpdatedPayload = assets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [ - ...(acc[asset.keyringAccountId]?.added ?? []), - ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), - ], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), - ], - }, - }), - {}, - ); - - // If no assets were added or removed, don't emit the event. - const isEmptyAccountAssetListUpdatedPayload = Object.values( - assetListUpdatedPayload, - ) - .map((item) => item.added.length + item.removed.length) - .every((item) => item === 0); - - if (!isEmptyAccountAssetListUpdatedPayload) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - } - - // Notify the extension about the changed balances in a single event - - const hasChanged = (asset: AssetEntity) => - AssetsService.hasChanged(asset, savedAssets); - - /** - * Build the event payload for snap keyring event `AccountBalancesUpdated`. - * - * @example - * { - * "balances": { - * "keyringAccountId0": { - * "assetType00": { - * "unit": "XYZ", - * "amount": "1234" - * }, - * "assetType01": { - * "unit": "ABC", - * "amount": "5678" - * } - * }, - * "keyringAccountId1": { - * "assetType10": { - * "unit": "XYZ", - * "amount": "42" - * } - * } - * } - * } - */ - const balancesUpdatedPayload = assets - .filter(isIncremental ? hasChanged : () => true) - .reduce( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - ...(acc[asset.keyringAccountId] ?? {}), - [asset.assetType]: { - unit: asset.symbol, - amount: asset.uiAmount, - }, - }, - }), - {}, - ); - - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. - const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes - .some((count) => count > 0); - - // Only emit the event if some balance was changed. - if (isSomeBalanceChanged) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } - } - - /** - * Checks if the asset has changed compared to passed assets lookup. - * - * @param asset - The asset to check. - * @param assetsLookup - The lookup table to check against. - * @returns True if the asset has changed, false otherwise. - */ - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - if (!savedAsset) { - return true; - } - - const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; - const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; - - return rawAmountChanged || uiAmountChanged; + return this.#snapAdapter.saveMany(assets); } async getAll(): Promise { - return this.#assetsRepository.getAll(); - } - - /** - * Resolves account assets via {@link findByAccount}, or `[]` if the account - * is missing. Centralizes the account lookup shared by the read API. - * - * @param accountId - Keyring account ID. - */ - async #getAccountAssetsOrEmpty(accountId: string): Promise { - const account = await this.#accountsService.findById(accountId); - - if (!account) { - return []; - } - - return this.findByAccount(account); + return this.#snapAdapter.getAll(); } /** @@ -672,9 +67,7 @@ export class AssetsService { accountId: string, assetId: CaipAssetType, ): Promise { - const assets = await this.getAccountAssetsByIDs(accountId, [assetId]); - - return assets[assetId] ?? null; + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } /** @@ -688,18 +81,7 @@ export class AssetsService { accountId: string, assetIds: CaipAssetType[], ): Promise> { - if (assetIds.length === 0) { - return {} as Record; - } - - const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); - const assetsByType = new Map( - accountAssets.map((asset) => [asset.assetType, asset]), - ); - - return Object.fromEntries( - assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), - ) as Record; + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } /** @@ -712,9 +94,7 @@ export class AssetsService { scope: CaipChainId, accountId: string, ): Promise { - const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); - - return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } /** @@ -723,45 +103,10 @@ export class AssetsService { * @param accountId - Keyring account ID. */ async getAccountAssets(accountId: string): Promise { - const activeNetworks = await this.#configProvider.getActiveNetworks(); - const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); - - return accountAssets.filter((asset) => - activeNetworks.some((scope) => asset.assetType.startsWith(scope)), - ); + return this.#snapAdapter.getAccountAssets(accountId); } async findByAccount(account: SolanaKeyringAccount): Promise { - const { id: keyringAccountId } = account; - - const savedAssets = - await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); - - // Every account must have at least the native assets. Ensure that they are always present, even if not yet fetched/saved. - const nativeAssetTypes = await this.getNativeAssetTypes(); - const missingNativeAssets: NativeAsset[] = []; - - for (const nativeAssetType of nativeAssetTypes) { - const hasNativeAsset = savedAssets.some( - (asset) => asset.assetType === nativeAssetType, - ); - - if (!hasNativeAsset) { - const network = getNetworkFromToken(nativeAssetType); - - missingNativeAssets.push({ - assetType: nativeAssetType, - keyringAccountId: account.id, - network, - address: account.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }); - } - } - - return [...savedAssets, ...missingNativeAssets]; + return this.#snapAdapter.findByAccount(account); } } diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts new file mode 100644 index 000000000..283c24c4a --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -0,0 +1,118 @@ +import { cloneDeep } from 'lodash'; + +import type { ICache } from '../../../caching/ICache'; +import { InMemoryCache } from '../../../caching/InMemoryCache'; +import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../../clients/nft-api/mocks/mockNftsListResponseMapped'; +import type { NftApiClient } from '../../../clients/nft-api/NftApiClient'; +import type { TokenApiClient } from '../../../clients/token-api-client/TokenApiClient'; +import type { Serializable } from '../../../serialization/types'; +import { + MOCK_ASSET_ENTITY_0, + MOCK_ASSET_ENTITY_1, + MOCK_ASSET_ENTITY_2, +} from '../../../test/mocks/asset-entities'; +import type { AccountsService } from '../../accounts/AccountsService'; +import type { ConfigProvider } from '../../config'; +import type { SolanaConnection } from '../../connection'; +import { mockLogger } from '../../mocks/logger'; +import { createMockConnection } from '../../mocks/mockConnection'; +import type { TokenPricesService } from '../../token-prices/TokenPrices'; +import type { AssetsRepository } from '../AssetsRepository'; +import { SnapAssetsAdapter } from './SnapAssetsAdapter'; + +describe('SnapAssetsAdapter', () => { + let snapAssetsAdapter: SnapAssetsAdapter; + let mockConnection: SolanaConnection; + let mockConfigProvider: ConfigProvider; + let mockAssetsRepository: AssetsRepository; + let mockAccountsService: AccountsService; + let mockTokenApiClient: TokenApiClient; + let mockTokenPricesService: TokenPricesService; + let mockNftApiClient: NftApiClient; + let mockCache: ICache; + + beforeEach(() => { + jest.clearAllMocks(); + mockConnection = createMockConnection(); + + mockConfigProvider = { + getActiveNetworks: jest.fn().mockResolvedValue([]), + } as unknown as ConfigProvider; + + mockTokenApiClient = { + getTokensMetadata: jest.fn().mockResolvedValue({}), + } as unknown as TokenApiClient; + + mockTokenPricesService = { + getMultipleTokensMarketData: jest.fn().mockResolvedValue({}), + } as unknown as TokenPricesService; + + mockCache = new InMemoryCache(mockLogger); + + mockNftApiClient = { + listAddressSolanaNfts: jest + .fn() + .mockResolvedValue(MOCK_NFTS_LIST_RESPONSE_MAPPED.items), + } as unknown as NftApiClient; + + mockAssetsRepository = { + findByKeyringAccountId: jest.fn(), + getAll: jest.fn(), + saveMany: jest.fn(), + } as unknown as AssetsRepository; + + mockAccountsService = { + findById: jest.fn(), + } as unknown as AccountsService; + + snapAssetsAdapter = new SnapAssetsAdapter({ + connection: mockConnection, + logger: mockLogger, + configProvider: mockConfigProvider, + assetsRepository: mockAssetsRepository, + accountsService: mockAccountsService, + tokenApiClient: mockTokenApiClient, + tokenPricesService: mockTokenPricesService, + cache: mockCache, + nftApiClient: mockNftApiClient, + }); + }); + + describe('constructor', () => { + it('creates an adapter instance', () => { + expect(snapAssetsAdapter).toBeDefined(); + }); + }); + + describe('hasChanged', () => { + it('returns true if the raw amount has changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + asset.rawAmount = '123'; + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(SnapAssetsAdapter.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns true if the ui amount has changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + asset.uiAmount = '123'; + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(SnapAssetsAdapter.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns true if the asset does not exist in the lookup', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + const assetsLookup = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; + + expect(SnapAssetsAdapter.hasChanged(asset, assetsLookup)).toBe(true); + }); + + it('returns false if the asset has not changed', () => { + const asset = cloneDeep(MOCK_ASSET_ENTITY_0); + const assetsLookup = [MOCK_ASSET_ENTITY_0]; + + expect(SnapAssetsAdapter.hasChanged(asset, assetsLookup)).toBe(false); + }); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts new file mode 100644 index 000000000..f9371283a --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts @@ -0,0 +1,776 @@ +/* eslint-disable jsdoc/require-returns */ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, + Balance, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { + FungibleAssetMarketData, + FungibleAssetMetadata, +} from '@metamask/snaps-sdk'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; +import { Duration, parseCaipAssetType } from '@metamask/utils'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import type { + AccountInfoBase, + AccountInfoWithPubkey, + Address, +} from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; + +import type { + AssetEntity, + NativeAsset, + SolanaKeyringAccount, + TokenAsset, +} from '../../../../entities'; +import type { ICache } from '../../../caching/ICache'; +import { useCache } from '../../../caching/useCache'; +import type { NftApiClient } from '../../../clients/nft-api/NftApiClient'; +import type { TokenApiClient } from '../../../clients/token-api-client/TokenApiClient'; +import { Network, SolanaCaip19Tokens } from '../../../constants/solana'; +import type { + Caip10Address, + NativeCaipAssetType, + NftCaipAssetType, + TokenCaipAssetType, +} from '../../../constants/solana'; +import type { TokenAccountInfoWithJsonData } from '../../../sdk-extensions/rpc-api'; +import type { Serializable } from '../../../serialization/types'; +import { fromTokenUnits } from '../../../utils/fromTokenUnit'; +import { getNetworkFromToken } from '../../../utils/getNetworkFromToken'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import { tokenAddressToCaip19 } from '../../../utils/tokenAddressToCaip19'; +import type { AccountsService } from '../../accounts/AccountsService'; +import type { ConfigProvider } from '../../config'; +import type { SolanaConnection } from '../../connection'; +import type { TokenPricesService } from '../../token-prices/TokenPrices'; +import type { AssetsRepository } from '../AssetsRepository'; +import type { AssetMetadata, NonFungibleAssetMetadata } from '../types'; + +/** + * Extends a token account as returned by the `getTokenAccountsByOwner` RPC method with the scope and the caip-19 asset type for convenience. + */ +type TokenAccountWithMetadata = { + token: AccountInfoWithPubkey; + scope: Network; + assetType: TokenCaipAssetType; + keyringAccount: SolanaKeyringAccount; +} & Serializable; + +export class SnapAssetsAdapter { + readonly #logger: ILogger; + + readonly #connection: SolanaConnection; + + readonly #configProvider: ConfigProvider; + + readonly #assetsRepository: AssetsRepository; + + readonly #accountsService: AccountsService; + + readonly #tokenApiClient: TokenApiClient; + + readonly #tokenPricesService: TokenPricesService; + + readonly #cache: ICache; + + readonly #nftApiClient: NftApiClient; + + public static readonly cacheTtlsMilliseconds = { + tokenAccountsByOwner: 5 * Duration.Second, + }; + + constructor({ + connection, + logger, + configProvider, + assetsRepository, + accountsService, + tokenApiClient, + tokenPricesService, + cache, + nftApiClient, + }: { + connection: SolanaConnection; + logger: ILogger; + configProvider: ConfigProvider; + assetsRepository: AssetsRepository; + accountsService: AccountsService; + tokenApiClient: TokenApiClient; + tokenPricesService: TokenPricesService; + cache: ICache; + nftApiClient: NftApiClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); + this.#connection = connection; + this.#configProvider = configProvider; + this.#assetsRepository = assetsRepository; + this.#accountsService = accountsService; + this.#tokenApiClient = tokenApiClient; + this.#tokenPricesService = tokenPricesService; + this.#cache = cache; + this.#nftApiClient = nftApiClient; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + tokenAssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { + const nativeAssetTypes = assetTypes.filter((assetType): boolean => + assetType.endsWith(SolanaCaip19Tokens.SOL), + ) as NativeCaipAssetType[]; + const tokenAssetTypes = assetTypes.filter((assetType): boolean => + assetType.includes('/token:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType): boolean => + assetType.includes('/nft:'), + ) as NftCaipAssetType[]; + + return { nativeAssetTypes, tokenAssetTypes, nftAssetTypes }; + } + + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const { + chain: { namespace, reference }, + assetNamespace, + assetReference, + } = parseCaipAssetType(assetType); + + nativeTokensMetadata[assetType] = { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: `${this.#configProvider.get().staticApi.baseUrl}/api/v2/tokenIcons/assets/${namespace}/${reference}/${assetNamespace}/${assetReference}.png`, + units: [ + { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + async #getNftsMetadata( + assetTypes: NftCaipAssetType[], + ): Promise> { + const nftsMetadata = await this.#nftApiClient.getNftsMetadata( + assetTypes.map((assetType) => { + const { assetReference } = parseCaipAssetType(assetType); + return assetReference; + }), + ); + + const nftsMetadataMap: Record = + {}; + + assetTypes.forEach((assetType, index) => { + const nftMetadata = nftsMetadata[index]; + + if (!nftMetadata) { + return; + } + + const metadata = { + name: nftMetadata.name, + symbol: nftMetadata.name, + imageUrl: nftMetadata.imageUrl, + description: nftMetadata.description, + fungible: false as const, + isPossibleSpam: false, // FIXME: The isSpam should be part of the NFT item response, not balance, otherwise we can't get it here + attributes: Object.fromEntries( + nftMetadata.attributes.map( + (attr: { key: string; value: string | number }) => [ + attr.key, + attr.value, + ], + ), + ), + collection: { + name: nftMetadata.collectionName, + address: nftMetadata.onchainCollectionAddress as Caip10Address, + symbol: nftMetadata.collectionSymbol, + tokenCount: nftMetadata.collectionCount, + creator: '' as Caip10Address, // FIXME: There can be more than one creator + imageUrl: nftMetadata.collectionImageUrl ?? '', + }, + }; + + nftsMetadataMap[assetType] = metadata; + }); + + return nftsMetadataMap; + } + + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.log('Fetching metadata for assets', assetTypes); + + const { nativeAssetTypes, tokenAssetTypes } = + this.#splitAssetsByType(assetTypes); + + const [ + nativeTokensMetadata, + tokensMetadata, + // nftMetadata, + ] = await Promise.all([ + this.#getNativeTokensMetadata(nativeAssetTypes), + this.#tokenApiClient.getTokensMetadata(tokenAssetTypes), + // this.#getNftsMetadata(nftAssetTypes), + ]); + + return { + ...nativeTokensMetadata, + ...tokensMetadata, + // ...nftMetadata, + }; + } + + /** + * Matrix-fetches all token accounts owned by the given address on the specified networks and program ids, + * and merges the results into a single array. Each individual token is augmented with the scope and the caip-19 asset type for convenience. + * + * It caches the results for each pair of scope and program id. + * + * @param accounts - The owners of the token accounts. + * @param programIds - The program ids to fetch the token accounts for. + * @param scopes - The networks to fetch the token accounts for. + * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. + */ + async #fetchTokenAccountsMultiple( + accounts: SolanaKeyringAccount[], + programIds: Address[] = [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], + scopes: Network[] = [Network.Mainnet], + ): Promise { + if (programIds.length === 0 || scopes.length === 0) { + return []; + } + + // Create all combinations of account, programId, and scope + const combinations = accounts.flatMap((account) => + programIds.flatMap((programId) => + scopes.map((scope) => ({ account, programId, scope })), + ), + ); + + const fetchTokenAccountsCached = useCache< + [SolanaKeyringAccount, Address, Network], + TokenAccountWithMetadata[] + >(this.#fetchTokenAccounts.bind(this), this.#cache, { + functionName: 'SnapAssetsAdapter:fetchTokenAccounts', + ttlMilliseconds: + SnapAssetsAdapter.cacheTtlsMilliseconds.tokenAccountsByOwner, + generateCacheKey: (functionName, args) => { + const [account, programId, scope] = args; + return `${functionName}:${account.id}:${programId}:${scope}`; + }, + }); + + const responses = await Promise.allSettled( + combinations.map(async ({ account, programId, scope }) => { + const response = await fetchTokenAccountsCached( + account, + programId, + scope, + ); + return response; + }), + ); + + return responses.flatMap((item) => + item.status === 'fulfilled' ? item.value : [], + ); + } + + /** + * Fetches the token accounts for the given owner and program id on the specified scope. + * + * @param account - The owner of the token accounts. + * @param programId - The program id to fetch the token accounts for. + * @param scope - The scope to fetch the token accounts for. + * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. + */ + async #fetchTokenAccounts( + account: SolanaKeyringAccount, + programId: Address = TOKEN_PROGRAM_ADDRESS, + scope: Network = Network.Mainnet, + ): Promise { + const response = await this.#connection + .getRpc(scope) + .getTokenAccountsByOwner( + asAddress(account.address), + { programId }, + { encoding: 'jsonParsed' }, + ) + .send(); + + const tokens = response.value; + + // Attach the scope and the caip-19 asset type to each token account for easier future reference + return tokens.map( + (token) => + ({ + token, + scope, + assetType: tokenAddressToCaip19( + scope, + token.account.data.parsed.info.mint, + ), + keyringAccount: account, + }) as TokenAccountWithMetadata, + ); + } + + /** + * Fetches all assets for the given account. + * + * @param account - The account to get the balances for. + * @returns The balances and metadata of the account for the given assets. + */ + async fetch(account: SolanaKeyringAccount): Promise { + const [nativeAssets, tokenAccounts] = await Promise.all([ + this.#fetchNativeAssets(account), + this.#fetchTokenAccountsMultiple( + [account], + [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], + await this.#configProvider.getActiveNetworks(), + ), + ]); + + const assetTypes = tokenAccounts.map( + (tokenAccount) => tokenAccount.assetType, + ); + + const tokensMetadata = + await this.#tokenApiClient.getTokensMetadata(assetTypes); + + const tokenAssets: TokenAsset[] = tokenAccounts + .filter((tokenAccount) => tokenAccount.assetType.includes('/token:')) + .map((tokenAccount) => { + const { assetType } = tokenAccount; + const { decimals, amount, uiAmountString } = + tokenAccount.token.account.data.parsed.info.tokenAmount; + + return { + assetType, + keyringAccountId: tokenAccount.keyringAccount.id, + network: tokenAccount.scope, + mint: tokenAccount.token.account.data.parsed.info.mint, + pubkey: tokenAccount.token.pubkey, + symbol: tokensMetadata[assetType]?.symbol ?? 'UNKNOWN', + decimals, + rawAmount: amount, + uiAmount: uiAmountString ?? fromTokenUnits(amount, decimals), + }; + }); + + // const nftAssets = await this.#fetchNftAssets(account, tokenAccounts.filter( + // (token) => token.assetType.includes('/nft:'), + // )); + + return [ + ...nativeAssets, + ...tokenAssets, + // ...nftAssets, + ]; + } + + async getNativeAssetTypes(): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + return activeNetworks.map( + (network) => `${network}/${SolanaCaip19Tokens.SOL}` as const, + ); + } + + async #fetchNativeAssets( + account: SolanaKeyringAccount, + ): Promise { + const nativeAssetsTypes = await this.getNativeAssetTypes(); + + const accountAddress = asAddress(account.address); + + const balancePromises = nativeAssetsTypes.map(async (assetType) => { + const balance = await this.#connection + .getRpc(getNetworkFromToken(assetType)) + .getBalance(accountAddress) + .send(); + + return { + assetType, + keyringAccountId: account.id, + network: getNetworkFromToken(assetType), + address: accountAddress, + symbol: 'SOL', + decimals: 9, + rawAmount: balance.value.toString(), + uiAmount: fromTokenUnits(balance.value, 9), + }; + }); + + const results = (await Promise.allSettled(balancePromises)).flatMap( + (item) => (item.status === 'fulfilled' ? item.value : []), + ); + + return results; + } + + async fetchAssetsMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + this.#logger.info('Fetching market data for assets', assets); + + const marketData = + await this.#tokenPricesService.getMultipleTokensMarketData(assets); + return marketData; + } + + async #fetchNftAssets( + account: SolanaKeyringAccount, + assetIds: NftCaipAssetType[], + ): Promise> { + const accountAddress = asAddress(account.address); + + const nftAssets = + await this.#nftApiClient.listAddressSolanaNfts(accountAddress); + const balances: Record = {}; + + for (const assetId of assetIds) { + const { assetReference } = parseCaipAssetType(assetId); + + const nftAsset = nftAssets.find( + (nft) => nft.tokenAddress === assetReference, + ); + + if (!nftAsset) { + continue; + } + + balances[assetId] = { + unit: nftAsset.nftToken.name, + amount: nftAsset.balance.toString(), + }; + } + + return balances; + } + + async save(asset: AssetEntity): Promise { + await this.saveMany([asset]); + } + + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + /** + * Should we save the assets incrementally? + * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. + * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. + */ + const isIncremental = false; + + const hasZeroAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0' || asset.uiAmount === '0'; + + const hasNonZeroAmount = (asset: AssetEntity): boolean => + !hasZeroAmount(asset); + + const savedAssets = await this.getAll(); + + // Save assets using repository + await this.#assetsRepository.saveMany(assets); + + // Notify the extension about the new assets in a single event + const isNew = (asset: AssetEntity): boolean => + !savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + const wasSavedWithZeroAmount = ( + asset: AssetEntity, + ): boolean | undefined => { + const savedAsset = savedAssets.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + return savedAsset && hasZeroAmount(savedAsset); + }; + + const isNativeAsset = (asset: AssetEntity): boolean => + asset.assetType.includes(SolanaCaip19Tokens.SOL); + + const shouldBeInRemovedList = (asset: AssetEntity): boolean => + hasZeroAmount(asset) && !isNativeAsset(asset); // Never remove native assets from the account asset list + + const shouldBeInAddedList = (asset: AssetEntity): boolean => + !shouldBeInRemovedList(asset) && + (!isIncremental || + ((isNew(asset) || wasSavedWithZeroAmount(asset)) && + hasNonZeroAmount(asset))); + + const assetListUpdatedPayload = assets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [ + ...(acc[asset.keyringAccountId]?.added ?? []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), + ], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), + ], + }, + }), + {}, + ); + + // If no assets were added or removed, don't emit the event. + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + // Notify the extension about the changed balances in a single event + + const hasChanged = (asset: AssetEntity): boolean => + SnapAssetsAdapter.hasChanged(asset, savedAssets); + + /** + * Build the event payload for snap keyring event `AccountBalancesUpdated`. + * + * @example + * { + * "balances": { + * "keyringAccountId0": { + * "assetType00": { + * "unit": "XYZ", + * "amount": "1234" + * }, + * "assetType01": { + * "unit": "ABC", + * "amount": "5678" + * } + * }, + * "keyringAccountId1": { + * "assetType10": { + * "unit": "XYZ", + * "amount": "42" + * } + * } + * } + * } + */ + const balancesUpdatedPayload = assets + .filter(isIncremental ? hasChanged : (): boolean => true) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + /** + * Checks if the asset has changed compared to passed assets lookup. + * + * @param asset - The asset to check. + * @param assetsLookup - The lookup table to check against. + * @returns True if the asset has changed, false otherwise. + */ + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + const savedAsset = assetsLookup.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + if (!savedAsset) { + return true; + } + + const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; + const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; + + return rawAmountChanged || uiAmountChanged; + } + + async getAll(): Promise { + return this.#assetsRepository.getAll(); + } + + /** + * Resolves account assets via {@link findByAccount}, or `[]` if the account + * is missing. Centralizes the account lookup shared by the read API. + * + * @param accountId - Keyring account ID. + */ + async #getAccountAssetsOrEmpty(accountId: string): Promise { + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + return this.findByAccount(account); + } + + /** + * Returns a single account asset by CAIP-19 ID, or `null` if missing. + * + * @param accountId - Keyring account ID. + * @param assetId - CAIP-19 asset ID. + */ + async getAccountAssetByID( + accountId: string, + assetId: CaipAssetType, + ): Promise { + const assets = await this.getAccountAssetsByIDs(accountId, [assetId]); + + return assets[assetId] ?? null; + } + + /** + * Returns account assets for the given CAIP-19 IDs, keyed by asset ID. + * Missing assets are `null`. + * + * @param accountId - Keyring account ID. + * @param assetIds - CAIP-19 asset IDs to resolve. + */ + async getAccountAssetsByIDs( + accountId: string, + assetIds: CaipAssetType[], + ): Promise> { + if (assetIds.length === 0) { + return {} as Record; + } + + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + const assetsByType = new Map( + accountAssets.map((asset) => [asset.assetType, asset]), + ); + + return Object.fromEntries( + assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), + ) as Record; + } + + /** + * Returns controller-backed assets for an account on the given Solana scope. + * + * @param scope - CAIP-2 chain ID to filter results. + * @param accountId - Keyring account ID. + */ + async getAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + ): Promise { + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + + return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); + } + + /** + * Returns assets for an account across all active Solana networks. + * + * @param accountId - Keyring account ID. + */ + async getAccountAssets(accountId: string): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + + return accountAssets.filter((asset) => + activeNetworks.some((scope) => asset.assetType.startsWith(scope)), + ); + } + + async findByAccount(account: SolanaKeyringAccount): Promise { + const { id: keyringAccountId, address } = account; + + const savedAssets = + await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); + + // Every account must have at least the native assets. Ensure that they are always present, even if not yet fetched/saved. + const nativeAssetTypes = await this.getNativeAssetTypes(); + const missingNativeAssets: NativeAsset[] = []; + + for (const nativeAssetType of nativeAssetTypes) { + const hasNativeAsset = savedAssets.some( + (asset) => asset.assetType === nativeAssetType, + ); + + if (!hasNativeAsset) { + // Create a placeholder native asset with zero balance + // This will be updated when assets are actually fetched + const network = getNetworkFromToken(nativeAssetType); + + missingNativeAssets.push({ + assetType: nativeAssetType, + keyringAccountId: account.id, + network, + address, + symbol: 'SOL', + decimals: 9, + rawAmount: '0', + uiAmount: '0', + }); + } + } + + return [...savedAssets, ...missingNativeAssets]; + } +} 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 cfec7e81d..494c206ba 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/SnapAssetsAdapter'; export * from './AssetsRepository'; export * from './AssetsService'; export * from './TokenHelper'; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 32fc38f63..905ab502d 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -13,6 +13,7 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, + SnapAssetsAdapter, AssetsRepository, AssetsService, KeyringAccountMonitor, @@ -148,18 +149,22 @@ const assetsRepository = new AssetsRepository(state); const accountsRepository = new AccountsRepository(state); const accountsService = new AccountsService(accountsRepository); -const assetsService = new AssetsService({ +const snapAssetsAdapter = new SnapAssetsAdapter({ connection, logger, configProvider, assetsRepository, accountsService, tokenApiClient, - cache: inMemoryCache, tokenPricesService, + cache: inMemoryCache, nftApiClient, }); +const assetsService = new AssetsService({ + snapAdapter: snapAssetsAdapter, +}); + const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper( tokenHelper, From dc2ee182cc6fa13a5be214b6e2f3a12974651bf4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:38:58 +0000 Subject: [PATCH 2/8] 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 | 4 + .../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 | 4 + 15 files changed, 1026 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 6f652af56..008180230 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 2e99a34bd..7e9b11984 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -52,10 +52,14 @@ }, "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", "@metamask/snaps-sdk": "^11.2.0", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 98c696194..2030bdde5 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 b773116ef..5f1036193 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 { mockLogger } from '../mocks/logger'; import { createMockConnection } from '../mocks/mockConnection'; import { MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE } from '../mocks/mockSolanaRpcResponses'; 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 905ab502d..7eb51ded2 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); @@ -295,22 +338,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 ee0ba714f..78844cd68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3667,10 +3667,14 @@ __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" "@metamask/snaps-sdk": "npm:^11.2.0" From a08c054b837fee77343f3b0035f1c17409887d48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:46:34 +0000 Subject: [PATCH 3/8] fix(solana-wallet-snap): lint CoreAssetsAdapter mapping tests and sync manifest shasum Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../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 +++- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 2030bdde5..de12a5559 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": "xbiQfxthMCvd75y7AYfX1H2AdaSSsZmSd/oKXvRO6Gw=", + "shasum": "YNtYDaS0dq5Nl7+TurSH6J82OFh1tkfizcCerNeoE18=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index 5f1036193..8bc60907a 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 b3c9d5dd1bfaf5bcb59ae7cc3408e668c8e514a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:10:29 +0000 Subject: [PATCH 4/8] 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 008180230..b33c306b6 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 400e0a083..11f5d2989 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsService.ts @@ -135,7 +135,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 bd729d687fecce004807d8b17097db5da2437797 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:12:57 +0000 Subject: [PATCH 5/8] chore(solana-wallet-snap): sync snap manifest shasum after Core adapter cleanup 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 de12a5559..182c8617c 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": "YNtYDaS0dq5Nl7+TurSH6J82OFh1tkfizcCerNeoE18=", + "shasum": "2ZJZAnGhLs7mB0gCCb9ffito6qcx8KMGbteQ0wwtsYw=", "location": { "npm": { "filePath": "dist/bundle.js", From 333a019daab05b43d0172521e0d26e6d67ec1fba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:50:34 +0000 Subject: [PATCH 6/8] feat(solana-wallet-snap): route asset reads through Core when migration is on Pass RemoteFeatureFlagsProvider into AssetsService and route getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, getAccountAssets, fetch, and saveMany through CoreAssetsAdapter when the Solana assets migration flag is active. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../services/assets/AssetsService.test.ts | 168 +++++++++++++++++- .../src/core/services/assets/AssetsService.ts | 54 +++++- .../solana-wallet-snap/src/snapContext.ts | 3 +- 4 files changed, 218 insertions(+), 8 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index b33c306b6..3c007f13d 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Route Solana asset reads, fetch, and save through `CoreAssetsAdapter` when the assets migration feature flag is active. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) - 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)) - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) 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 8bc60907a..fd948a366 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 @@ -1,5 +1,10 @@ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { ICache } from '../../caching/ICache'; @@ -7,7 +12,7 @@ import { InMemoryCache } from '../../caching/InMemoryCache'; import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; -import { Network } from '../../constants/solana'; +import { KnownCaip19Id, Network } from '../../constants/solana'; import type { Serializable } from '../../serialization/types'; import { MOCK_ASSET_ENTITIES, @@ -36,6 +41,8 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; let snapAssetsAdapter: SnapAssetsAdapter; + let coreAdapter: CoreAssetsAdapter; + let mockGetFeatureFlag: jest.Mock; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; @@ -45,6 +52,10 @@ describe('AssetsService', () => { let mockNftApiClient: NftApiClient; let mockCache: ICache; + const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { + mockGetFeatureFlag.mockResolvedValue({ stage }); + }; + beforeEach(() => { jest.clearAllMocks(); mockConnection = createMockConnection(); @@ -102,7 +113,7 @@ describe('AssetsService', () => { nftApiClient: mockNftApiClient, }); - const coreAdapter = new CoreAssetsAdapter({ + coreAdapter = new CoreAssetsAdapter({ getAccountAssetByID: jest.fn().mockResolvedValue(null), getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.fn().mockResolvedValue({}), @@ -111,9 +122,16 @@ describe('AssetsService', () => { mockConfigProvider.getActiveNetworks.bind(mockConfigProvider), }); + mockGetFeatureFlag = jest.fn().mockResolvedValue({ + stage: SnapsAssetsMigrationStage.Off, + }); + assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter, + remoteFeatureFlagsProvider: { + getFeatureFlag: mockGetFeatureFlag, + } as unknown as RemoteFeatureFlagsProvider, }); }); @@ -824,4 +842,150 @@ describe('AssetsService', () => { ).not.toHaveBeenCalled(); }); }); + + describe('assets migration', () => { + const accountId = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; + const activeMigrationStage = + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; + + it('routes getAccountAssetByID through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssetByID') + .mockResolvedValue(MOCK_ASSET_ENTITY_0); + + const asset = await assetsService.getAccountAssetByID( + accountId, + KnownCaip19Id.SolMainnet, + ); + + expect(coreAdapter.getAccountAssetByID).toHaveBeenCalledWith( + accountId, + KnownCaip19Id.SolMainnet, + ); + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_0); + }); + + it('routes getAccountAssetsByIDs through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.SolMainnet]: MOCK_ASSET_ENTITY_0, + [KnownCaip19Id.UsdcMainnet]: MOCK_ASSET_ENTITY_1, + }); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(coreAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + accountId, + [KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet], + ); + expect(results[KnownCaip19Id.SolMainnet]).toStrictEqual( + MOCK_ASSET_ENTITY_0, + ); + expect(results[KnownCaip19Id.UsdcMainnet]).toStrictEqual( + MOCK_ASSET_ENTITY_1, + ); + }); + + it('routes getAccountAssetsByScope through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssetsByScope') + .mockResolvedValue([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + accountId, + ); + + expect(coreAdapter.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + accountId, + ); + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + + it('routes getAccountAssets through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssets') + .mockResolvedValue([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssets(accountId); + + expect(coreAdapter.getAccountAssets).toHaveBeenCalledWith(accountId); + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + + it('fetches only snap-owned assets when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'fetch').mockResolvedValue([]); + + const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(coreAdapter.fetch).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + expect(assets).toStrictEqual([]); + }); + + it('emits only snap-owned assets and does not persist when migration is active', async () => { + setMigrationStage(activeMigrationStage); + + const nftAsset = { + assetType: `${Network.Mainnet}/nft:NftMintAddress`, + keyringAccountId: accountId, + network: Network.Mainnet, + mint: 'NftMintAddress', + pubkey: 'NftTokenAccount', + symbol: 'NFT', + rawAmount: '1', + uiAmount: '1', + } as const; + + await assetsService.saveMany([MOCK_ASSET_ENTITY_0, nftAsset]); + + expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [accountId]: { + added: [nftAsset.assetType], + removed: [], + }, + }, + }, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [accountId]: { + [nftAsset.assetType]: { + unit: 'NFT', + amount: '1', + }, + }, + }, + }, + ); + }); + + it('reads the Solana migration flag key', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'getAccountAssets').mockResolvedValue([]); + + await assetsService.getAccountAssets(accountId); + + expect(mockGetFeatureFlag).toHaveBeenCalledWith( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana, + ); + }); + }); }); 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 af43b6f0b..c8c51e31a 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,4 +1,10 @@ /* eslint-disable jsdoc/require-returns */ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; @@ -8,31 +14,45 @@ import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; /** - * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter is initialized for upcoming - * routing without changing callers. + * Assets domain facade. Reads, fetch, and save use the Snap adapter while + * migration is off, and the Core adapter once migration is active. When + * migration is active, fetch returns only snap-owned assets and save publishes + * them via keyring events without local persistence. */ 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 #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + readonly cacheTtlsMilliseconds: typeof SnapAssetsAdapter.cacheTtlsMilliseconds; constructor({ snapAdapter, coreAdapter, + remoteFeatureFlagsProvider, }: { snapAdapter: SnapAssetsAdapter; coreAdapter: CoreAssetsAdapter; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; }) { this.#snapAdapter = snapAdapter; this.#coreAdapter = coreAdapter; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; this.cacheTtlsMilliseconds = SnapAssetsAdapter.cacheTtlsMilliseconds; } + async #shouldReturnAssetsFromCore(): Promise { + const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana, + ); + return ( + parseSnapsAssetsMigrationStage(flagValue) !== + SnapsAssetsMigrationStage.Off + ); + } + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } @@ -44,6 +64,10 @@ export class AssetsService { } async fetch(account: SolanaKeyringAccount): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.fetch(account); + } + return this.#snapAdapter.fetch(account); } @@ -63,6 +87,10 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.saveMany(assets); + } + return this.#snapAdapter.saveMany(assets); } @@ -80,6 +108,10 @@ export class AssetsService { accountId: string, assetId: CaipAssetType, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetByID(accountId, assetId); + } + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } @@ -94,6 +126,10 @@ export class AssetsService { accountId: string, assetIds: CaipAssetType[], ): Promise> { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); + } + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } @@ -107,6 +143,10 @@ export class AssetsService { scope: CaipChainId, accountId: string, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByScope(scope, accountId); + } + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } @@ -116,6 +156,10 @@ export class AssetsService { * @param accountId - Keyring account ID. */ async getAccountAssets(accountId: string): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssets(accountId); + } + return this.#snapAdapter.getAccountAssets(accountId); } diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 7eb51ded2..70ba0ec98 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -94,7 +94,7 @@ export type SnapExecutionContext = { accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; /** - * Core messenger plumbing (routing wired in a follow-up PR). + * Core messenger plumbing. */ coreMessenger: CoreMessengerClient; remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; @@ -206,6 +206,7 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter: coreAssetsAdapter, + remoteFeatureFlagsProvider, }); const transactionsRepository = new TransactionsRepository(state); From feeef1d035f8b7df5f8d9075c1633b3ec48f9070 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:16:16 +0000 Subject: [PATCH 7/8] feat(solana-wallet-snap): skip Snap asset persistence when Core migration is on Route reads through CoreAssetsAdapter when the Solana assets flag is active. Fetch and save are no-ops because Solana has no snap-owned assets. KeyringAccountMonitor still discovers transactions but no longer persists balances from websocket notifications. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- .../services/assets/AssetsService.test.ts | 60 +++++-------------- .../src/core/services/assets/AssetsService.ts | 22 +++++-- .../KeyringAccountMonitor.test.ts | 34 +++++++++++ .../subscriptions/KeyringAccountMonitor.ts | 32 ++++++---- 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 3c007f13d..204a73246 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Route Solana asset reads, fetch, and save through `CoreAssetsAdapter` when the assets migration feature flag is active. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) +- Route Solana asset reads through `CoreAssetsAdapter` when the assets migration feature flag is active. Fetch and save become no-ops (Solana has no snap-owned assets), and `KeyringAccountMonitor` stops persisting balances from websocket notifications. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) - 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)) - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) 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 fd948a366..52b894d8d 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 @@ -920,61 +920,33 @@ describe('AssetsService', () => { expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); }); - it('fetches only snap-owned assets when migration is active', async () => { + it('returns no assets from fetch when migration is active', async () => { setMigrationStage(activeMigrationStage); - jest.spyOn(coreAdapter, 'fetch').mockResolvedValue([]); + const snapFetchSpy = jest.spyOn(snapAssetsAdapter, 'fetch'); const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - expect(coreAdapter.fetch).toHaveBeenCalledWith( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); + expect(snapFetchSpy).not.toHaveBeenCalled(); expect(assets).toStrictEqual([]); }); - it('emits only snap-owned assets and does not persist when migration is active', async () => { + it('does not persist or emit when saveMany is called and migration is active', async () => { setMigrationStage(activeMigrationStage); - const nftAsset = { - assetType: `${Network.Mainnet}/nft:NftMintAddress`, - keyringAccountId: accountId, - network: Network.Mainnet, - mint: 'NftMintAddress', - pubkey: 'NftTokenAccount', - symbol: 'NFT', - rawAmount: '1', - uiAmount: '1', - } as const; - - await assetsService.saveMany([MOCK_ASSET_ENTITY_0, nftAsset]); + await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [accountId]: { - added: [nftAsset.assetType], - removed: [], - }, - }, - }, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [accountId]: { - [nftAsset.assetType]: { - unit: 'NFT', - amount: '1', - }, - }, - }, - }, - ); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }); + + it('reports Core assets as active when the migration flag is on', async () => { + setMigrationStage(activeMigrationStage); + + await expect(assetsService.isUsingCoreAssets()).resolves.toBe(true); + }); + + it('reports Core assets as inactive when the migration flag is off', async () => { + await expect(assetsService.isUsingCoreAssets()).resolves.toBe(false); }); it('reads the Solana migration flag key', async () => { 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 c8c51e31a..08139b359 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -14,10 +14,10 @@ import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; /** - * Assets domain facade. Reads, fetch, and save use the Snap adapter while - * migration is off, and the Core adapter once migration is active. When - * migration is active, fetch returns only snap-owned assets and save publishes - * them via keyring events without local persistence. + * Assets domain facade. Reads use the Snap adapter while migration is off, and + * the Core adapter once migration is active. Solana has no snap-owned assets, + * so when migration is active fetch returns nothing and save is a no-op — + * Core owns fungible balances and the Snap does not persist or publish them. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; @@ -53,6 +53,16 @@ export class AssetsService { ); } + /** + * Whether asset reads come from AssetsController. When true, the Snap must + * not fetch, persist, or websocket-monitor balances — Core already does. + * + * @returns Whether the Solana assets migration flag is active. + */ + async isUsingCoreAssets(): Promise { + return this.#shouldReturnAssetsFromCore(); + } + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } @@ -65,7 +75,7 @@ export class AssetsService { async fetch(account: SolanaKeyringAccount): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.fetch(account); + return []; } return this.#snapAdapter.fetch(account); @@ -88,7 +98,7 @@ export class AssetsService { async saveMany(assets: AssetEntity[]): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.saveMany(assets); + return; } return this.#snapAdapter.saveMany(assets); diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts index 578e2dca6..aa0331b25 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts @@ -127,6 +127,7 @@ describe('KeyringAccountMonitor', () => { mockAssetsService = { getTokenAccountsByOwnerMultiple: jest.fn(), save: jest.fn(), + isUsingCoreAssets: jest.fn().mockResolvedValue(false), getAssetsMetadata: jest.fn().mockImplementation((assetType) => ({ [assetType]: { symbol: 'USDC', @@ -384,6 +385,22 @@ describe('KeyringAccountMonitor', () => { ); }); + it('does not persist native balances when Core assets migration is active', async () => { + jest + .spyOn(mockAssetsService, 'isUsingCoreAssets') + .mockResolvedValue(true); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = accountNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).not.toHaveBeenCalled(); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + it('fetches and saves the transaction that caused the native asset balance to change', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); @@ -546,6 +563,23 @@ describe('KeyringAccountMonitor', () => { ); }); + it('does not persist token balances when Core assets migration is active', async () => { + jest + .spyOn(mockAssetsService, 'isUsingCoreAssets') + .mockResolvedValue(true); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = programNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).not.toHaveBeenCalled(); + expect(mockTokenHelper.amountToUiAmountForMint).not.toHaveBeenCalled(); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + it('fetches and saves the transaction that caused the token asset to change', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts index 38eb4c325..b9b71601a 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts @@ -34,7 +34,8 @@ import { isSpam } from '../transactions/utils/isSpam'; * - It gets updates when the balance of token assets change by subscribing to each RPC token account. * * On each update: - * - It saves the new balance. Under the hood, AssetsService also notifies the extension. + * - While Snap still owns balances, it saves the new balance. Under the hood, AssetsService also notifies the extension. + * - Once Core assets migration is active, balance persistence is skipped (Core already tracks fungibles). Transaction discovery continues. * - It fetches the transaction that caused the native asset or token asset to change and saves it. Under the hood, TransactionsService also notifies the extension. */ export class KeyringAccountMonitor { @@ -328,17 +329,21 @@ export class KeyringAccountMonitor { const decimals = 9; + const persistAssets = !(await this.#assetsService.isUsingCoreAssets()); + await Promise.all([ - this.#assetsService.save({ - assetType: `${network}/${SolanaCaip19Tokens.SOL}`, - keyringAccountId: keyringAccount.id, - network, - address, - symbol: 'SOL', - decimals, - rawAmount: accountLamports.toString(), - uiAmount: fromTokenUnits(accountLamports, decimals), - }), + persistAssets + ? this.#assetsService.save({ + assetType: `${network}/${SolanaCaip19Tokens.SOL}`, + keyringAccountId: keyringAccount.id, + network, + address, + symbol: 'SOL', + decimals, + rawAmount: accountLamports.toString(), + uiAmount: fromTokenUnits(accountLamports, decimals), + }) + : Promise.resolve(), this.#saveCausingTransaction(keyringAccount, network, address), ]); } @@ -386,6 +391,11 @@ export class KeyringAccountMonitor { throw new Error(`No keyring account found with address: ${owner}`); } + if (await this.#assetsService.isUsingCoreAssets()) { + await this.#saveCausingTransaction(keyringAccount, network, pubkey); + return; + } + /** * WARNING: This is to compensate for the fact that the notification returned by Infura's programSubscribe * includes a uiAmount/uiAmountString that does not take into account the mint's multiplier (if any). From e7c52db753e34073f46adf985d03f4c0a1fd0649 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:17:24 +0000 Subject: [PATCH 8/8] fix(solana-wallet-snap): lint Core migration routing and sync manifest shasum Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../src/core/services/assets/AssetsService.test.ts | 4 ++-- .../src/core/services/assets/AssetsService.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 182c8617c..ad36f14fa 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": "2ZJZAnGhLs7mB0gCCb9ffito6qcx8KMGbteQ0wwtsYw=", + "shasum": "SWB+3GxlGtesIJB2wxYfykU0SmJbn6oonAilJxUEqBk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index 52b894d8d..7f8296b81 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 @@ -942,11 +942,11 @@ describe('AssetsService', () => { it('reports Core assets as active when the migration flag is on', async () => { setMigrationStage(activeMigrationStage); - await expect(assetsService.isUsingCoreAssets()).resolves.toBe(true); + expect(await assetsService.isUsingCoreAssets()).toBe(true); }); it('reports Core assets as inactive when the migration flag is off', async () => { - await expect(assetsService.isUsingCoreAssets()).resolves.toBe(false); + expect(await assetsService.isUsingCoreAssets()).toBe(false); }); it('reads the Solana migration flag key', async () => { 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 08139b359..94690f93c 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -101,7 +101,7 @@ export class AssetsService { return; } - return this.#snapAdapter.saveMany(assets); + await this.#snapAdapter.saveMany(assets); } async getAll(): Promise {