From b946882eed86052cad22ce5dbf8e6b72aebc7ea8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 14:40:21 +0000 Subject: [PATCH 1/2] 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 + .../services/assets/AssetsService.test.ts | 47 +- .../src/core/services/assets/AssetsService.ts | 700 +--------------- .../assets/adapters/SnapAssetsAdapter.test.ts | 119 +++ .../assets/adapters/SnapAssetsAdapter.ts | 775 ++++++++++++++++++ .../src/core/services/assets/index.ts | 1 + .../solana-wallet-snap/src/snapContext.ts | 9 +- 8 files changed, 973 insertions(+), 684 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 c25c53f2c..e6e00f264 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -344,13 +344,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/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index cadfa4956..7ed66f964 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 @@ -25,6 +25,7 @@ import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import { AssetsService } from './AssetsService'; @@ -34,6 +35,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; + let snapAssetsAdapter: SnapAssetsAdapter; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; @@ -88,7 +90,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, @@ -99,6 +101,10 @@ describe('AssetsService', () => { cache: mockCache, nftApiClient: mockNftApiClient, }); + + assetsService = new AssetsService({ + snapAdapter: snapAssetsAdapter, + }); }); describe('fetch', () => { @@ -174,6 +180,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 aac6b3ae0..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,460 +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 { Logger } from '@metamask/snap-networks-utils/logger'; -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 { 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: Logger; - - 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: Logger; - configProvider: ConfigProvider; - assetsRepository: AssetsRepository; - accountsService: AccountsService; - tokenApiClient: TokenApiClient; - tokenPricesService: TokenPricesService; - cache: ICache; - nftApiClient: NftApiClient; - }) { - this.#logger = logger.withPrefix('[🪙 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( @@ -465,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 { @@ -477,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(); } /** @@ -671,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); } /** @@ -687,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); } /** @@ -711,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); } /** @@ -722,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..7c971bd4e --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -0,0 +1,119 @@ +/* eslint-disable jest/no-mocks-import -- Test fixtures are imported directly. */ +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 { mockLogger } from '../../__mocks__/logger'; +import { createMockConnection } from '../../__mocks__/mockConnection'; +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 { 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..685320e9b --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts @@ -0,0 +1,775 @@ +/* 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 { Logger } from '@metamask/snap-networks-utils/logger'; +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 { 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: Logger; + + 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: Logger; + configProvider: ConfigProvider; + assetsRepository: AssetsRepository; + accountsService: AccountsService; + tokenApiClient: TokenApiClient; + tokenPricesService: TokenPricesService; + cache: ICache; + nftApiClient: NftApiClient; + }) { + this.#logger = logger.withPrefix('[🪙 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 fb5fb54d9..15f7b5cf0 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 42ca53074c98cc52983625ff6a060f07136a319a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 14:30:40 +0000 Subject: [PATCH 2/2] fix(solana-wallet-snap): update snap.manifest.json shasum after adapter extract The SnapAssetsAdapter extraction changed the bundle, so CI's mm-snap build rewrote the shasum and failed the dirty-tree check. 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 744d35c2a..57d2ca99d 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": "dlsGUfU2sircQb9+jdv2u74wqp3t/Zn82O0JrY2lHAw=", + "shasum": "2c2WNzBfLdP/UYJdepbBVpyYrpjzuSob/iobC9JJRLo=", "location": { "npm": { "filePath": "dist/bundle.js",