diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 3d37116f..53933ffa 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extract Snap-owned balance fetch/persist/read logic into `SnapAssetsAdapter`; `AssetsService` delegates account asset reads and saves through the adapter (no Core routing yet). +- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssetsForAllActiveScopes`, and routing Keyring, Send, send render, and `refreshSend` through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) - This package was migrated from [snap-solana-wallet](https://github.com/MetaMask/snap-solana-wallet). See the source repository for the original [changelog](https://github.com/MetaMask/snap-solana-wallet/blob/main/packages/snap/CHANGELOG.md). ([#72](https://github.com/MetaMask/internal-snaps/pull/72)) [Unreleased]: https://github.com/MetaMask/internal-snaps/ diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index fe947258..84e85f44 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": "ml2uYEdvkS+53VKce/0XkF/NIeewCD/1X9MbwndcV1M=", + "shasum": "j3n+ylk6thi2g8W+qqmF7S56WcW2d9Kq5AhjCkqZSAk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx index a0eb261f..64877134 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx @@ -1,4 +1,10 @@ -import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import { + accountsService, + assetsService, + configProvider, + priceApiClient, + state, +} from '../../../../snapContext'; import { KnownCaip19Id } from '../../../constants/solana'; import { trackError } from '../../../utils/errors'; import { @@ -33,9 +39,15 @@ jest.mock('../../../../features/send/Send', () => ({ })); jest.mock('../../../../snapContext', () => ({ - assetsService: { + accountsService: { getAll: jest.fn(), }, + assetsService: { + getAccountAssetsByScope: jest.fn(), + }, + configProvider: { + getActiveNetworks: jest.fn(), + }, priceApiClient: { getMultipleSpotPrices: jest.fn(), }, @@ -50,7 +62,13 @@ const setupTest = () => { request: jest.fn(), }; - (assetsService.getAll as jest.Mock).mockResolvedValue([ + (accountsService.getAll as jest.Mock).mockResolvedValue([ + { id: 'account-1' }, + ]); + (configProvider.getActiveNetworks as jest.Mock).mockResolvedValue([ + 'solana:mainnet', + ]); + (assetsService.getAccountAssetsByScope as jest.Mock).mockResolvedValue([ { assetType: KnownCaip19Id.SolMainnet, }, diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx index 68e38712..8efdd9fb 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx @@ -3,7 +3,13 @@ import type { OnCronjobHandler } from '@metamask/snaps-sdk'; import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; import { Send } from '../../../../features/send/Send'; import type { SendContext } from '../../../../features/send/types'; -import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import { + assetsService, + configProvider, + priceApiClient, + state, + accountsService, +} from '../../../../snapContext'; import type { UnencryptedStateValue } from '../../../services/state/State'; import { trackError } from '../../../utils/errors'; import { @@ -19,13 +25,25 @@ export const refreshSend: OnCronjobHandler = async () => { logger.info(`Background event triggered`); - const [assets, mapInterfaceNameToId, preferences] = await Promise.all([ - assetsService.getAll(), - state.getKey( - 'mapInterfaceNameToId', - ), - getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), - ]); + const [accounts, activeNetworks, mapInterfaceNameToId, preferences] = + await Promise.all([ + accountsService.getAll(), + configProvider.getActiveNetworks(), + state.getKey( + 'mapInterfaceNameToId', + ), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assets = ( + await Promise.all( + accounts.flatMap((account) => + activeNetworks.map((network) => + assetsService.getAccountAssetsByScope(network, account.id), + ), + ), + ) + ).flat(); const assetTypes = assets.flatMap((asset) => asset.assetType); diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index 5c27fe3e..3fc76e52 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -103,7 +103,8 @@ describe('SolanaKeyring', () => { mockAssetsService = { fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), saveMany: jest.fn(), - findByAccount: jest.fn(), + getAccountAssetsForAllActiveScopes: jest.fn(), + getAccountAssetsByIDs: jest.fn(), getNativeAssetTypes: jest .fn() .mockReturnValue([KnownCaip19Id.SolMainnet]), @@ -143,7 +144,7 @@ describe('SolanaKeyring', () => { describe('getAccountAssets', () => { it('calls the assets service', async () => { jest - .spyOn(mockAssetsService, 'findByAccount') + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') .mockResolvedValue(MOCK_ASSET_ENTITIES); const result = await keyring.getAccountAssets( @@ -158,10 +159,12 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance - ]); + jest + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValue([ + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance + ]); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -171,10 +174,12 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance - { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance - ]); + jest + .spyOn(mockAssetsService, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValue([ + { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance + { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance + ]); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -343,9 +348,9 @@ describe('SolanaKeyring', () => { symbol: 4, } as unknown as AssetEntity; - jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue([invalidAsset]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.SolMainnet]: invalidAsset, + }); await expect( keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ @@ -355,10 +360,13 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + [MOCK_ASSET_ENTITY_2.assetType]: { + ...MOCK_ASSET_ENTITY_2, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -374,10 +382,16 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance - { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '0', + }, + [MOCK_ASSET_ENTITY_1.assetType]: { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index 9dc6a985..785496a6 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -413,9 +413,10 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId }, ListAccountAssetsStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); - const assetEntities = await this.#assetsService.findByAccount(account); + const assetEntities = + await this.#assetsService.getAccountAssetsForAllActiveScopes(accountId); const result = assetEntities // Remove token assets with zero balance @@ -448,10 +449,15 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId, assets }, GetAccountBalancesStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); + + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + assets, + ); - const assetsToUse = (await this.#assetsService.findByAccount(account)) - .filter((asset) => assets.includes(asset.assetType)) + const assetsToUse = Object.values(assetsById) + .filter((asset): asset is NonNullable => asset !== null) // Remove token assets with zero balance .filter( (asset) => 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 d7e7f672..e0b161b8 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 @@ -17,12 +17,14 @@ import { SOLANA_MOCK_TOKEN_METADATA, } from '../../test/mocks/asset-entities'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +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 { 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'; @@ -32,9 +34,11 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; + let snapAssetsAdapter: SnapAssetsAdapter; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; + let mockAccountsService: AccountsService; let mockTokenApiClient: TokenApiClient; let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; @@ -81,16 +85,29 @@ describe('AssetsService', () => { saveMany: jest.fn(), } as unknown as AssetsRepository; - assetsService = new AssetsService({ + mockAccountsService = { + findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), + } 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, }); + + assetsService = new AssetsService({ + logger: mockLogger, + configProvider: mockConfigProvider, + snapAssetsAdapter, + tokenApiClient: mockTokenApiClient, + tokenPricesService: mockTokenPricesService, + nftApiClient: mockNftApiClient, + }); }); describe('fetch', () => { @@ -604,4 +621,81 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('getAccountAssetByID', () => { + it('returns the matching asset when present', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + + it('returns null when the asset is missing', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('returns a record keyed by asset ID', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + }); + }); + + it('returns null entries for missing assets', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('filters account assets to the requested scope', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + }); }); 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 a2778e7b..152caf53 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,114 +1,61 @@ /* 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 } 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 { CaipAssetType, CaipChainId } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; -import type { - AssetEntity, - NativeAsset, - SolanaKeyringAccount, - TokenAsset, -} from '../../../entities'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; +import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; +import { SolanaCaip19Tokens } from '../../constants/solana'; 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 { ConfigProvider } from '../config'; -import type { SolanaConnection } from '../connection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; -import type { AssetsRepository } from './AssetsRepository'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; 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 AssetsService { readonly #logger: ILogger; - readonly #connection: SolanaConnection; - readonly #configProvider: ConfigProvider; - readonly #assetsRepository: AssetsRepository; + readonly #snapAdapter: SnapAssetsAdapter; 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, + snapAssetsAdapter, tokenApiClient, tokenPricesService, - cache, nftApiClient, }: { - connection: SolanaConnection; logger: ILogger; configProvider: ConfigProvider; - assetsRepository: AssetsRepository; + snapAssetsAdapter: SnapAssetsAdapter; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; - cache: ICache; nftApiClient: NftApiClient; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#connection = connection; this.#configProvider = configProvider; - this.#assetsRepository = assetsRepository; + this.#snapAdapter = snapAssetsAdapter; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; - this.#cache = cache; this.#nftApiClient = nftApiClient; } @@ -235,221 +182,8 @@ export class AssetsService { }; } - /** - * 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, - ); - } - - /** - * 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( @@ -472,144 +206,7 @@ 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, - }); - } + return this.#snapAdapter.saveMany(assets); } /** @@ -620,59 +217,65 @@ export class AssetsService { * @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, - ); + return SnapAssetsAdapter.hasChanged(asset, assetsLookup); + } - if (!savedAsset) { - return true; - } + async getAll(): Promise { + return this.#snapAdapter.getAll(); + } - const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; - const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; + /** + * 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: string, + ): Promise { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + /** + * 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: string[], + ): Promise> { + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); + } - return rawAmountChanged || uiAmountChanged; + /** + * 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 { + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } - async getAll(): Promise { - return this.#assetsRepository.getAll(); + /** + * Returns assets for an account across all active Solana networks. + * + * @param accountId - Keyring account ID. + */ + async getAccountAssetsForAllActiveScopes( + accountId: string, + ): Promise { + return this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); } 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]; + 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 00000000..c1cad8f2 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -0,0 +1,105 @@ +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 { 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 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; + + 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, + cache: mockCache, + nftApiClient: mockNftApiClient, + }); + }); + + 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 00000000..d24a04a8 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts @@ -0,0 +1,622 @@ +/* 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 { 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 { + 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 { AssetsRepository } from '../AssetsRepository'; + +/** + * 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 #cache: ICache; + + readonly #nftApiClient: NftApiClient; + + public static readonly cacheTtlsMilliseconds = { + tokenAccountsByOwner: 5 * Duration.Second, + }; + + constructor({ + connection, + logger, + configProvider, + assetsRepository, + accountsService, + tokenApiClient, + cache, + nftApiClient, + }: { + connection: SolanaConnection; + logger: ILogger; + configProvider: ConfigProvider; + assetsRepository: AssetsRepository; + accountsService: AccountsService; + tokenApiClient: TokenApiClient; + 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.#cache = cache; + this.#nftApiClient = nftApiClient; + } + + /** + * 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 #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) => + 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) => + 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 : () => 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(); + } + + /** + * 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: string, + ): Promise { + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + + const assets = await this.getAccountAssetsByScope(chainId, accountId); + + return assets.find((asset) => asset.assetType === 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: string[], + ): Promise> { + if (assetIds.length === 0) { + return {}; + } + + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); + } + + const accountAssets = await this.findByAccount(account); + + return Object.fromEntries( + assetIds.map((assetId) => [ + assetId, + accountAssets.find((asset) => asset.assetType === assetId) ?? null, + ]), + ); + } + + /** + * 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 account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + const accountAssets = await this.findByAccount(account); + + return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); + } + + /** + * Returns assets for an account across all active Solana networks. + * + * @param accountId - Keyring account ID. + */ + async getAccountAssetsForAllActiveScopes( + accountId: string, + ): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + + const assetsByScope = await Promise.all( + activeNetworks.map((network) => + this.getAccountAssetsByScope(network, accountId), + ), + ); + + return assetsByScope.flat(); + } + + 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 cfec7e81..494c206b 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/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index bf47c833..7ef7660c 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts @@ -108,7 +108,7 @@ describe('SendService', () => { } as unknown as SendSplTokenBuilder; mockAssetsService = { - findByAccount: jest.fn(), + getAccountAssetsByIDs: jest.fn(), } as unknown as AssetsService; (fromTransactionToBase64String as jest.Mock).mockReturnValue( @@ -291,8 +291,16 @@ describe('SendService', () => { beforeEach(() => { jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue(mockAssetBalances); + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockImplementation(async (_accountId, assetIds) => + Object.fromEntries( + assetIds.map((assetId) => [ + assetId, + mockAssetBalances.find((asset) => asset.assetType === assetId) ?? + null, + ]), + ), + ); jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ @@ -325,7 +333,10 @@ describe('SendService', () => { }); it('rejects when asset balance not found', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [mockRequest.params.assetId]: null, + [Networks[Network.Mainnet].nativeToken.caip19Id]: null, + }); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( `Balance not found for asset ${mockRequest.params.assetId} and account ${mockAccount.id}`, @@ -338,8 +349,8 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.000001' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.00001', keyringAccountId: mockAccount.id, @@ -349,7 +360,17 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '999999999999999999', }, - ]); + [mockRequest.params.assetId]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.00001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '999999999999999999', + }, + }); const result = await sendService.onAmountInput(lowBalanceRequest); @@ -397,8 +418,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.1', keyringAccountId: mockAccount.id, @@ -408,7 +429,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - { + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '0.001', keyringAccountId: mockAccount.id, @@ -419,7 +440,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '1000000', }, - ]); + }); const result = await sendService.onAmountInput(zeroBalanceRequest); @@ -435,8 +456,18 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.1' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '0', + }, + [mockRequest.params.assetId]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0', keyringAccountId: mockAccount.id, @@ -446,7 +477,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '0', }, - ]); + }); const result = await sendService.onAmountInput(zeroSolRequest); @@ -465,8 +496,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -477,7 +508,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '1.0', keyringAccountId: mockAccount.id, @@ -487,7 +518,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -506,8 +537,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -518,7 +549,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.0001', keyringAccountId: mockAccount.id, @@ -528,7 +559,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -549,7 +580,9 @@ describe('SendService', () => { it('handles errors if balances are not found', async () => { const error = new Error('Failed to fetch balances'); - jest.spyOn(mockAssetsService, 'findByAccount').mockRejectedValue(error); + jest + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockRejectedValue(error); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( 'Failed to fetch balances', diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.ts index cbfea69f..14593aaf 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -225,15 +225,13 @@ export class SendService { const isNativeToken = assetId === nativeAssetType; - const accountBalances = await this.#assetsService.findByAccount(account); - - const assetEntry = accountBalances.find( - (asset) => asset.assetType === assetId, + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + [assetId, nativeAssetType], ); - const nativeAsset = accountBalances.find( - (asset) => asset.assetType === nativeAssetType, - ); + const assetEntry = assetsById[assetId]; + const nativeAsset = assetsById[nativeAssetType]; if (!assetEntry) { throw new Error( diff --git a/packages/solana-wallet-snap/src/features/send/render.tsx b/packages/solana-wallet-snap/src/features/send/render.tsx index dc1524d9..2974b8fa 100644 --- a/packages/solana-wallet-snap/src/features/send/render.tsx +++ b/packages/solana-wallet-snap/src/features/send/render.tsx @@ -91,13 +91,19 @@ export const renderSend: OnRpcRequestHandler = async ({ request }) => { loading: true, }; - const [assetEntities, keyringAccounts, tokenPrices, preferences] = - await Promise.all([ - assetsService.getAll(), - accountsService.getAll(), - state.getKey('tokenPrices'), - getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), - ]); + const [keyringAccounts, tokenPrices, preferences] = await Promise.all([ + accountsService.getAll(), + state.getKey('tokenPrices'), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assetEntities = ( + await Promise.all( + keyringAccounts.map((keyringAccount) => + assetsService.getAccountAssetsByScope(scope, keyringAccount.id), + ), + ) + ).flat(); context.balances = getBalancesInScope(scope, assetEntities); context.assets = assetEntities.map((asset) => asset.assetType); diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 58e48856..488250df 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, @@ -144,19 +145,29 @@ const tokenPricesService = new TokenPricesService({ const nameResolutionService = new NameResolutionService(connection, logger); const assetsRepository = new AssetsRepository(state); -const assetsService = new AssetsService({ + +const accountsRepository = new AccountsRepository(state); +const accountsService = new AccountsService(accountsRepository); + +const snapAssetsAdapter = new SnapAssetsAdapter({ connection, logger, configProvider, assetsRepository, + accountsService, tokenApiClient, cache: inMemoryCache, - tokenPricesService, nftApiClient, }); -const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService(accountsRepository); +const assetsService = new AssetsService({ + logger, + configProvider, + snapAssetsAdapter, + tokenApiClient, + tokenPricesService, + nftApiClient, +}); const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper(