diff --git a/package.json b/package.json index d7a92f547..39b2d2559 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ ], "files": [], "scripts": { - "build": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build", + "build": "yarn workspaces foreach --all --no-private --parallel --topological-dev --interlaced --verbose run build", "build:clean": "yarn build:only-clean && yarn build", "build:docs": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run build:docs", "build:only-clean": "rimraf -g 'packages/*/dist'", diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 3d37116f6..7f1aadb95 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Hardcode Solana fungible asset reads through `AssetsProvider` / `mapControllerAsset` (no migration-stage or remote feature-flag routing). Disable Snap fungible tracking in `fetch`/`save`/`saveMany` and account sync; Snap-owned NFT assets still use `SnapAssetsAdapter`. +- Wire Core messenger plumbing (`endowment:messenger`, `AssetsProvider`) into the Solana snap. +- Extract Snap-owned balance fetch/persist/read logic into `SnapAssetsAdapter`; `AssetsService` delegates account asset reads and saves 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 `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/jest.setup.ts b/packages/solana-wallet-snap/jest.setup.ts index df1465e94..1c7c8359a 100644 --- a/packages/solana-wallet-snap/jest.setup.ts +++ b/packages/solana-wallet-snap/jest.setup.ts @@ -1,7 +1,9 @@ import { jest } from '@jest/globals'; +import type { SimulationUserOptions } from '@metamask/snaps-simulation'; import BigNumber from 'bignumber.js'; import dotenv from 'dotenv'; +import { registerCoreAssetsControllerHandlers } from './src/core/test/helpers/registerCoreAssetsControllerHandlers'; import logger from './src/core/utils/logger'; dotenv.config(); @@ -9,6 +11,36 @@ dotenv.config(); // Lowest precision we ever go for: MicroLamports represented in Sol amount BigNumber.config({ EXPONENTIAL_AT: 16 }); +type SnapsTestEnvironment = { + installSnap: ( + snapId?: string, + options?: { options?: SimulationUserOptions }, + ) => Promise<{ + controllerMessenger: Parameters< + typeof registerCoreAssetsControllerHandlers + >[0]; + }>; +}; + +const { snapsEnvironment } = globalThis as { + snapsEnvironment?: SnapsTestEnvironment; +}; + +if (snapsEnvironment) { + const originalInstallSnap = + snapsEnvironment.installSnap.bind(snapsEnvironment); + jest + .spyOn(snapsEnvironment, 'installSnap') + .mockImplementation(async (snapId, options = {}) => { + const installed = await originalInstallSnap(snapId, options); + registerCoreAssetsControllerHandlers( + installed.controllerMessenger, + options.options ?? {}, + ); + return installed; + }); +} + // Mock the console methods jest.spyOn(logger, 'log').mockImplementation(() => { /* no-op */ diff --git a/packages/solana-wallet-snap/package.json b/packages/solana-wallet-snap/package.json index 642f2ecc8..f2376ad63 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -52,10 +52,13 @@ }, "devDependencies": { "@jest/globals": "^29.5.0", + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "9.1.2", "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/messenger": "^2.0.0", + "@metamask/snap-networks-utils": "workspace:^", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", "@metamask/snaps-sdk": "^11.2.0", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index fe9472582..c7c832470 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": "8pC/CUT1b2BVxIsPy6IWJQ8SMrFeW13nBhWeWKriNUs=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -88,7 +88,14 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:messenger": { + "actions": [ + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope" + ] + } }, "platformVersion": "11.2.0", "manifestVersion": "0.1" 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 a0eb261fd..64877134e 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 68e387128..8efdd9fb9 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 5c27fe3eb..3fc76e52d 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 9dc6a9858..785496a61 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/accounts/AccountsSynchronizer.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts index d214690c5..8b859dca5 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts @@ -34,15 +34,13 @@ export class AccountsSynchronizer { const assets = ( await Promise.allSettled( accountsToSync.map(async (account) => - this.#assetsService.fetch(account), + this.#assetsService.getAccountAssetsForAllActiveScopes(account.id), ), ) ) .map((item) => (item.status === 'fulfilled' ? item.value : [])) .flat(); - await this.#assetsService.saveMany(assets); - const transactions = await this.#transactionsService.fetchAssetsTransactions(assets, { limit: 20, 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 d7e7f6726..bf305a78e 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -1,7 +1,7 @@ -import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { cloneDeep } from 'lodash'; +import type { AssetEntity } from '../../../entities'; import type { ICache } from '../../caching/ICache'; import { InMemoryCache } from '../../caching/InMemoryCache'; import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; @@ -17,12 +17,13 @@ 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,13 +33,16 @@ 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; let mockCache: ICache; + let mockAssetsProvider: import('@metamask/snap-networks-utils').AssetsProvider; beforeEach(() => { jest.clearAllMocks(); @@ -81,405 +85,69 @@ 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, }); - }); - - describe('fetch', () => { - it('fetches native and token assets', async () => { - jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ - getBalance: jest.fn().mockReturnValueOnce({ - send: jest.fn().mockResolvedValue({ - value: 1000000000, // Native balance on Mainnet - }), - }), - getTokenAccountsByOwner: jest.fn().mockReturnValue({ - send: jest - .fn() - .mockResolvedValueOnce({ - value: - MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result - .value, - }) - .mockResolvedValue({ - value: [], - }), - }), - } as any); - const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + mockAssetsProvider = { + getAccountAssetByID: jest.fn(), + getAccountAssetsByIDs: jest.fn(), + getAccountAssetsByScope: jest.fn(), + } as unknown as import('@metamask/snap-networks-utils').AssetsProvider; - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - - it('does not fail on individual RPC call failures to fetch native assets', async () => { - jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ - getBalance: jest.fn().mockReturnValue({ - send: jest - .fn() - .mockRejectedValueOnce(new Error('Error getting balance')), - }), - getTokenAccountsByOwner: jest.fn().mockReturnValue({ - send: jest - .fn() - .mockResolvedValueOnce({ - value: - MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE.result - .value, - }) - .mockResolvedValue({ - value: [], - }), - }), - } as any); - - const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - - expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]); + assetsService = new AssetsService({ + logger: mockLogger, + configProvider: mockConfigProvider, + snapAssetsAdapter, + accountsService: mockAccountsService, + tokenApiClient: mockTokenApiClient, + tokenPricesService: mockTokenPricesService, + nftApiClient: mockNftApiClient, + assetsProvider: mockAssetsProvider, }); + }); - it('does not fail on individual RPC call failures to fetch token assets', async () => { - jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ - getBalance: jest.fn().mockReturnValueOnce({ - send: jest.fn().mockResolvedValue({ - value: 1000000000, // Native balance on Mainnet - }), - }), - getTokenAccountsByOwner: jest.fn().mockReturnValue({ - send: jest - .fn() - .mockRejectedValueOnce(new Error('Error getting token accounts')), - }), - } as any); + describe('fetch', () => { + it('returns an empty array because Snap fungible tracking is disabled', async () => { + const fetchSpy = jest.spyOn(snapAssetsAdapter, 'fetch'); const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + expect(assets).toStrictEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); }); }); describe('save', () => { - it('saves an asset', async () => { - const spy = jest - .spyOn(assetsService, 'saveMany') - .mockResolvedValueOnce(undefined); + it('is a no-op because Snap fungible tracking is disabled', async () => { + const saveManySpy = jest.spyOn(mockAssetsRepository, 'saveMany'); await assetsService.save(MOCK_ASSET_ENTITY_0); - expect(spy).toHaveBeenCalledWith([MOCK_ASSET_ENTITY_0]); + expect(saveManySpy).not.toHaveBeenCalled(); }); }); describe('saveMany', () => { - it('delegates to repository for saving assets', async () => { - const saveManySpy = jest - .spyOn(mockAssetsRepository, 'saveMany') - .mockResolvedValue(undefined); - - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); - - await assetsService.saveMany(MOCK_ASSET_ENTITIES); - - expect(saveManySpy).toHaveBeenCalledWith(MOCK_ASSET_ENTITIES); - }); - - it('emits event "AccountAssetListUpdated" with ALL assets in added list, and removed assets in removed list', async () => { - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); - - const addedAssets = [MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]; - const removedAssets = [ - { - ...MOCK_ASSET_ENTITY_2, - rawAmount: '0', - }, - ]; - - await assetsService.saveMany([...addedAssets, ...removedAssets]); - - expect(emitSnapKeyringEvent).toHaveBeenNthCalledWith( - 1, - snap, - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - added: [ - MOCK_ASSET_ENTITY_0.assetType, - MOCK_ASSET_ENTITY_1.assetType, - ], - removed: [MOCK_ASSET_ENTITY_2.assetType], - }, - }, - }, - ); - }); - - it('emits event "AccountAssetListUpdated" when an asset was saved with a zero balance and some more is added', async () => { - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValueOnce([{ ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }]) - .mockResolvedValueOnce([{ ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }]); - - await assetsService.saveMany([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, - ]); - - await assetsService.saveMany([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '1000000' }, - ]); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - added: [MOCK_ASSET_ENTITY_0.assetType], - removed: [], - }, - }, - }, - ); - }); - - it('emits event "AccountBalancesUpdated" when balances change', async () => { - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); - - await assetsService.saveMany(MOCK_ASSET_ENTITIES); - - expect(emitSnapKeyringEvent).toHaveBeenNthCalledWith( - 2, - snap, - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - [MOCK_ASSET_ENTITY_0.assetType]: { - unit: MOCK_ASSET_ENTITY_0.symbol, - amount: MOCK_ASSET_ENTITY_0.uiAmount, - }, - [MOCK_ASSET_ENTITY_1.assetType]: { - unit: MOCK_ASSET_ENTITY_1.symbol, - amount: MOCK_ASSET_ENTITY_1.uiAmount, - }, - [MOCK_ASSET_ENTITY_2.assetType]: { - unit: MOCK_ASSET_ENTITY_2.symbol, - amount: MOCK_ASSET_ENTITY_2.uiAmount, - }, - }, - }, - }, - ); - }); - - it('emits event "AccountBalancesUpdated" when native balance goes from non-zero to zero', async () => { - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValue([{ ...MOCK_ASSET_ENTITY_0, uiAmount: '1234' }]); - - await assetsService.saveMany([{ ...MOCK_ASSET_ENTITY_0, uiAmount: '0' }]); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - [MOCK_ASSET_ENTITY_0.assetType]: { - unit: MOCK_ASSET_ENTITY_0.symbol, - amount: '0', - }, - }, - }, - }, - ); - }); - - // With isIncremental = false, we do emit events, even when no assets changed - it.skip('does not emit events when no assets changed', async () => { - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValue(MOCK_ASSET_ENTITIES); - - await assetsService.saveMany(MOCK_ASSET_ENTITIES); - (emitSnapKeyringEvent as jest.Mock).mockClear(); + it('is a no-op because Snap fungible tracking is disabled', async () => { + const saveManySpy = jest.spyOn(mockAssetsRepository, 'saveMany'); await assetsService.saveMany(MOCK_ASSET_ENTITIES); + expect(saveManySpy).not.toHaveBeenCalled(); expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); }); - - it('fetches saved assets before saving new assets to ensure correct change detection', async () => { - const callOrder: string[] = []; - - const getAllSpy = jest - .spyOn(mockAssetsRepository, 'getAll') - .mockImplementation(async () => { - callOrder.push('getAll'); - return []; - }); - - const saveManySpy = jest - .spyOn(mockAssetsRepository, 'saveMany') - .mockImplementation(async () => { - callOrder.push('saveMany'); - }); - - await assetsService.saveMany(MOCK_ASSET_ENTITIES); - - // Verify that getAll was called before saveMany - expect(callOrder).toStrictEqual(['getAll', 'saveMany']); - expect(getAllSpy).toHaveBeenCalledTimes(1); - expect(saveManySpy).toHaveBeenCalledTimes(1); - }); - - it('correctly detects new assets when savedAssets is fetched before saving', async () => { - // Start with empty state - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); - - await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); - - // Should emit AccountAssetListUpdated with the new asset - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - added: [MOCK_ASSET_ENTITY_0.assetType], - removed: [], - }, - }, - }, - ); - }); - - it('correctly detects assets going from zero to non-zero balance when savedAssets is fetched before saving', async () => { - const assetWithZeroBalance = { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }; - const assetWithNonZeroBalance = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '1000000', - }; - - // First save with zero balance - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([]); - await assetsService.saveMany([assetWithZeroBalance]); - - (emitSnapKeyringEvent as jest.Mock).mockClear(); - - // Then save with non-zero balance, but getAll should return the state before this save - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValueOnce([assetWithZeroBalance]); - await assetsService.saveMany([assetWithNonZeroBalance]); - - // Should emit AccountAssetListUpdated because asset went from zero to non-zero - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - added: [MOCK_ASSET_ENTITY_0.assetType], - removed: [], - }, - }, - }, - ); - }); - - // With isIncremental = false, we do emit events, even when no assets changed - it.skip('does not incorrectly mark assets as new when they are already in the saved state', async () => { - // Mock that the asset already exists in saved state - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); - - await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); - - // Should not emit AccountAssetListUpdated since no assets were actually added/removed - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - snap, - KeyringEvent.AccountAssetListUpdated, - expect.any(Object), - ); - }); - - it('correctly identifies balance changes when savedAssets reflects pre-save state', async () => { - const originalAsset = { ...MOCK_ASSET_ENTITY_0, rawAmount: '1000000' }; - const updatedAsset = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '2000000', - uiAmount: '2.0', - }; - - // Mock that original asset exists in saved state - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValueOnce([originalAsset]); - - await assetsService.saveMany([updatedAsset]); - - // Should emit AccountBalancesUpdated because balance changed - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - [MOCK_ASSET_ENTITY_0.assetType]: { - unit: updatedAsset.symbol, - amount: updatedAsset.uiAmount, - }, - }, - }, - }, - ); - }); - - it('does not include native assets in removed array even when they have zero balance', async () => { - const nativeAssetWithZeroBalance = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '0', - }; - const tokenAssetWithZeroBalance = { - ...MOCK_ASSET_ENTITY_1, - rawAmount: '0', - }; - - // Mock that both assets existed with non-zero balance - jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([ - MOCK_ASSET_ENTITY_0, // Native asset with non-zero balance - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - ]); - - await assetsService.saveMany([ - nativeAssetWithZeroBalance, - tokenAssetWithZeroBalance, - ]); - - // Should emit AccountAssetListUpdated with only the token asset in the removed array - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - snap, - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { - added: [MOCK_ASSET_ENTITY_0.assetType], - removed: [MOCK_ASSET_ENTITY_1.assetType], // Only token asset, not native - }, - }, - }, - ); - }); }); describe('hasChanged', () => { @@ -563,7 +231,7 @@ describe('AssetsService', () => { }); it('includes placeholder native assets with zero balance when no native assets exist', async () => { - const nonNativeAssets = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; // Token assets only + const nonNativeAssets = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; jest .spyOn(mockAssetsRepository, 'findByKeyringAccountId') @@ -573,7 +241,6 @@ describe('AssetsService', () => { MOCK_SOLANA_KEYRING_ACCOUNT_0, ); - // Should include the saved assets plus a placeholder native asset expect(assets).toHaveLength(nonNativeAssets.length + 1); expect(assets).toStrictEqual( expect.arrayContaining([ @@ -595,7 +262,7 @@ describe('AssetsService', () => { it('does not add placeholder native assets when they already exist', async () => { jest .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); // Includes native asset (MOCK_ASSET_ENTITY_0) + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); const assets = await assetsService.findByAccount( MOCK_SOLANA_KEYRING_ACCOUNT_0, @@ -604,4 +271,243 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('getAccountAssetByID', () => { + it('routes fungible assets through AssetsProvider', async () => { + jest.spyOn(mockAssetsProvider, 'getAccountAssetByID').mockResolvedValue({ + id: MOCK_ASSET_ENTITY_1.assetType, + chainId: Network.Mainnet, + balance: { amount: MOCK_ASSET_ENTITY_1.rawAmount }, + metadata: { + type: 'fungible', + symbol: MOCK_ASSET_ENTITY_1.symbol, + name: MOCK_ASSET_ENTITY_1.symbol, + decimals: MOCK_ASSET_ENTITY_1.decimals, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as never); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(mockAssetsProvider.getAccountAssetByID).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + expect(asset).toMatchObject({ + assetType: MOCK_ASSET_ENTITY_1.assetType, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + rawAmount: MOCK_ASSET_ENTITY_1.rawAmount, + }); + }); + + it('routes NFT assets through SnapAssetsAdapter', async () => { + const nftAssetType = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const snapSpy = jest + .spyOn(snapAssetsAdapter, 'getAccountAssetByID') + .mockResolvedValueOnce(null); + + await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + nftAssetType, + ); + + expect(snapSpy).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + nftAssetType, + ); + expect(mockAssetsProvider.getAccountAssetByID).not.toHaveBeenCalled(); + }); + + it('returns null when the fungible asset is missing from Core', async () => { + jest + .spyOn(mockAssetsProvider, 'getAccountAssetByID') + .mockResolvedValueOnce(null); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('routes fungible and NFT asset IDs to the correct adapters', async () => { + const nftAssetType = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + jest.spyOn(mockAssetsProvider, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + id: MOCK_ASSET_ENTITY_0.assetType, + chainId: Network.Mainnet, + balance: { amount: MOCK_ASSET_ENTITY_0.rawAmount }, + metadata: { + type: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + }, + } as never); + jest + .spyOn(snapAssetsAdapter, 'getAccountAssetsByIDs') + .mockResolvedValueOnce({ + [nftAssetType]: null, + }); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, nftAssetType], + ); + + expect(mockAssetsProvider.getAccountAssetsByIDs).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType], + ); + expect(snapAssetsAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [nftAssetType], + ); + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }), + [nftAssetType]: null, + }); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('merges fungible Core assets with Snap-owned NFT assets for the scope', async () => { + const nftAssetType = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const nftAsset = { + assetType: nftAssetType, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + symbol: 'NFT', + decimals: 0, + rawAmount: '1', + uiAmount: '1', + } as AssetEntity; + + jest.spyOn(mockAssetsProvider, 'getAccountAssetsByScope').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + id: MOCK_ASSET_ENTITY_0.assetType, + chainId: Network.Mainnet, + balance: { amount: MOCK_ASSET_ENTITY_0.rawAmount }, + metadata: { + type: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + }, + [MOCK_ASSET_ENTITY_1.assetType]: { + id: MOCK_ASSET_ENTITY_1.assetType, + chainId: Network.Mainnet, + balance: { amount: MOCK_ASSET_ENTITY_1.rawAmount }, + metadata: { + type: 'fungible', + symbol: MOCK_ASSET_ENTITY_1.symbol, + name: MOCK_ASSET_ENTITY_1.symbol, + decimals: MOCK_ASSET_ENTITY_1.decimals, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + }, + } as never); + jest + .spyOn(snapAssetsAdapter, 'getAccountAssetsByScope') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_2, nftAsset]); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(mockAssetsProvider.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + expect(assets).toHaveLength(3); + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }), + expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_1.assetType, + }), + nftAsset, + ]), + ); + }); + }); + + describe('getAccountAssetsForAllActiveScopes', () => { + it('merges fungible Core assets with Snap-owned NFT assets across active scopes', async () => { + const nftAssetType = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const nftAsset = { + assetType: nftAssetType, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', + symbol: 'NFT', + decimals: 0, + rawAmount: '1', + uiAmount: '1', + } as AssetEntity; + + jest.spyOn(mockAssetsProvider, 'getAccountAssetsByScope').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + id: MOCK_ASSET_ENTITY_0.assetType, + chainId: Network.Mainnet, + balance: { amount: MOCK_ASSET_ENTITY_0.rawAmount }, + metadata: { + type: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + }, + } as never); + jest + .spyOn(snapAssetsAdapter, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_1, nftAsset]); + + const assets = await assetsService.getAccountAssetsForAllActiveScopes( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(mockAssetsProvider.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + expect(assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }), + nftAsset, + ]), + ); + }); + }); }); 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 a2778e7b1..4b63813a6 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,117 +1,167 @@ /* 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 { Caip19AssetId } from '@metamask/assets-controller'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; 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 { 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 './adapters/SnapAssetsAdapter'; +import { mapControllerAsset } from './mapControllerAsset'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; 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; +function isFungibleProviderAsset(assetId: string): boolean { + return !isSnapOwnedAsset(assetId); +} export class AssetsService { readonly #logger: ILogger; - readonly #connection: SolanaConnection; - readonly #configProvider: ConfigProvider; - readonly #assetsRepository: AssetsRepository; + readonly #snapAdapter: SnapAssetsAdapter; + + readonly #assetsProvider: AssetsProvider; + + 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, + snapAssetsAdapter, + accountsService, tokenApiClient, tokenPricesService, - cache, nftApiClient, + assetsProvider, }: { - connection: SolanaConnection; logger: ILogger; configProvider: ConfigProvider; - assetsRepository: AssetsRepository; + snapAssetsAdapter: SnapAssetsAdapter; + accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; - cache: ICache; nftApiClient: NftApiClient; + assetsProvider: AssetsProvider; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#connection = connection; this.#configProvider = configProvider; - this.#assetsRepository = assetsRepository; + this.#snapAdapter = snapAssetsAdapter; + this.#accountsService = accountsService; + this.#assetsProvider = assetsProvider; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; - this.#cache = cache; this.#nftApiClient = nftApiClient; } + async #solanaChainIds(): Promise { + return this.#configProvider.getActiveNetworks(); + } + + async #getCoreAccountAssetByID( + accountId: string, + assetId: CaipAssetType, + accountAddress: string, + ): Promise { + const result = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!result) { + return null; + } + + return mapControllerAsset(accountId, assetId, accountAddress, result); + } + + async #getCoreAccountAssetsByIDs( + accountId: string, + assetIds: string[], + accountAddress: string, + ): Promise> { + const fungibleAssetIds = assetIds.filter(isFungibleProviderAsset); + const providerAssets = fungibleAssetIds.length + ? await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + fungibleAssetIds as Caip19AssetId[], + ) + : {}; + + const entries = await Promise.all( + assetIds.map(async (assetId) => { + if (!isFungibleProviderAsset(assetId)) { + return [assetId, null] as const; + } + + const asset = providerAssets[assetId as Caip19AssetId]; + if (!asset) { + return [assetId, null] as const; + } + + const entity = await mapControllerAsset( + accountId, + assetId as CaipAssetType, + accountAddress, + asset, + ); + return [assetId, entity] as const; + }), + ); + + return Object.fromEntries(entries); + } + + async #getCoreAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + accountAddress: string, + ): Promise { + const providerAssets = await this.#assetsProvider.getAccountAssetsByScope( + scope, + accountId, + ); + + const supportedEntries = Object.entries(providerAssets).filter( + ([assetId]) => isFungibleProviderAsset(assetId), + ); + + return Promise.all( + supportedEntries.map(([assetId, asset]) => + mapControllerAsset( + accountId, + assetId as CaipAssetType, + accountAddress, + asset, + ), + ), + ); + } + #splitAssetsByType(assetTypes: CaipAssetType[]) { const nativeAssetTypes = assetTypes.filter((assetType) => assetType.endsWith(SolanaCaip19Tokens.SOL), @@ -185,7 +235,7 @@ export class AssetsService { 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 + isPossibleSpam: false, attributes: Object.fromEntries( nftMetadata.attributes.map( (attr: { key: string; value: string | number }) => [ @@ -199,7 +249,7 @@ export class AssetsService { address: nftMetadata.onchainCollectionAddress as Caip10Address, symbol: nftMetadata.collectionSymbol, tokenCount: nftMetadata.collectionCount, - creator: '' as Caip10Address, // FIXME: There can be more than one creator + creator: '' as Caip10Address, imageUrl: nftMetadata.collectionImageUrl ?? '', }, }; @@ -215,241 +265,22 @@ export class AssetsService { ): Promise> { this.#logger.log('Fetching metadata for assets', assetTypes); - const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = + const { nativeAssetTypes, tokenAssetTypes } = this.#splitAssetsByType(assetTypes); - const [ - nativeTokensMetadata, - tokensMetadata, - // nftMetadata, - ] = await Promise.all([ + const [nativeTokensMetadata, tokensMetadata] = 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, - ); - } - - /** - * 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 fetch(_account: SolanaKeyringAccount): Promise { + return []; } async fetchAssetsMarketData( @@ -467,212 +298,125 @@ export class AssetsService { return marketData; } - async save(asset: AssetEntity): Promise { - await this.saveMany([asset]); + async save(_asset: AssetEntity): Promise { + // Fungible assets are tracked by Core; Snap persistence is disabled. } - 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; + async saveMany(_assets: AssetEntity[]): Promise { + // Fungible assets are tracked by Core; Snap persistence is disabled. + } - const hasZeroAmount = (asset: AssetEntity) => - asset.rawAmount === '0' || asset.uiAmount === '0'; + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + return SnapAssetsAdapter.hasChanged(asset, assetsLookup); + } - const hasNonZeroAmount = (asset: AssetEntity) => !hasZeroAmount(asset); + async getAll(): Promise { + return this.#snapAdapter.getAll(); + } - const savedAssets = await this.getAll(); + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + if (isSnapOwnedAsset(assetId)) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } - // Save assets using repository - await this.#assetsRepository.saveMany(assets); + const account = await this.#accountsService.findById(accountId); + if (!account) { + return null; + } - // 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, - ); + return this.#getCoreAccountAssetByID( + accountId, + assetId as CaipAssetType, + account.address, + ); + } - const wasSavedWithZeroAmount = (asset: AssetEntity) => { - const savedAsset = savedAssets.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise> { + if (assetIds.length === 0) { + return {}; + } - return savedAsset && hasZeroAmount(savedAsset); - }; + const account = await this.#accountsService.findById(accountId); + if (!account) { + return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); + } - 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] : []), - ], - }, - }), - {}, + const snapOwnedIds = assetIds.filter(isSnapOwnedAsset); + const fungibleIds = assetIds.filter( + (assetId) => !isSnapOwnedAsset(assetId), ); - // 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, - }); - } + const [fungibleResults, snapResults] = await Promise.all([ + fungibleIds.length > 0 + ? this.#getCoreAccountAssetsByIDs( + accountId, + fungibleIds, + account.address, + ) + : Promise.resolve({}), + snapOwnedIds.length > 0 + ? this.#snapAdapter.getAccountAssetsByIDs(accountId, snapOwnedIds) + : Promise.resolve({}), + ]); - // 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 { ...snapResults, ...fungibleResults }; } - /** - * 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, + async getAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + ): Promise { + const account = await this.#accountsService.findById(accountId); + if (!account) { + return []; + } + + const [fungibleAssets, snapAssets] = await Promise.all([ + this.#getCoreAccountAssetsByScope(scope, accountId, account.address), + this.#snapAdapter.getAccountAssetsByScope(scope, accountId), + ]); + + const nftAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), ); - if (!savedAsset) { - return true; + return [...fungibleAssets, ...nftAssets]; + } + + async getAccountAssetsForAllActiveScopes( + accountId: string, + ): Promise { + const account = await this.#accountsService.findById(accountId); + if (!account) { + return []; } - const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; - const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; + const chainIds = (await this.#solanaChainIds()) as CaipChainId[]; + const relevantChainIds = chainIds.filter((chainId) => + account.scopes.includes(chainId), + ); - return rawAmountChanged || uiAmountChanged; - } + const fungibleByScope = await Promise.all( + relevantChainIds.map((scope) => + this.#getCoreAccountAssetsByScope(scope, accountId, account.address), + ), + ); + const snapAssets = + await this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); + const nftAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); - async getAll(): Promise { - return this.#assetsRepository.getAll(); + return [...fungibleByScope.flat(), ...nftAssets]; } 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 000000000..c1cad8f2c --- /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 000000000..d24a04a83 --- /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 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/core/services/assets/mapControllerAsset.test.ts b/packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.test.ts new file mode 100644 index 000000000..fa8f3152b --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.test.ts @@ -0,0 +1,79 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { KnownCaip19Id, Network } from '../../constants/solana'; +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import { mapControllerAsset } from './mapControllerAsset'; + +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { symbol: string; decimals: number }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.symbol, + decimals: metadata.decimals, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + +describe('mapControllerAsset', () => { + it('maps native SOL assets', async () => { + const asset = buildControllerAsset(KnownCaip19Id.SolMainnet, '1000000000', { + symbol: 'SOL', + decimals: 9, + }); + + const entity = await mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + KnownCaip19Id.SolMainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + ); + + expect(entity).toStrictEqual({ + assetType: KnownCaip19Id.SolMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + symbol: 'SOL', + decimals: 9, + rawAmount: '1000000000', + uiAmount: '1', + }); + }); + + it('maps SPL token assets with ATA pubkey', async () => { + const asset = buildControllerAsset(KnownCaip19Id.UsdcMainnet, '1234567', { + symbol: 'USDC', + decimals: 6, + }); + + const entity = await mapControllerAsset( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + KnownCaip19Id.UsdcMainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.address, + asset, + ); + + expect(entity).toMatchObject({ + assetType: KnownCaip19Id.UsdcMainnet, + keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + network: Network.Mainnet, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + symbol: 'USDC', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + }); + expect(entity).toHaveProperty('pubkey'); + expect(typeof (entity as { pubkey?: string }).pubkey).toBe('string'); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.ts b/packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.ts new file mode 100644 index 000000000..d17f82b86 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.ts @@ -0,0 +1,72 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; +import { + findAssociatedTokenPda, + TOKEN_PROGRAM_ADDRESS, +} from '@solana-program/token'; +import { address as asAddress } from '@solana/kit'; + +import type { AssetEntity } from '../../../entities'; +import type { + NativeCaipAssetType, + Network, + TokenCaipAssetType, +} from '../../constants/solana'; +import { SolanaCaip19Tokens } from '../../constants/solana'; +import { fromTokenUnits } from '../../utils/fromTokenUnit'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * @param accountId - Keyring account ID. + * @param assetId - CAIP-19 asset ID. + * @param accountAddress - Solana account address (owner). + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export async function mapControllerAsset( + accountId: string, + assetId: CaipAssetType, + accountAddress: string, + asset: Asset, +): Promise { + const { chainId, assetReference } = parseCaipAssetType(assetId); + const decimals = asset.metadata.decimals ?? 0; + const symbol = asset.metadata.symbol ?? 'UNKNOWN'; + const rawAmount = asset.balance.amount; + const uiAmount = fromTokenUnits(rawAmount, decimals); + const network = chainId as Network; + + if (assetId.endsWith(SolanaCaip19Tokens.SOL)) { + return { + assetType: assetId as NativeCaipAssetType, + keyringAccountId: accountId, + network, + address: accountAddress, + symbol, + decimals, + rawAmount, + uiAmount, + }; + } + + const mint = assetReference; + const [pubkey] = await findAssociatedTokenPda({ + mint: asAddress(mint), + owner: asAddress(accountAddress), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + return { + assetType: assetId as TokenCaipAssetType, + keyringAccountId: accountId, + network, + mint, + pubkey, + symbol, + decimals, + rawAmount, + uiAmount, + }; +} diff --git a/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts b/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts new file mode 100644 index 000000000..3b249e594 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts @@ -0,0 +1,17 @@ +import { KnownCaip19Id } from '../../constants/solana'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; + +describe('isSnapOwnedAsset', () => { + it('returns true for NFT CAIP-19 asset IDs', () => { + expect( + isSnapOwnedAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ), + ).toBe(true); + }); + + it('returns false for fungible native and token asset IDs', () => { + expect(isSnapOwnedAsset(KnownCaip19Id.SolMainnet)).toBe(false); + expect(isSnapOwnedAsset(KnownCaip19Id.UsdcMainnet)).toBe(false); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts b/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts new file mode 100644 index 000000000..872deb885 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts @@ -0,0 +1,13 @@ +/** + * Returns whether an asset remains exclusively managed by the Snap. + * + * AssetsController does not persist Solana NFT balances. NFT assets must always + * be read, synchronized, persisted, and published by the Snap, regardless of + * the assets migration stage. + * + * @param assetId - CAIP-19 asset ID. + * @returns Whether the asset is exclusively managed by the Snap. + */ +export function isSnapOwnedAsset(assetId: string): boolean { + return assetId.includes('/nft:'); +} diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index bf47c8336..7ef7660cc 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 cbfea69f5..14593aafb 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/core/services/subscriptions/KeyringAccountMonitor.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts index 578e2dca6..ee96f44be 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts @@ -16,28 +16,20 @@ import type { } from '../../../entities'; import { KnownCaip19Id, Network } from '../../constants/solana'; import { MOCK_SOLANA_KEYRING_ACCOUNTS } from '../../test/mocks/solana-keyring-accounts'; -import { trackError } from '../../utils/errors'; import type { AccountsSynchronizer } from '../accounts'; import type { AccountsService } from '../accounts/AccountsService'; -import type { AssetsService, TokenHelper } from '../assets'; import type { ConfigProvider } from '../config'; import { mockLogger } from '../mocks/logger'; import type { TransactionsService } from '../transactions'; import { KeyringAccountMonitor } from './KeyringAccountMonitor'; import type { SubscriptionService } from './SubscriptionService'; -jest.mock('../../utils/errors', () => ({ - trackError: jest.fn().mockResolvedValue('tracked-error-id'), -})); - describe('KeyringAccountMonitor', () => { let keyringAccountMonitor: KeyringAccountMonitor; let mockSubscriptionService: SubscriptionService; let mockAccountService: AccountsService; - let mockAssetsService: AssetsService; let mockTransactionsService: TransactionsService; let mockAccountsSynchronizer: AccountsSynchronizer; - let mockTokenHelper: TokenHelper; let mockConfigProvider: ConfigProvider; const account = MOCK_SOLANA_KEYRING_ACCOUNTS[0]; @@ -124,17 +116,6 @@ describe('KeyringAccountMonitor', () => { findByAddress: jest.fn(), } as unknown as AccountsService; - mockAssetsService = { - getTokenAccountsByOwnerMultiple: jest.fn(), - save: jest.fn(), - getAssetsMetadata: jest.fn().mockImplementation((assetType) => ({ - [assetType]: { - symbol: 'USDC', - decimals: 6, - }, - })), - } as unknown as AssetsService; - mockTransactionsService = { fetchLatestSignatures: jest.fn(), fetchBySignature: jest.fn(), @@ -145,11 +126,6 @@ describe('KeyringAccountMonitor', () => { synchronize: jest.fn(), } as unknown as AccountsSynchronizer; - mockTokenHelper = { - uiAmountToAmountForMint: jest.fn(), - amountToUiAmountForMint: jest.fn(), - } as unknown as TokenHelper; - mockConfigProvider = { getActiveNetworks: jest .fn() @@ -159,10 +135,8 @@ describe('KeyringAccountMonitor', () => { keyringAccountMonitor = new KeyringAccountMonitor( mockSubscriptionService, mockAccountService, - mockAssetsService, mockTransactionsService, mockAccountsSynchronizer, - mockTokenHelper, mockConfigProvider, mockLogger, ); @@ -361,24 +335,13 @@ describe('KeyringAccountMonitor', () => { params: [account.address, { commitment: 'confirmed' as const }], } as unknown as Subscription; - it('saves the new balance of the native asset', async () => { + it('persists the causing transaction without saving asset balance', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); // Send the notification by manually calling the handler const handler = accountNotificationHandlers[0]!; await handler(mockNotification, mockSubscription); - expect(mockAssetsService.save).toHaveBeenCalledWith({ - assetType: KnownCaip19Id.SolMainnet, - keyringAccountId: account.id, - network: Network.Mainnet, - address: account.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '1000000000', - uiAmount: '1', - }); - expect(mockTransactionsService.save).toHaveBeenCalledWith( mockCausingTransaction, ); @@ -424,35 +387,6 @@ describe('KeyringAccountMonitor', () => { expect(mockTransactionsService.save).not.toHaveBeenCalled(); }); - - it('throws an error when lamports is missing', async () => { - const mockNotificationWithMissingLamports: AccountNotification = { - jsonrpc: '2.0', - method: 'accountNotification', - params: { - subscription: 1, - result: { - context: { - slot: 1, - }, - value: { - data: {}, - executable: false, - lamports: undefined as unknown as number, // Lamports is missing - owner: '11111111111111111111111111111111', - rentEpoch: null, - }, - }, - }, - }; - - await keyringAccountMonitor.setMonitoredAccounts([account.id]); - - const handler = accountNotificationHandlers[0]!; - await expect( - handler(mockNotificationWithMissingLamports, mockSubscription), - ).rejects.toThrow('Expected a number, but received: undefined'); - }); }); describe('when a token asset changed', () => { @@ -503,44 +437,12 @@ describe('KeyringAccountMonitor', () => { params: [TOKEN_PROGRAM_ADDRESS, { commitment: 'confirmed' as const }], } as unknown as Subscription; - beforeEach(() => { - jest - .spyOn(mockTokenHelper, 'amountToUiAmountForMint') - .mockResolvedValue('123.456789'); - }); - - it('tracks ui amount fallback errors and keeps the raw value', async () => { - const error = new Error('Conversion failed'); - - jest - .spyOn(mockTokenHelper, 'amountToUiAmountForMint') - .mockRejectedValue(error); - - await keyringAccountMonitor.setMonitoredAccounts([account.id]); - - const handler = programNotificationHandlers[0]!; - await handler(mockNotification, mockSubscription); - - expect(trackError).toHaveBeenCalledWith(error); - }); - - it('saves the new balance of the token asset and the transaction that caused it', async () => { + it('persists the causing transaction without saving asset balance', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); const handler = programNotificationHandlers[0]!; await handler(mockNotification, mockSubscription); - expect(mockAssetsService.save).toHaveBeenCalledWith({ - assetType: KnownCaip19Id.UsdcMainnet, - keyringAccountId: account.id, - network: Network.Mainnet, - mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', - symbol: 'USDC', - decimals: 6, - rawAmount: '123456789', - uiAmount: '123.456789', - }); expect(mockTransactionsService.save).toHaveBeenCalledWith( mockCausingTransaction, ); @@ -557,108 +459,6 @@ describe('KeyringAccountMonitor', () => { ); }); - it('throws an error when mint address is missing', async () => { - const mockNotificationWithMissingMint: ProgramNotification = { - jsonrpc: '2.0', - method: 'programNotification', - params: { - subscription: 1, - result: { - context: { - slot: 1, - }, - value: { - pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', - account: { - data: { - parsed: { - info: { - isNative: false, - mint: undefined as unknown as string, // Mint is missing - owner: account.address, - state: 'initialized', - tokenAmount: { - amount: '20011079', - decimals: 6, - uiAmount: 20.011079, - uiAmountString: '20.011079', - }, - }, - type: 'account', - }, - program: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', - space: 165, - }, - executable: true, - lamports: 1000000000, - owner: account.address, - rentEpoch: 1, - }, - }, - }, - }, - }; - - await keyringAccountMonitor.setMonitoredAccounts([account.id]); - const handler = programNotificationHandlers[0]!; - - await expect( - handler(mockNotificationWithMissingMint, mockSubscription), - ).rejects.toThrow('Expected a string, but received: undefined'); - expect(mockAssetsService.save).not.toHaveBeenCalled(); - }); - - it('throws an error when uiAmountString is missing', async () => { - const mockNotificationWithMissingUiAmountString: ProgramNotification = { - jsonrpc: '2.0', - method: 'programNotification', - params: { - subscription: 1, - result: { - context: { - slot: 1, - }, - value: { - pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', - account: { - data: { - parsed: { - info: { - isNative: false, - mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - owner: account.address, - state: 'initialized', - tokenAmount: { - amount: '20011079', - decimals: 6, - uiAmount: 20.011079, - uiAmountString: undefined as unknown as string, // uiAmountString is missing - }, - }, - type: 'account', - }, - program: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', - space: 165, - }, - executable: true, - lamports: 1000000000, - owner: account.address, - rentEpoch: 1, - }, - }, - }, - }, - }; - - await keyringAccountMonitor.setMonitoredAccounts([account.id]); - const handler = programNotificationHandlers[0]!; - - await expect( - handler(mockNotificationWithMissingUiAmountString, mockSubscription), - ).rejects.toThrow('Expected a string, but received: undefined'); - expect(mockAssetsService.save).not.toHaveBeenCalled(); - }); - describe('when #saveCausingTransaction encounters errors', () => { it('throws an error when no signatures are found', async () => { // No signatures found for the token account diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts index 38eb4c325..34dc2ba10 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts @@ -1,8 +1,8 @@ -import { assert, number, string } from '@metamask/superstruct'; +import { assert, string } from '@metamask/superstruct'; import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; import type { Base58EncodedBytes } from '@solana/kit'; -import { address as asAddress, lamports } from '@solana/kit'; +import { address as asAddress } from '@solana/kit'; import { get, uniq } from 'lodash'; import type { SubscriptionService } from '.'; @@ -13,15 +13,10 @@ import type { Subscription, } from '../../../entities'; import type { Network } from '../../constants/solana'; -import { SolanaCaip19Tokens } from '../../constants/solana'; -import { trackError } from '../../utils/errors'; -import { fromTokenUnits } from '../../utils/fromTokenUnit'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; -import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; import type { AccountsSynchronizer } from '../accounts'; import type { AccountsService } from '../accounts/AccountsService'; -import type { AssetsService, TokenHelper } from '../assets'; import type { ConfigProvider } from '../config'; import { SUPPORTED_NETWORKS } from '../config/ConfigProvider'; import type { TransactionsService } from '../transactions'; @@ -34,7 +29,6 @@ import { isSpam } from '../transactions/utils/isSpam'; * - It gets updates when the balance of token assets change by subscribing to each RPC token account. * * On each update: - * - It saves the new balance. Under the hood, AssetsService also notifies the extension. * - It fetches the transaction that caused the native asset or token asset to change and saves it. Under the hood, TransactionsService also notifies the extension. */ export class KeyringAccountMonitor { @@ -42,14 +36,10 @@ export class KeyringAccountMonitor { readonly #accountService: AccountsService; - readonly #assetsService: AssetsService; - readonly #transactionsService: TransactionsService; readonly #accountsSynchronizer: AccountsSynchronizer; - readonly #tokenHelper: TokenHelper; - readonly #configProvider: ConfigProvider; readonly #logger: ILogger; @@ -62,19 +52,15 @@ export class KeyringAccountMonitor { constructor( subscriptionService: SubscriptionService, accountService: AccountsService, - assetsService: AssetsService, transactionsService: TransactionsService, accountsSynchronizer: AccountsSynchronizer, - tokenHelper: TokenHelper, configProvider: ConfigProvider, logger: ILogger, ) { this.#subscriptionService = subscriptionService; this.#accountService = accountService; - this.#assetsService = assetsService; this.#transactionsService = transactionsService; this.#accountsSynchronizer = accountsSynchronizer; - this.#tokenHelper = tokenHelper; this.#configProvider = configProvider; this.#logger = createPrefixedLogger(logger, '[🗝️ KeyringAccountMonitor]'); @@ -322,25 +308,7 @@ export class KeyringAccountMonitor { throw new Error(`No keyring account found for address: ${address}`); } - // Handle the notification with clean data - const { lamports: accountLamports } = notification.params.result.value; - assert(accountLamports, number()); - - const decimals = 9; - - await Promise.all([ - this.#assetsService.save({ - assetType: `${network}/${SolanaCaip19Tokens.SOL}`, - keyringAccountId: keyringAccount.id, - network, - address, - symbol: 'SOL', - decimals, - rawAmount: accountLamports.toString(), - uiAmount: fromTokenUnits(accountLamports, decimals), - }), - this.#saveCausingTransaction(keyringAccount, network, address), - ]); + await this.#saveCausingTransaction(keyringAccount, network, address); } async #handleProgramNotification( @@ -367,60 +335,15 @@ export class KeyringAccountMonitor { const { owner } = notification.params.result.value.account.data.parsed.info; assert(owner, string()); - const { mint } = notification.params.result.value.account.data.parsed.info; - assert(mint, string()); - - const { amount, decimals, uiAmountString } = - notification.params.result.value.account.data.parsed.info.tokenAmount; - assert(amount, string()); - assert(decimals, number()); - assert(uiAmountString, string()); - const { pubkey } = notification.params.result.value; assert(pubkey, string()); - const assetType = tokenAddressToCaip19(network, mint); - const keyringAccount = await this.#accountService.findByAddress(owner); if (!keyringAccount) { throw new Error(`No keyring account found with address: ${owner}`); } - /** - * WARNING: This is to compensate for the fact that the notification returned by Infura's programSubscribe - * includes a uiAmount/uiAmountString that does not take into account the mint's multiplier (if any). - * In theory, it should; because the regular Solana RPC (wss://api.mainnet-beta.solana.com) does. - * - * So this needs to be removed once Infura fixes their programSubscribe notification. - */ - const uiAmount = await this.#tokenHelper - .amountToUiAmountForMint(mint, network, lamports(BigInt(amount))) - .catch(async (error) => { - await trackError(error); - this.#logger.error('Error converting amount to uiAmount', error); - return uiAmountString; - }); - - const metadata = (await this.#assetsService.getAssetsMetadata([assetType]))[ - assetType - ]; - - await Promise.all([ - // Update the balance of the token asset - this.#assetsService.save({ - assetType, - keyringAccountId: keyringAccount.id, - network, - mint, - pubkey, - symbol: metadata?.symbol ?? 'UNKNOWN', - decimals, - rawAmount: amount, - uiAmount, - }), - // Fetch and save the transaction that caused the token asset change. - this.#saveCausingTransaction(keyringAccount, network, pubkey), - ]); + await this.#saveCausingTransaction(keyringAccount, network, pubkey); } /** diff --git a/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts b/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts new file mode 100644 index 000000000..973ffd27d --- /dev/null +++ b/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts @@ -0,0 +1,110 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { SimulationUserOptions } from '@metamask/snaps-simulation'; +import type { CaipAssetType } from '@metamask/utils'; + +type ControllerMessenger = { + registerActionHandler: ( + action: string, + handler: (...args: unknown[]) => unknown, + ) => void; +}; + +const DEFAULT_RAW_AMOUNT = '123456789'; + +function buildMockAsset( + assetId: CaipAssetType, + metadata?: { symbol: string; name: string }, +): Asset { + const chainId = assetId.split('/')[0] as Asset['chainId']; + const isNative = assetId.endsWith('/slip44:501'); + + return { + id: assetId, + chainId, + balance: { amount: DEFAULT_RAW_AMOUNT }, + metadata: { + type: isNative ? 'native' : 'spl', + symbol: metadata?.symbol ?? (isNative ? 'SOL' : 'TOKEN'), + name: metadata?.name ?? (isNative ? 'Solana' : 'Token'), + decimals: 9, + }, + price: { + assetPriceType: 'fungible', + price: 1, + usdPrice: 1, + lastUpdated: 0, + }, + fiatValue: 1, + }; +} + +function buildAssetsForAccount( + accountId: string, + options: SimulationUserOptions, +): Record { + const account = options.accounts?.find((entry) => entry.id === accountId); + if (!account?.assets?.length) { + return {}; + } + + const assets: Record = {}; + for (const assetId of account.assets) { + assets[assetId] = buildMockAsset(assetId, options.assets?.[assetId]); + } + return assets; +} + +/** + * Registers AssetsController messenger handlers for snaps-jest simulation. + * Maps installSnap `accounts` / `assets` options to Core AssetsController reads. + * + * @param controllerMessenger - Controller messenger used to register simulation handlers. + * @param options - installSnap simulation options (`accounts` / `assets`). + */ +export function registerCoreAssetsControllerHandlers( + controllerMessenger: ControllerMessenger, + options: SimulationUserOptions, +): void { + controllerMessenger.registerActionHandler( + 'AssetsController:getAccountAssetByID', + (...args: unknown[]) => { + const accountId = args[0] as string; + const assetId = args[1] as string; + const assets = buildAssetsForAccount(accountId, options); + return assets[assetId]; + }, + ); + + controllerMessenger.registerActionHandler( + 'AssetsController:getAccountAssetsByIDs', + (...args: unknown[]) => { + const accountId = args[0] as string; + const assetIds = args[1] as string[]; + const assets = buildAssetsForAccount(accountId, options); + const result: Record = {}; + for (const assetId of assetIds) { + const asset = assets[assetId]; + if (asset) { + result[assetId] = asset; + } + } + return result; + }, + ); + + controllerMessenger.registerActionHandler( + 'AssetsController:getAccountAssetsByScope', + (...args: unknown[]) => { + const accountId = args[0] as string; + const scope = args[1] as string; + const assets = buildAssetsForAccount(accountId, options); + const result: Record = {}; + for (const [assetId, asset] of Object.entries(assets)) { + if (assetId.startsWith(`${scope}/`)) { + result[assetId] = asset; + } + } + return result; + }, + ); +} diff --git a/packages/solana-wallet-snap/src/features/send/render.tsx b/packages/solana-wallet-snap/src/features/send/render.tsx index dc1524d9e..2974b8fa2 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 58e488561..80242a8e6 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,3 +1,7 @@ +import { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { AssetsProviderMessenger } from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; + import type { ICache } from './core/caching/ICache'; import { InMemoryCache } from './core/caching/InMemoryCache'; import { StateCache } from './core/caching/StateCache'; @@ -13,6 +17,7 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, + SnapAssetsAdapter, AssetsRepository, AssetsService, KeyringAccountMonitor, @@ -46,6 +51,7 @@ import { TransactionScanService } from './core/services/transaction-scan/Transac import { WalletService } from './core/services/wallet/WalletService'; import logger, { noOpLogger } from './core/utils/logger'; import { EventEmitter } from './infrastructure'; +import type { CoreMessenger } from './types/core-messenger'; /** * Initializes all the services using dependency injection. @@ -77,6 +83,11 @@ export type SnapExecutionContext = { accountsService: AccountsService; accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; + /** + * Core messenger plumbing for AssetsProvider reads. + */ + coreMessenger: CoreMessenger; + assetsProvider: AssetsProvider; }; const configProvider = new ConfigProvider(); @@ -144,19 +155,39 @@ 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); +/** + * Core controllers plumbing + */ +const coreMessenger = getMessenger(); +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); + +const assetsService = new AssetsService({ + logger, + configProvider, + snapAssetsAdapter, + accountsService, + tokenApiClient, + tokenPricesService, + nftApiClient, + assetsProvider, +}); const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper( @@ -201,10 +232,8 @@ const signatureMonitor = new SignatureMonitor( const keyringAccountMonitor = new KeyringAccountMonitor( subscriptionService, accountsService, - assetsService, transactionsService, accountsSynchronizer, - tokenHelper, configProvider, logger, ); @@ -288,17 +317,21 @@ const snapContext: SnapExecutionContext = { accountsService, accountsSynchronizer, tokenHelper, + coreMessenger, + assetsProvider, }; export { accountsService, accountsSynchronizer, analyticsService, + assetsProvider, assetsService, clientRequestHandler, configProvider, confirmationHandler, connection, + coreMessenger, eventEmitter, keyring, nameResolutionService, diff --git a/packages/solana-wallet-snap/src/types/core-messenger.ts b/packages/solana-wallet-snap/src/types/core-messenger.ts new file mode 100644 index 000000000..16dcdcf5e --- /dev/null +++ b/packages/solana-wallet-snap/src/types/core-messenger.ts @@ -0,0 +1,37 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE = + 'SolanaWalletSnap' as const; + +export type CoreMessengerActions = + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessengerMessenger = Messenger< + typeof SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; + +/** + * Typed async messenger for Core controller actions available to this Snap via + * `endowment:messenger` / `getMessenger`. + */ +export type CoreMessenger = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; diff --git a/yarn.lock b/yarn.lock index 14ac741c6..803527da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3312,7 +3312,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-networks-utils@workspace:packages/snap-networks-utils": +"@metamask/snap-networks-utils@workspace:^, @metamask/snap-networks-utils@workspace:packages/snap-networks-utils": version: 0.0.0-use.local resolution: "@metamask/snap-networks-utils@workspace:packages/snap-networks-utils" dependencies: @@ -3677,10 +3677,13 @@ __metadata: resolution: "@metamask/solana-wallet-snap@workspace:packages/solana-wallet-snap" dependencies: "@jest/globals": "npm:^29.5.0" + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm:9.1.2" "@metamask/keyring-api": "npm:^23.7.0" "@metamask/keyring-snap-sdk": "npm:^9.2.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/snap-networks-utils": "workspace:^" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.0" "@metamask/snaps-sdk": "npm:^11.2.0"