From d980503d921946c3e8071f5cc77cf1b473dbd151 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:57:19 +0000 Subject: [PATCH 1/2] feat(WPN-1476): route Solana asset reads via migration stages Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 3 +- packages/solana-wallet-snap/jest.setup.ts | 32 ++ .../services/accounts/AccountsSynchronizer.ts | 20 +- .../services/assets/AssetsService.test.ts | 333 ++++++++++++++- .../src/core/services/assets/AssetsService.ts | 384 +++++++++++++++++- .../assets/mapControllerAsset.test.ts | 79 ++++ .../services/assets/mapControllerAsset.ts | 72 ++++ .../assets/shouldTrackSnapAssets.test.ts | 14 + .../services/assets/shouldTrackSnapAssets.ts | 14 + .../services/assets/snapOwnedAssets.test.ts | 17 + .../core/services/assets/snapOwnedAssets.ts | 13 + .../KeyringAccountMonitor.test.ts | 204 +--------- .../subscriptions/KeyringAccountMonitor.ts | 85 +--- .../registerCoreAssetsControllerHandlers.ts | 115 ++++++ .../solana-wallet-snap/src/snapContext.ts | 5 +- .../src/types/core-messenger.ts | 14 +- 16 files changed, 1086 insertions(+), 318 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/mapControllerAsset.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts create mode 100644 packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 71412fb9..4f91b0da 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Wire Core messenger plumbing (`endowment:messenger`, `AssetsProvider`, `RemoteFeatureFlagsProvider`) into the Solana snap. Providers are constructed and injected into `AssetsService` but Snap-owned reads remain the sole production path. +- Route Solana fungible asset reads through AssetsController migration stages (`Off`, `ReadAssetsControllerWithFallback`, `ReadAssetsControllerOnly`), mapping controller assets via `mapControllerAsset` while Snap-owned NFT assets always use `SnapAssetsAdapter`. Gate fungible tracking in `fetch`/`save`/`saveMany` and account monitors via `shouldTrackSnapAssets`. +- Wire Core messenger plumbing (`endowment:messenger`, `AssetsProvider`, `RemoteFeatureFlagsProvider`) 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)) diff --git a/packages/solana-wallet-snap/jest.setup.ts b/packages/solana-wallet-snap/jest.setup.ts index df1465e9..1c7c8359 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/src/core/services/accounts/AccountsSynchronizer.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts index d214690c..6212b9ef 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts @@ -33,16 +33,26 @@ export class AccountsSynchronizer { const assets = ( await Promise.allSettled( - accountsToSync.map(async (account) => - this.#assetsService.fetch(account), - ), + accountsToSync.map(async (account) => { + if ( + await this.#assetsService.shouldTrackSnapAssetsForAccount( + account.id, + ) + ) { + const fetchedAssets = await this.#assetsService.fetch(account); + await this.#assetsService.saveMany(fetchedAssets); + return fetchedAssets; + } + + return 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 15185af7..000364d1 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,13 @@ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { cloneDeep } from 'lodash'; +import type { AssetEntity } from '../../../entities'; +import type { CoreMessengerCaller } from '../../../types/core-messenger'; import type { ICache } from '../../caching/ICache'; import { InMemoryCache } from '../../caching/InMemoryCache'; import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; @@ -32,6 +38,18 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); +const SOLANA_FLAG_KEY = SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana; + +function createMessengerCallMock( + getState: () => unknown, +): CoreMessengerCaller['call'] { + return async (method) => { + if (method === 'RemoteFeatureFlagController:getState') { + return getState() as Awaited>; + } + return undefined; + }; +} describe('AssetsService', () => { let assetsService: AssetsService; let snapAssetsAdapter: SnapAssetsAdapter; @@ -43,9 +61,14 @@ describe('AssetsService', () => { let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; let mockCache: ICache; + let mockAssetsProvider: import('@metamask/snap-networks-utils').AssetsProvider; + let migrationStage: SnapsAssetsMigrationStage; + let mockCoreMessenger: CoreMessengerCaller; + let setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; beforeEach(() => { jest.clearAllMocks(); + migrationStage = SnapsAssetsMigrationStage.Off; mockConnection = createMockConnection(); mockConfigProvider = { @@ -100,21 +123,36 @@ describe('AssetsService', () => { nftApiClient: mockNftApiClient, }); + mockAssetsProvider = { + getAccountAssetByID: jest.fn(), + getAccountAssetsByIDs: jest.fn(), + getAccountAssetsByScope: jest.fn(), + } as unknown as import('@metamask/snap-networks-utils').AssetsProvider; + + setMigrationStage = (stage: SnapsAssetsMigrationStage) => { + migrationStage = stage; + }; + + mockCoreMessenger = { + call: jest.fn().mockImplementation( + createMessengerCallMock(() => ({ + remoteFeatureFlags: { + [SOLANA_FLAG_KEY]: { stage: migrationStage }, + }, + })), + ), + }; + assetsService = new AssetsService({ logger: mockLogger, configProvider: mockConfigProvider, snapAssetsAdapter, + coreMessenger: mockCoreMessenger, + accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, tokenPricesService: mockTokenPricesService, nftApiClient: mockNftApiClient, - remoteFeatureFlagsProvider: { - getFeatureFlags: jest.fn(), - } as unknown as import('@metamask/snap-networks-utils').RemoteFeatureFlagsProvider, - assetsProvider: { - getAccountAssetByID: jest.fn(), - getAccountAssetsByIDs: jest.fn(), - getAccountAssetsByScope: jest.fn(), - } as unknown as import('@metamask/snap-networks-utils').AssetsProvider, + assetsProvider: mockAssetsProvider, }); }); @@ -706,4 +744,283 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('assets migration routing', () => { + beforeEach(() => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + }); + + 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(); + }); + + it('routes fungible assets through SnapAssetsAdapter when stage is Off', async () => { + setMigrationStage(SnapsAssetsMigrationStage.Off); + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(mockAssetsProvider.getAccountAssetByID).not.toHaveBeenCalled(); + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + + it('falls back to SnapAssetsAdapter when Core read fails in WithFallback stage', async () => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + ); + jest + .spyOn(mockAssetsProvider, 'getAccountAssetByID') + .mockRejectedValueOnce(new Error('Core unavailable')); + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + }); + + 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[MOCK_ASSET_ENTITY_0.assetType]).toMatchObject({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }); + expect(assets[nftAssetType]).toBeNull(); + }); + }); + + 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, 'getAccountAssetsByScope') + .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 92f0d77b..f9ed3e52 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,16 +1,21 @@ /* eslint-disable jsdoc/require-returns */ -import type { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + getSnapsAssetsMigrationNamespace, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; +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, CaipChainId } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; +import type { CoreMessengerCaller } from '../../../types/core-messenger'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; import { SolanaCaip19Tokens } from '../../constants/solana'; @@ -22,11 +27,26 @@ import type { } from '../../constants/solana'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; +import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { TokenPricesService } from '../token-prices/TokenPrices'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; +import { mapControllerAsset } from './mapControllerAsset'; +import { shouldTrackSnapAssets } from './shouldTrackSnapAssets'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; import type { AssetMetadata, NonFungibleAssetMetadata } from './types'; +export { shouldTrackSnapAssets }; + +/** + * Assets migration stage used when no remote feature flag is set for the chain. + */ +const ASSETS_MIGRATION_STAGE = SnapsAssetsMigrationStage.Off; + +function isFungibleProviderAsset(assetId: string): boolean { + return !isSnapOwnedAsset(assetId); +} + export class AssetsService { readonly #logger: ILogger; @@ -34,6 +54,12 @@ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; + readonly #assetsProvider: AssetsProvider; + + readonly #coreMessenger: CoreMessengerCaller; + + readonly #accountsService: AccountsService; + readonly #tokenPricesService: TokenPricesService; readonly #tokenApiClient: TokenApiClient; @@ -44,32 +70,181 @@ export class AssetsService { logger, configProvider, snapAssetsAdapter, + coreMessenger, + accountsService, tokenApiClient, tokenPricesService, nftApiClient, + assetsProvider, }: { logger: ILogger; configProvider: ConfigProvider; snapAssetsAdapter: SnapAssetsAdapter; + coreMessenger: CoreMessengerCaller; + accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; nftApiClient: NftApiClient; - /** - * Core plumbing for a follow-up PR that routes fungible reads via - * AssetsController. Required in the constructor options so DI is wired - * without changing callers again when routing lands. - */ - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; assetsProvider: AssetsProvider; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); this.#configProvider = configProvider; this.#snapAdapter = snapAssetsAdapter; + this.#coreMessenger = coreMessenger; + this.#accountsService = accountsService; + this.#assetsProvider = assetsProvider; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; this.#nftApiClient = nftApiClient; } + async #resolveMigrationStage( + chainId: string, + ): Promise { + const { remoteFeatureFlags } = await this.#coreMessenger.call( + 'RemoteFeatureFlagController:getState', + ); + + const namespace = getSnapsAssetsMigrationNamespace(chainId as CaipChainId); + + if (namespace) { + const flagKey = SNAPS_ASSETS_MIGRATION_FLAG_KEYS[namespace]; + + if (Object.hasOwn(remoteFeatureFlags, flagKey)) { + const remoteStage = parseSnapsAssetsMigrationStage( + remoteFeatureFlags[flagKey] as Json | undefined, + ); + + if (remoteStage !== undefined) { + return remoteStage; + } + } + } + + return ASSETS_MIGRATION_STAGE; + } + + async #solanaChainIds(): Promise { + return this.#configProvider.getActiveNetworks(); + } + + async #filterTrackableAssets(assets: AssetEntity[]): Promise { + const filtered: AssetEntity[] = []; + + for (const asset of assets) { + if (isSnapOwnedAsset(asset.assetType)) { + filtered.push(asset); + continue; + } + + if (await this.shouldTrackSnapAssetsForScope(asset.network)) { + filtered.push(asset); + } + } + + return filtered; + } + + async shouldTrackSnapAssetsForScope(scope: CaipChainId): Promise { + const stage = await this.#resolveMigrationStage(scope); + return shouldTrackSnapAssets(stage); + } + + async shouldTrackSnapAssetsForAccount(accountId: string): Promise { + const account = await this.#accountsService.findById(accountId); + if (!account) { + return false; + } + + for (const scope of account.scopes) { + if (await this.shouldTrackSnapAssetsForScope(scope)) { + return true; + } + } + + return false; + } + + 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), @@ -194,7 +369,8 @@ export class AssetsService { } async fetch(account: SolanaKeyringAccount): Promise { - return this.#snapAdapter.fetch(account); + const assets = await this.#snapAdapter.fetch(account); + return this.#filterTrackableAssets(assets); } async fetchAssetsMarketData( @@ -217,7 +393,13 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { - return this.#snapAdapter.saveMany(assets); + const trackableAssets = await this.#filterTrackableAssets(assets); + + if (trackableAssets.length === 0) { + return; + } + + await this.#snapAdapter.saveMany(trackableAssets); } /** @@ -245,7 +427,43 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + if (isSnapOwnedAsset(assetId)) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + const stage = await this.#resolveMigrationStage(chainId); + + if (stage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + const account = await this.#accountsService.findById(accountId); + if (!account) { + return null; + } + + if (stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback) { + try { + const coreAsset = await this.#getCoreAccountAssetByID( + accountId, + assetId as CaipAssetType, + account.address, + ); + if (coreAsset) { + return coreAsset; + } + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } catch { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + } + + return this.#getCoreAccountAssetByID( + accountId, + assetId as CaipAssetType, + account.address, + ); } /** @@ -259,7 +477,78 @@ export class AssetsService { accountId: string, assetIds: string[], ): Promise> { - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); + if (assetIds.length === 0) { + return {}; + } + + const result: Record = {}; + const fungibleIds: string[] = []; + const snapOwnedIds: string[] = []; + + for (const assetId of assetIds) { + if (isSnapOwnedAsset(assetId)) { + snapOwnedIds.push(assetId); + } else { + fungibleIds.push(assetId); + } + } + + if (snapOwnedIds.length > 0) { + const snapResults = await this.#snapAdapter.getAccountAssetsByIDs( + accountId, + snapOwnedIds, + ); + Object.assign(result, snapResults); + } + + if (fungibleIds.length === 0) { + return result; + } + + const { chainId } = parseCaipAssetType(fungibleIds[0] as CaipAssetType); + const stage = await this.#resolveMigrationStage(chainId); + const account = await this.#accountsService.findById(accountId); + + if (!account) { + fungibleIds.forEach((assetId) => { + result[assetId] = null; + }); + return result; + } + + let fungibleResults: Record; + + if (stage === SnapsAssetsMigrationStage.Off) { + fungibleResults = await this.#snapAdapter.getAccountAssetsByIDs( + accountId, + fungibleIds, + ); + } else if ( + stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback + ) { + try { + fungibleResults = await this.#getCoreAccountAssetsByIDs( + accountId, + fungibleIds, + account.address, + ); + } catch { + fungibleResults = await this.#snapAdapter.getAccountAssetsByIDs( + accountId, + fungibleIds, + ); + } + } else { + fungibleResults = await this.#getCoreAccountAssetsByIDs( + accountId, + fungibleIds, + account.address, + ); + } + + Object.assign(result, fungibleResults); + + return result; } /** @@ -272,7 +561,50 @@ export class AssetsService { scope: CaipChainId, accountId: string, ): Promise { - return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); + const stage = await this.#resolveMigrationStage(scope); + const snapAssets = await this.#snapAdapter.getAccountAssetsByScope( + scope, + accountId, + ); + const nftAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + + if (stage === SnapsAssetsMigrationStage.Off) { + const fungibleAssets = snapAssets.filter( + (asset) => !isSnapOwnedAsset(asset.assetType), + ); + return [...fungibleAssets, ...nftAssets]; + } + + const account = await this.#accountsService.findById(accountId); + if (!account) { + return nftAssets; + } + + let fungibleAssets: AssetEntity[]; + + if (stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback) { + try { + fungibleAssets = await this.#getCoreAccountAssetsByScope( + scope, + accountId, + account.address, + ); + } catch { + fungibleAssets = snapAssets.filter( + (asset) => !isSnapOwnedAsset(asset.assetType), + ); + } + } else { + fungibleAssets = await this.#getCoreAccountAssetsByScope( + scope, + accountId, + account.address, + ); + } + + return [...fungibleAssets, ...nftAssets]; } /** @@ -283,10 +615,28 @@ export class AssetsService { async getAccountAssetsForAllActiveScopes( accountId: string, ): Promise { - return this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); + const account = await this.#accountsService.findById(accountId); + if (!account) { + return []; + } + + const chainIds = (await this.#solanaChainIds()) as CaipChainId[]; + const relevantChainIds = chainIds.filter((chainId) => + account.scopes.includes(chainId), + ); + + const assetsByScope = await Promise.all( + relevantChainIds.map((scope) => + this.getAccountAssetsByScope(scope, accountId), + ), + ); + + return assetsByScope.flat(); } async findByAccount(account: SolanaKeyringAccount): Promise { return this.#snapAdapter.findByAccount(account); } } + +export { SnapsAssetsMigrationStage }; 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 00000000..fa8f3152 --- /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 00000000..d17f82b8 --- /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/shouldTrackSnapAssets.test.ts b/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts new file mode 100644 index 00000000..8d8110dc --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts @@ -0,0 +1,14 @@ +import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; + +import { shouldTrackSnapAssets } from './shouldTrackSnapAssets'; + +describe('shouldTrackSnapAssets', () => { + it.each([ + [SnapsAssetsMigrationStage.Off, true], + [SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, true], + [SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, true], + [SnapsAssetsMigrationStage.ReadAssetsControllerOnly, false], + ])('returns %s for stage %s', (stage, expected) => { + expect(shouldTrackSnapAssets(stage)).toBe(expected); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts b/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts new file mode 100644 index 00000000..cd15b341 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts @@ -0,0 +1,14 @@ +import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; + +/** + * Returns whether the Snap should persist fungible asset balances for the given + * migration stage. NFT assets are always tracked by the Snap regardless of stage. + * + * @param stage - Assets migration stage for the chain. + * @returns Whether Snap-side fungible asset tracking is enabled. + */ +export function shouldTrackSnapAssets( + stage: SnapsAssetsMigrationStage, +): boolean { + return stage < SnapsAssetsMigrationStage.ReadAssetsControllerOnly; +} 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 00000000..3b249e59 --- /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 00000000..872deb88 --- /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/subscriptions/KeyringAccountMonitor.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts index 578e2dca..ee96f44b 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 38eb4c32..34dc2ba1 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 00000000..232388b4 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts @@ -0,0 +1,115 @@ +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; + }, + ); + + controllerMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {} }), + ); +} diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 6d012d1d..94676b04 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -192,10 +192,11 @@ const assetsService = new AssetsService({ logger, configProvider, snapAssetsAdapter, + coreMessenger, + accountsService, tokenApiClient, tokenPricesService, nftApiClient, - remoteFeatureFlagsProvider, assetsProvider, }); @@ -242,10 +243,8 @@ const signatureMonitor = new SignatureMonitor( const keyringAccountMonitor = new KeyringAccountMonitor( subscriptionService, accountsService, - assetsService, transactionsService, accountsSynchronizer, - tokenHelper, configProvider, logger, ); diff --git a/packages/solana-wallet-snap/src/types/core-messenger.ts b/packages/solana-wallet-snap/src/types/core-messenger.ts index a430bbd9..3982bfb1 100644 --- a/packages/solana-wallet-snap/src/types/core-messenger.ts +++ b/packages/solana-wallet-snap/src/types/core-messenger.ts @@ -5,6 +5,7 @@ import type { } from '@metamask/assets-controller'; import type { Messenger } from '@metamask/messenger'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; /** * Namespace for this Snap's Core messenger endowment. @@ -21,7 +22,18 @@ export type CoreMessengerActions = /** * Messenger type passed to `getMessenger` for Core controller actions. */ -export type CoreMessenger = Messenger< +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; From 10ba0a63b95ccd1fb4153a254567c18c90c0f1f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:58:00 +0000 Subject: [PATCH 2/2] chore: update snap manifest shasum after build Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index c6da0ee6..c588fb0b 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": "8xioVFo7Tpep5rVLoWUqeg+bvogIihY9EY6YxFTelDY=", + "shasum": "5UbMR/XOp/xr10ccku+kJxErgd1zptASqMx4tavLCL8=", "location": { "npm": { "filePath": "dist/bundle.js",