From c0b1fa838b70052d97d11c5e2bb4bc3357e241c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 14:40:21 +0000 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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",