From 7be990e6219c27d9bacdfe57f9efefbac569602e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:44:32 +0000 Subject: [PATCH 1/8] chore(WPN-1652): align Solana AssetsService read API with snap-networks-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, and getAccountAssetsForAllActiveScopes. Update Keyring, Send, send render, and refreshSend to use the new API. No behavior change — still reads from Snap-owned assetEntities via AssetsRepository. Migrated from MetaMask/snap-solana-wallet#635. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../solana-wallet-snap/snap.manifest.json | 2 +- .../backgroundEvents/refreshSend.test.tsx | 24 ++++- .../backgroundEvents/refreshSend.tsx | 34 +++++-- .../handlers/onKeyringRequest/Keyring.test.ts | 56 +++++++---- .../core/handlers/onKeyringRequest/Keyring.ts | 16 +++- .../services/assets/AssetsService.test.ts | 84 ++++++++++++++++ .../src/core/services/assets/AssetsService.ts | 96 ++++++++++++++++++- .../core/services/send/SendService.test.ts | 79 ++++++++++----- .../src/core/services/send/SendService.ts | 12 +-- .../src/features/send/render.tsx | 20 ++-- .../solana-wallet-snap/src/snapContext.ts | 8 +- 12 files changed, 353 insertions(+), 79 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 3d37116f6..9919171b5 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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). - This package was migrated from [snap-solana-wallet](https://github.com/MetaMask/snap-solana-wallet). See the source repository for the original [changelog](https://github.com/MetaMask/snap-solana-wallet/blob/main/packages/snap/CHANGELOG.md). ([#72](https://github.com/MetaMask/internal-snaps/pull/72)) [Unreleased]: https://github.com/MetaMask/internal-snaps/ diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index fe9472582..1fa6e7de0 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": "vHRHiEOC6PZbl9L3n+k9GvwCh+K8Dzh3Zz0+TnVO5O4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.test.tsx index 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/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index d7e7f6726..fd8880238 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -17,6 +17,7 @@ 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'; @@ -35,6 +36,7 @@ describe('AssetsService', () => { let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; + let mockAccountsService: AccountsService; let mockTokenApiClient: TokenApiClient; let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; @@ -81,11 +83,16 @@ describe('AssetsService', () => { saveMany: jest.fn(), } as unknown as AssetsRepository; + mockAccountsService = { + findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), + } as unknown as AccountsService; + assetsService = new AssetsService({ connection: mockConnection, logger: mockLogger, configProvider: mockConfigProvider, assetsRepository: mockAssetsRepository, + accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, tokenPricesService: mockTokenPricesService, cache: mockCache, @@ -604,4 +611,81 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('getAccountAssetByID', () => { + it('returns the matching asset when present', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + + it('returns null when the asset is missing', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('returns a record keyed by asset ID', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + }); + }); + + it('returns null entries for missing assets', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('filters account assets to the requested scope', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + }); }); diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts index a2778e7b1..7d5bd7f03 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -11,7 +11,7 @@ import type { FungibleAssetMarketData, FungibleAssetMetadata, } from '@metamask/snaps-sdk'; -import type { CaipAssetType } from '@metamask/utils'; +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'; @@ -46,6 +46,7 @@ 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'; @@ -71,6 +72,8 @@ export class AssetsService { readonly #assetsRepository: AssetsRepository; + readonly #accountsService: AccountsService; + readonly #tokenPricesService: TokenPricesService; readonly #tokenApiClient: TokenApiClient; @@ -88,6 +91,7 @@ export class AssetsService { logger, configProvider, assetsRepository, + accountsService, tokenApiClient, tokenPricesService, cache, @@ -97,6 +101,7 @@ export class AssetsService { logger: ILogger; configProvider: ConfigProvider; assetsRepository: AssetsRepository; + accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; cache: ICache; @@ -106,6 +111,7 @@ export class AssetsService { this.#connection = connection; this.#configProvider = configProvider; this.#assetsRepository = assetsRepository; + this.#accountsService = accountsService; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; this.#cache = cache; @@ -640,6 +646,94 @@ export class AssetsService { 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; 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/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..32fc38f63 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -144,20 +144,22 @@ const tokenPricesService = new TokenPricesService({ const nameResolutionService = new NameResolutionService(connection, logger); const assetsRepository = new AssetsRepository(state); + +const accountsRepository = new AccountsRepository(state); +const accountsService = new AccountsService(accountsRepository); + const assetsService = new AssetsService({ connection, logger, configProvider, assetsRepository, + accountsService, tokenApiClient, cache: inMemoryCache, tokenPricesService, nftApiClient, }); -const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService(accountsRepository); - const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper( tokenHelper, From de7476970f46421e5ef9a05ab39c9e4e8613e81b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:45:22 +0000 Subject: [PATCH 2/8] chore(WPN-1652): link changelog entry to #120 Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 9919171b5..4ba905bae 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- 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). +- 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/ From 0b8c00fafe0ab8b9ebf4e9f66ee8b4b0c25bc764 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:46:49 +0000 Subject: [PATCH 3/8] refactor(WPN-1652): extract SnapAssetsAdapter for Solana balance reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move balance fetch/persist/read logic into SnapAssetsAdapter. AssetsService delegates to a single adapter — no Core routing yet. Migrated from MetaMask/snap-solana-wallet#636. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../solana-wallet-snap/snap.manifest.json | 2 +- .../services/assets/AssetsService.test.ts | 14 +- .../src/core/services/assets/AssetsService.ts | 525 +-------------- .../assets/adapters/SnapAssetsAdapter.test.ts | 105 +++ .../assets/adapters/SnapAssetsAdapter.ts | 622 ++++++++++++++++++ .../src/core/services/assets/index.ts | 1 + .../solana-wallet-snap/src/snapContext.ts | 11 +- 8 files changed, 769 insertions(+), 512 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts create mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 4ba905bae..53933ffae 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extract Snap-owned balance fetch/persist/read logic into `SnapAssetsAdapter`; `AssetsService` delegates account asset reads and saves through the adapter (no Core routing yet). - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssetsForAllActiveScopes`, and routing Keyring, Send, send render, and `refreshSend` through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) - This package was migrated from [snap-solana-wallet](https://github.com/MetaMask/snap-solana-wallet). See the source repository for the original [changelog](https://github.com/MetaMask/snap-solana-wallet/blob/main/packages/snap/CHANGELOG.md). ([#72](https://github.com/MetaMask/internal-snaps/pull/72)) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 1fa6e7de0..84e85f44c 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": "vHRHiEOC6PZbl9L3n+k9GvwCh+K8Dzh3Zz0+TnVO5O4=", + "shasum": "j3n+ylk6thi2g8W+qqmF7S56WcW2d9Kq5AhjCkqZSAk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index fd8880238..e0b161b81 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -24,6 +24,7 @@ import { mockLogger } from '../mocks/logger'; import { createMockConnection } from '../mocks/mockConnection'; import { MOCK_SOLANA_RPC_GET_TOKEN_ACCOUNTS_BY_OWNER_RESPONSE } from '../mocks/mockSolanaRpcResponses'; import type { TokenPricesService } from '../token-prices/TokenPrices'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import { AssetsService } from './AssetsService'; @@ -33,6 +34,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; + let snapAssetsAdapter: SnapAssetsAdapter; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; @@ -87,17 +89,25 @@ describe('AssetsService', () => { findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), } as unknown as AccountsService; - assetsService = new AssetsService({ + snapAssetsAdapter = new SnapAssetsAdapter({ connection: mockConnection, logger: mockLogger, configProvider: mockConfigProvider, assetsRepository: mockAssetsRepository, accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, - tokenPricesService: mockTokenPricesService, cache: mockCache, nftApiClient: mockNftApiClient, }); + + assetsService = new AssetsService({ + logger: mockLogger, + configProvider: mockConfigProvider, + snapAssetsAdapter, + tokenApiClient: mockTokenApiClient, + tokenPricesService: mockTokenPricesService, + nftApiClient: mockNftApiClient, + }); }); describe('fetch', () => { 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 7d5bd7f03..152caf539 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,120 +1,61 @@ /* eslint-disable jsdoc/require-returns */ - -import { KeyringEvent } from '@metamask/keyring-api'; -import type { - AccountAssetListUpdatedEvent, - AccountBalancesUpdatedEvent, - Balance, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { FungibleAssetMarketData, FungibleAssetMetadata, } from '@metamask/snaps-sdk'; import type { CaipAssetType, 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 { 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 type { AssetMetadata, NonFungibleAssetMetadata } from './types'; -/** - * Extends a token account as returned by the `getTokenAccountsByOwner` RPC method with the scope and the caip-19 asset type for convenience. - */ -type TokenAccountWithMetadata = { - token: AccountInfoWithPubkey; - scope: Network; - assetType: TokenCaipAssetType; - keyringAccount: SolanaKeyringAccount; -} & Serializable; - export class AssetsService { readonly #logger: ILogger; - readonly #connection: SolanaConnection; - readonly #configProvider: ConfigProvider; - readonly #assetsRepository: AssetsRepository; - - readonly #accountsService: AccountsService; + readonly #snapAdapter: SnapAssetsAdapter; readonly #tokenPricesService: TokenPricesService; readonly #tokenApiClient: TokenApiClient; - readonly #cache: ICache; - readonly #nftApiClient: NftApiClient; - public static readonly cacheTtlsMilliseconds = { - tokenAccountsByOwner: 5 * Duration.Second, - }; - constructor({ - connection, logger, configProvider, - assetsRepository, - accountsService, + snapAssetsAdapter, tokenApiClient, tokenPricesService, - cache, nftApiClient, }: { - connection: SolanaConnection; logger: ILogger; configProvider: ConfigProvider; - assetsRepository: AssetsRepository; - accountsService: AccountsService; + snapAssetsAdapter: SnapAssetsAdapter; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; - cache: ICache; nftApiClient: NftApiClient; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#connection = connection; this.#configProvider = configProvider; - this.#assetsRepository = assetsRepository; - this.#accountsService = accountsService; + this.#snapAdapter = snapAssetsAdapter; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; - this.#cache = cache; this.#nftApiClient = nftApiClient; } @@ -241,221 +182,8 @@ export class AssetsService { }; } - /** - * Matrix-fetches all token accounts owned by the given address on the specified networks and program ids, - * and merges the results into a single array. Each individual token is augmented with the scope and the caip-19 asset type for convenience. - * - * It caches the results for each pair of scope and program id. - * - * @param accounts - The owners of the token accounts. - * @param programIds - The program ids to fetch the token accounts for. - * @param scopes - The networks to fetch the token accounts for. - * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. - */ - async #fetchTokenAccountsMultiple( - accounts: SolanaKeyringAccount[], - programIds: Address[] = [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], - scopes: Network[] = [Network.Mainnet], - ): Promise { - if (programIds.length === 0 || scopes.length === 0) { - return []; - } - - // Create all combinations of account, programId, and scope - const combinations = accounts.flatMap((account) => - programIds.flatMap((programId) => - scopes.map((scope) => ({ account, programId, scope })), - ), - ); - - const fetchTokenAccountsCached = useCache< - [SolanaKeyringAccount, Address, Network], - TokenAccountWithMetadata[] - >(this.#fetchTokenAccounts.bind(this), this.#cache, { - functionName: 'AssetsService:fetchTokenAccounts', - ttlMilliseconds: AssetsService.cacheTtlsMilliseconds.tokenAccountsByOwner, - generateCacheKey: (functionName, args) => { - const [account, programId, scope] = args; - return `${functionName}:${account.id}:${programId}:${scope}`; - }, - }); - - const responses = await Promise.allSettled( - combinations.map(async ({ account, programId, scope }) => { - const response = await fetchTokenAccountsCached( - account, - programId, - scope, - ); - return response; - }), - ); - - return responses.flatMap((item) => - item.status === 'fulfilled' ? item.value : [], - ); - } - - /** - * Fetches the token accounts for the given owner and program id on the specified scope. - * - * @param account - The owner of the token accounts. - * @param programId - The program id to fetch the token accounts for. - * @param scope - The scope to fetch the token accounts for. - * @returns The token accounts augmented with the scope and the caip-19 asset type for convenience. - */ - async #fetchTokenAccounts( - account: SolanaKeyringAccount, - programId: Address = TOKEN_PROGRAM_ADDRESS, - scope: Network = Network.Mainnet, - ): Promise { - const response = await this.#connection - .getRpc(scope) - .getTokenAccountsByOwner( - asAddress(account.address), - { programId }, - { encoding: 'jsonParsed' }, - ) - .send(); - - const tokens = response.value; - - // Attach the scope and the caip-19 asset type to each token account for easier future reference - return tokens.map( - (token) => - ({ - token, - scope, - assetType: tokenAddressToCaip19( - scope, - token.account.data.parsed.info.mint, - ), - keyringAccount: account, - }) as TokenAccountWithMetadata, - ); - } - - /** - * Fetches all assets for the given account. - * - * @param account - The account to get the balances for. - * @returns The balances and metadata of the account for the given assets. - */ async fetch(account: SolanaKeyringAccount): Promise { - const [nativeAssets, tokenAccounts] = await Promise.all([ - this.#fetchNativeAssets(account), - this.#fetchTokenAccountsMultiple( - [account], - [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS], - await this.#configProvider.getActiveNetworks(), - ), - ]); - - const assetTypes = tokenAccounts.map( - (tokenAccount) => tokenAccount.assetType, - ); - - const tokensMetadata = - await this.#tokenApiClient.getTokensMetadata(assetTypes); - - const tokenAssets: TokenAsset[] = tokenAccounts - .filter((tokenAccount) => tokenAccount.assetType.includes('/token:')) - .map((tokenAccount) => { - const { assetType } = tokenAccount; - const { decimals, amount, uiAmountString } = - tokenAccount.token.account.data.parsed.info.tokenAmount; - - return { - assetType, - keyringAccountId: tokenAccount.keyringAccount.id, - network: tokenAccount.scope, - mint: tokenAccount.token.account.data.parsed.info.mint, - pubkey: tokenAccount.token.pubkey, - symbol: tokensMetadata[assetType]?.symbol ?? 'UNKNOWN', - decimals, - rawAmount: amount, - uiAmount: uiAmountString ?? fromTokenUnits(amount, decimals), - }; - }); - - // const nftAssets = await this.#fetchNftAssets(account, tokenAccounts.filter( - // (token) => token.assetType.includes('/nft:'), - // )); - - return [ - ...nativeAssets, - ...tokenAssets, - // ...nftAssets, - ]; - } - - async getNativeAssetTypes(): Promise { - const activeNetworks = await this.#configProvider.getActiveNetworks(); - return activeNetworks.map( - (network) => `${network}/${SolanaCaip19Tokens.SOL}` as const, - ); - } - - async #fetchNativeAssets( - account: SolanaKeyringAccount, - ): Promise { - const nativeAssetsTypes = await this.getNativeAssetTypes(); - - const accountAddress = asAddress(account.address); - - const balancePromises = nativeAssetsTypes.map(async (assetType) => { - const balance = await this.#connection - .getRpc(getNetworkFromToken(assetType)) - .getBalance(accountAddress) - .send(); - - return { - assetType, - keyringAccountId: account.id, - network: getNetworkFromToken(assetType), - address: accountAddress, - symbol: 'SOL', - decimals: 9, - rawAmount: balance.value.toString(), - uiAmount: fromTokenUnits(balance.value, 9), - }; - }); - - const results = (await Promise.allSettled(balancePromises)).flatMap( - (item) => (item.status === 'fulfilled' ? item.value : []), - ); - - return results; - } - - async #fetchNftAssets( - account: SolanaKeyringAccount, - assetIds: NftCaipAssetType[], - ): Promise> { - const accountAddress = asAddress(account.address); - - const nftAssets = - await this.#nftApiClient.listAddressSolanaNfts(accountAddress); - const balances: Record = {}; - - for (const assetId of assetIds) { - const { assetReference } = parseCaipAssetType(assetId); - - const nftAsset = nftAssets.find( - (nft) => nft.tokenAddress === assetReference, - ); - - if (!nftAsset) { - continue; - } - - balances[assetId] = { - unit: nftAsset.nftToken.name, - amount: nftAsset.balance.toString(), - }; - } - - return balances; + return this.#snapAdapter.fetch(account); } async fetchAssetsMarketData( @@ -478,144 +206,7 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { - this.#logger.info('Saving assets', assets); - - /** - * Should we save the assets incrementally? - * - If true, only saves and emits events for the assets that have changed (new or balance changed). Better performance because it only informs the client of what has changed. - * - If false, saves all assets. More reliable because it enforces that the client has the same state of assets as the snap. - */ - const isIncremental = false; - - const hasZeroAmount = (asset: AssetEntity) => - asset.rawAmount === '0' || asset.uiAmount === '0'; - - const hasNonZeroAmount = (asset: AssetEntity) => !hasZeroAmount(asset); - - const savedAssets = await this.getAll(); - - // Save assets using repository - await this.#assetsRepository.saveMany(assets); - - // Notify the extension about the new assets in a single event - const isNew = (asset: AssetEntity) => - !savedAssets.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - const wasSavedWithZeroAmount = (asset: AssetEntity) => { - const savedAsset = savedAssets.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - return savedAsset && hasZeroAmount(savedAsset); - }; - - const isNativeAsset = (asset: AssetEntity) => - asset.assetType.includes(SolanaCaip19Tokens.SOL); - - const shouldBeInRemovedList = (asset: AssetEntity) => - hasZeroAmount(asset) && !isNativeAsset(asset); // Never remove native assets from the account asset list - - const shouldBeInAddedList = (asset: AssetEntity) => - !shouldBeInRemovedList(asset) && - (!isIncremental || - ((isNew(asset) || wasSavedWithZeroAmount(asset)) && - hasNonZeroAmount(asset))); - - const assetListUpdatedPayload = assets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [ - ...(acc[asset.keyringAccountId]?.added ?? []), - ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), - ], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), - ], - }, - }), - {}, - ); - - // If no assets were added or removed, don't emit the event. - const isEmptyAccountAssetListUpdatedPayload = Object.values( - assetListUpdatedPayload, - ) - .map((item) => item.added.length + item.removed.length) - .every((item) => item === 0); - - if (!isEmptyAccountAssetListUpdatedPayload) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - } - - // Notify the extension about the changed balances in a single event - - const hasChanged = (asset: AssetEntity) => - AssetsService.hasChanged(asset, savedAssets); - - /** - * Build the event payload for snap keyring event `AccountBalancesUpdated`. - * - * @example - * { - * "balances": { - * "keyringAccountId0": { - * "assetType00": { - * "unit": "XYZ", - * "amount": "1234" - * }, - * "assetType01": { - * "unit": "ABC", - * "amount": "5678" - * } - * }, - * "keyringAccountId1": { - * "assetType10": { - * "unit": "XYZ", - * "amount": "42" - * } - * } - * } - * } - */ - const balancesUpdatedPayload = assets - .filter(isIncremental ? hasChanged : () => true) - .reduce( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - ...(acc[asset.keyringAccountId] ?? {}), - [asset.assetType]: { - unit: asset.symbol, - amount: asset.uiAmount, - }, - }, - }), - {}, - ); - - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. - const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes - .some((count) => count > 0); - - // Only emit the event if some balance was changed. - if (isSomeBalanceChanged) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } + return this.#snapAdapter.saveMany(assets); } /** @@ -626,24 +217,11 @@ export class AssetsService { * @returns True if the asset has changed, false otherwise. */ static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - if (!savedAsset) { - return true; - } - - const rawAmountChanged = savedAsset.rawAmount !== asset.rawAmount; - const uiAmountChanged = savedAsset.uiAmount !== asset.uiAmount; - - return rawAmountChanged || uiAmountChanged; + return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } async getAll(): Promise { - return this.#assetsRepository.getAll(); + return this.#snapAdapter.getAll(); } /** @@ -656,11 +234,7 @@ export class AssetsService { 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; + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } /** @@ -674,24 +248,7 @@ export class AssetsService { 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, - ]), - ); + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } /** @@ -704,15 +261,7 @@ export class AssetsService { 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)); + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } /** @@ -723,50 +272,10 @@ export class AssetsService { 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(); + return this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); } async findByAccount(account: SolanaKeyringAccount): Promise { - const { id: keyringAccountId, address } = account; - - const savedAssets = - await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); - - // Every account must have at least the native assets. Ensure that they are always present, even if not yet fetched/saved. - const nativeAssetTypes = await this.getNativeAssetTypes(); - const missingNativeAssets: NativeAsset[] = []; - - for (const nativeAssetType of nativeAssetTypes) { - const hasNativeAsset = savedAssets.some( - (asset) => asset.assetType === nativeAssetType, - ); - - if (!hasNativeAsset) { - // Create a placeholder native asset with zero balance - // This will be updated when assets are actually fetched - const network = getNetworkFromToken(nativeAssetType); - - missingNativeAssets.push({ - assetType: nativeAssetType, - keyringAccountId: account.id, - network, - address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }); - } - } - - return [...savedAssets, ...missingNativeAssets]; + return this.#snapAdapter.findByAccount(account); } } diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts new file mode 100644 index 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/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 32fc38f63..488250dfa 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -13,6 +13,7 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, + SnapAssetsAdapter, AssetsRepository, AssetsService, KeyringAccountMonitor, @@ -148,7 +149,7 @@ const assetsRepository = new AssetsRepository(state); const accountsRepository = new AccountsRepository(state); const accountsService = new AccountsService(accountsRepository); -const assetsService = new AssetsService({ +const snapAssetsAdapter = new SnapAssetsAdapter({ connection, logger, configProvider, @@ -156,6 +157,14 @@ const assetsService = new AssetsService({ accountsService, tokenApiClient, cache: inMemoryCache, + nftApiClient, +}); + +const assetsService = new AssetsService({ + logger, + configProvider, + snapAssetsAdapter, + tokenApiClient, tokenPricesService, nftApiClient, }); From 852614d11ff9a1058bcc541e25d05aee63164b61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:50:34 +0000 Subject: [PATCH 4/8] feat(WPN-1652): add Core messenger plumbing for Solana AssetsProvider Wire endowment:messenger, AssetsProvider, and RemoteFeatureFlagsProvider following the Tron WPN-1497 pattern. Providers are injected into AssetsService but Snap-owned reads remain the sole production path. Migrated from MetaMask/snap-solana-wallet#637 (Tron-style AssetsProvider instead of a literal CoreAssetsAdapter). Co-authored-by: Ulisses Ferreira --- package.json | 2 +- packages/solana-wallet-snap/CHANGELOG.md | 3 +- packages/solana-wallet-snap/package.json | 4 +++ .../solana-wallet-snap/snap.manifest.json | 12 +++++-- .../services/assets/AssetsService.test.ts | 8 +++++ .../src/core/services/assets/AssetsService.ts | 11 ++++++ .../solana-wallet-snap/src/snapContext.ts | 36 +++++++++++++++++++ .../src/types/core-messenger.ts | 27 ++++++++++++++ yarn.lock | 6 +++- 9 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 packages/solana-wallet-snap/src/types/core-messenger.ts 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 53933ffae..71412fb94 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 -- Extract Snap-owned balance fetch/persist/read logic into `SnapAssetsAdapter`; `AssetsService` delegates account asset reads and saves through the adapter (no Core routing yet). +- 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. +- 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/package.json b/packages/solana-wallet-snap/package.json index 642f2ecc8..51f74c9fe 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -52,10 +52,14 @@ }, "devDependencies": { "@jest/globals": "^29.5.0", + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "9.1.2", "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/messenger": "^2.0.0", + "@metamask/remote-feature-flag-controller": "^5.0.0", + "@metamask/snap-networks-utils": "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 84e85f44c..c6da0ee66 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": "j3n+ylk6thi2g8W+qqmF7S56WcW2d9Kq5AhjCkqZSAk=", + "shasum": "8xioVFo7Tpep5rVLoWUqeg+bvogIihY9EY6YxFTelDY=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -88,7 +88,15 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:messenger": { + "actions": [ + "RemoteFeatureFlagController:getState", + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope" + ] + } }, "platformVersion": "11.2.0", "manifestVersion": "0.1" diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index e0b161b81..15185af73 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -107,6 +107,14 @@ describe('AssetsService', () => { 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, }); }); 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 152caf539..92f0d77b3 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -1,4 +1,8 @@ /* eslint-disable jsdoc/require-returns */ +import type { + AssetsProvider, + RemoteFeatureFlagsProvider, +} from '@metamask/snap-networks-utils'; import type { FungibleAssetMarketData, FungibleAssetMetadata, @@ -50,6 +54,13 @@ export class AssetsService { 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; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 488250dfa..6d012d1d1 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,3 +1,13 @@ +import { + AssetsProvider, + RemoteFeatureFlagsProvider, +} from '@metamask/snap-networks-utils'; +import type { + AssetsProviderMessenger, + RemoteFeatureFlagsProviderMessenger, +} from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; + import type { ICache } from './core/caching/ICache'; import { InMemoryCache } from './core/caching/InMemoryCache'; import { StateCache } from './core/caching/StateCache'; @@ -47,6 +57,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. @@ -78,6 +89,12 @@ export type SnapExecutionContext = { accountsService: AccountsService; accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; + /** + * Core messenger plumbing (routing wired in a follow-up PR). + */ + coreMessenger: CoreMessenger; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + assetsProvider: AssetsProvider; }; const configProvider = new ConfigProvider(); @@ -160,6 +177,17 @@ const snapAssetsAdapter = new SnapAssetsAdapter({ nftApiClient, }); +/** + * Core controllers plumbing + */ +const coreMessenger = getMessenger(); +const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, +}); +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); + const assetsService = new AssetsService({ logger, configProvider, @@ -167,6 +195,8 @@ const assetsService = new AssetsService({ tokenApiClient, tokenPricesService, nftApiClient, + remoteFeatureFlagsProvider, + assetsProvider, }); const transactionsRepository = new TransactionsRepository(state); @@ -299,22 +329,28 @@ const snapContext: SnapExecutionContext = { accountsService, accountsSynchronizer, tokenHelper, + coreMessenger, + remoteFeatureFlagsProvider, + assetsProvider, }; export { accountsService, accountsSynchronizer, analyticsService, + assetsProvider, assetsService, clientRequestHandler, configProvider, confirmationHandler, connection, + coreMessenger, eventEmitter, keyring, nameResolutionService, nftService, priceApiClient, + remoteFeatureFlagsProvider, sendSolBuilder, sendSplTokenBuilder, signer, diff --git a/packages/solana-wallet-snap/src/types/core-messenger.ts b/packages/solana-wallet-snap/src/types/core-messenger.ts new file mode 100644 index 000000000..a430bbd9d --- /dev/null +++ b/packages/solana-wallet-snap/src/types/core-messenger.ts @@ -0,0 +1,27 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE = + 'SolanaWalletSnap' as const; + +export type CoreMessengerActions = + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessenger = Messenger< + typeof SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; diff --git a/yarn.lock b/yarn.lock index 14ac741c6..d9e3043ee 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,14 @@ __metadata: resolution: "@metamask/solana-wallet-snap@workspace:packages/solana-wallet-snap" dependencies: "@jest/globals": "npm:^29.5.0" + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm: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/remote-feature-flag-controller": "npm:^5.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" From d980503d921946c3e8071f5cc77cf1b473dbd151 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:57:19 +0000 Subject: [PATCH 5/8] 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 71412fb94..4f91b0da6 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 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/src/core/services/accounts/AccountsSynchronizer.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts index d214690c5..6212b9ef6 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 15185af73..000364d16 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 92f0d77b3..f9ed3e520 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 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/shouldTrackSnapAssets.test.ts b/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts new file mode 100644 index 000000000..8d8110dc8 --- /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 000000000..cd15b3412 --- /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 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/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..232388b44 --- /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 6d012d1d1..94676b047 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 a430bbd9d..3982bfb1c 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 6/8] 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 c6da0ee66..c588fb0bd 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", From c31a861fb835adb718ae2c89c018c02c718a13fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 14:02:45 +0000 Subject: [PATCH 7/8] feat(WPN-1476): hardcode Core assets reads and disable Snap fungible tracking Remove migration-stage and remote feature-flag routing from AssetsService. Always read fungibles via AssetsProvider/mapControllerAsset; keep Snap-owned NFT merge through SnapAssetsAdapter. Disable fetch/save persistence for fungibles and simplify AccountsSynchronizer to Core reads only. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 4 +- packages/solana-wallet-snap/package.json | 1 - .../solana-wallet-snap/snap.manifest.json | 1 - .../services/accounts/AccountsSynchronizer.ts | 18 +- .../services/assets/AssetsService.test.ts | 903 ++++-------------- .../src/core/services/assets/AssetsService.ts | 310 +----- .../assets/shouldTrackSnapAssets.test.ts | 14 - .../services/assets/shouldTrackSnapAssets.ts | 14 - .../registerCoreAssetsControllerHandlers.ts | 5 - .../solana-wallet-snap/src/snapContext.ts | 19 +- .../src/types/core-messenger.ts | 2 - yarn.lock | 1 - 12 files changed, 248 insertions(+), 1044 deletions(-) delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 4f91b0da6..7f1aadb95 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- 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. +- 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)) diff --git a/packages/solana-wallet-snap/package.json b/packages/solana-wallet-snap/package.json index 51f74c9fe..f2376ad63 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -58,7 +58,6 @@ "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", "@metamask/messenger": "^2.0.0", - "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/snap-networks-utils": "workspace:^", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index c588fb0bd..d7c3f2835 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -91,7 +91,6 @@ "snap_getPreferences": {}, "endowment:messenger": { "actions": [ - "RemoteFeatureFlagController:getState", "AssetsController:getAccountAssetByID", "AssetsController:getAccountAssetsByIDs", "AssetsController:getAccountAssetsByScope" 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 6212b9ef6..8b859dca5 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsSynchronizer.ts @@ -33,21 +33,9 @@ export class AccountsSynchronizer { const assets = ( await Promise.allSettled( - 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, - ); - }), + accountsToSync.map(async (account) => + this.#assetsService.getAccountAssetsForAllActiveScopes(account.id), + ), ) ) .map((item) => (item.status === 'fulfilled' ? item.value : [])) 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 000364d16..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,13 +1,7 @@ -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'; @@ -28,7 +22,6 @@ 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'; @@ -38,18 +31,6 @@ 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; @@ -62,13 +43,9 @@ describe('AssetsService', () => { 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 = { @@ -129,25 +106,10 @@ describe('AssetsService', () => { 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, @@ -157,392 +119,35 @@ describe('AssetsService', () => { }); 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); - - 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); + 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_1, MOCK_ASSET_ENTITY_2]); - }); - - 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); - - 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', () => { @@ -626,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') @@ -636,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([ @@ -658,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, @@ -669,23 +273,60 @@ describe('AssetsService', () => { }); describe('getAccountAssetByID', () => { - it('returns the matching asset when present', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + 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(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + 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('returns null when the asset is missing', async () => { + 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(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce([]); + .spyOn(mockAssetsProvider, 'getAccountAssetByID') + .mockResolvedValueOnce(null); const asset = await assetsService.getAccountAssetByID( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -697,64 +338,84 @@ describe('AssetsService', () => { }); describe('getAccountAssetsByIDs', () => { - it('returns a record keyed by asset ID', async () => { + 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(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + .spyOn(snapAssetsAdapter, 'getAccountAssetsByIDs') + .mockResolvedValueOnce({ + [nftAssetType]: null, + }); const assets = await assetsService.getAccountAssetsByIDs( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + [MOCK_ASSET_ENTITY_0.assetType, nftAssetType], ); - expect(assets).toStrictEqual({ - [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, - [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, - }); - }); - - it('returns null entries for missing assets', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); - - const assets = await assetsService.getAccountAssetsByIDs( + expect(mockAssetsProvider.getAccountAssetsByIDs).toHaveBeenCalledWith( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + [MOCK_ASSET_ENTITY_0.assetType], + ); + expect(snapAssetsAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [nftAssetType], ); - expect(assets).toStrictEqual({ - [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, - [MOCK_ASSET_ENTITY_1.assetType]: null, + [MOCK_ASSET_ENTITY_0.assetType]: expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }), + [nftAssetType]: null, }); }); }); describe('getAccountAssetsByScope', () => { - it('filters account assets to the requested scope', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); - - const assets = await assetsService.getAccountAssetsByScope( - Network.Mainnet, - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - }); - - describe('assets migration routing', () => { - beforeEach(() => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); - }); - - describe('getAccountAssetByID', () => { - it('routes fungible assets through AssetsProvider', async () => { - jest.spyOn(mockAssetsProvider, 'getAccountAssetByID').mockResolvedValue({ + 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 }, @@ -766,261 +427,87 @@ describe('AssetsService', () => { }, 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, - ); + }, + } as never); + jest + .spyOn(snapAssetsAdapter, 'getAccountAssetsByScope') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_2, nftAsset]); - expect(mockAssetsProvider.getAccountAssetByID).not.toHaveBeenCalled(); - expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); - }); + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); - 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); - }); + 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('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, + 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, }, - } 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(); - }); - }); + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + }, + } as never); + jest + .spyOn(snapAssetsAdapter, 'getAccountAssetsForAllActiveScopes') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_1, nftAsset]); - 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, - ]), - ); - }); - }); + const assets = await assetsService.getAccountAssetsForAllActiveScopes( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); - 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, - ]), - ); - }); + 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 f9ed3e520..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,21 +1,14 @@ /* eslint-disable jsdoc/require-returns */ -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, Json } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } 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'; @@ -32,17 +25,9 @@ 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); } @@ -56,8 +41,6 @@ export class AssetsService { readonly #assetsProvider: AssetsProvider; - readonly #coreMessenger: CoreMessengerCaller; - readonly #accountsService: AccountsService; readonly #tokenPricesService: TokenPricesService; @@ -70,7 +53,6 @@ export class AssetsService { logger, configProvider, snapAssetsAdapter, - coreMessenger, accountsService, tokenApiClient, tokenPricesService, @@ -80,7 +62,6 @@ export class AssetsService { logger: ILogger; configProvider: ConfigProvider; snapAssetsAdapter: SnapAssetsAdapter; - coreMessenger: CoreMessengerCaller; accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; @@ -90,7 +71,6 @@ export class AssetsService { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); this.#configProvider = configProvider; this.#snapAdapter = snapAssetsAdapter; - this.#coreMessenger = coreMessenger; this.#accountsService = accountsService; this.#assetsProvider = assetsProvider; this.#tokenApiClient = tokenApiClient; @@ -98,73 +78,10 @@ export class AssetsService { 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, @@ -318,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 }) => [ @@ -332,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 ?? '', }, }; @@ -348,29 +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, }; } - async fetch(account: SolanaKeyringAccount): Promise { - const assets = await this.#snapAdapter.fetch(account); - return this.#filterTrackableAssets(assets); + async fetch(_account: SolanaKeyringAccount): Promise { + return []; } async fetchAssetsMarketData( @@ -388,27 +298,14 @@ 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 { - const trackableAssets = await this.#filterTrackableAssets(assets); - - if (trackableAssets.length === 0) { - return; - } - - await this.#snapAdapter.saveMany(trackableAssets); + async saveMany(_assets: AssetEntity[]): Promise { + // Fungible assets are tracked by Core; Snap persistence is disabled. } - /** - * 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 { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } @@ -417,12 +314,6 @@ export class AssetsService { return this.#snapAdapter.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, @@ -431,34 +322,11 @@ export class AssetsService { 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, @@ -466,13 +334,6 @@ export class AssetsService { ); } - /** - * 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[], @@ -481,137 +342,53 @@ export class AssetsService { 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; + return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); } - 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, - ); - } + const snapOwnedIds = assetIds.filter(isSnapOwnedAsset); + const fungibleIds = assetIds.filter( + (assetId) => !isSnapOwnedAsset(assetId), + ); - Object.assign(result, fungibleResults); + 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({}), + ]); - return result; + return { ...snapResults, ...fungibleResults }; } - /** - * 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 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; + return []; } - let fungibleAssets: AssetEntity[]; + const [fungibleAssets, snapAssets] = await Promise.all([ + this.#getCoreAccountAssetsByScope(scope, accountId, account.address), + this.#snapAdapter.getAccountAssetsByScope(scope, accountId), + ]); - 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, - ); - } + const nftAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); return [...fungibleAssets, ...nftAssets]; } - /** - * Returns assets for an account across all active Solana networks. - * - * @param accountId - Keyring account ID. - */ async getAccountAssetsForAllActiveScopes( accountId: string, ): Promise { @@ -625,18 +402,21 @@ export class AssetsService { account.scopes.includes(chainId), ); - const assetsByScope = await Promise.all( + const fungibleByScope = await Promise.all( relevantChainIds.map((scope) => - this.getAccountAssetsByScope(scope, accountId), + this.#getCoreAccountAssetsByScope(scope, accountId, account.address), ), ); + const snapAssets = + await this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); + const nftAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); - return assetsByScope.flat(); + return [...fungibleByScope.flat(), ...nftAssets]; } async findByAccount(account: SolanaKeyringAccount): Promise { return this.#snapAdapter.findByAccount(account); } } - -export { SnapsAssetsMigrationStage }; 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 deleted file mode 100644 index 8d8110dc8..000000000 --- a/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index cd15b3412..000000000 --- a/packages/solana-wallet-snap/src/core/services/assets/shouldTrackSnapAssets.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/test/helpers/registerCoreAssetsControllerHandlers.ts b/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts index 232388b44..973ffd27d 100644 --- a/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts +++ b/packages/solana-wallet-snap/src/core/test/helpers/registerCoreAssetsControllerHandlers.ts @@ -107,9 +107,4 @@ export function registerCoreAssetsControllerHandlers( 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 94676b047..80242a8e6 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,11 +1,5 @@ -import { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; -import type { - AssetsProviderMessenger, - RemoteFeatureFlagsProviderMessenger, -} from '@metamask/snap-networks-utils'; +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'; @@ -90,10 +84,9 @@ export type SnapExecutionContext = { accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; /** - * Core messenger plumbing (routing wired in a follow-up PR). + * Core messenger plumbing for AssetsProvider reads. */ coreMessenger: CoreMessenger; - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; assetsProvider: AssetsProvider; }; @@ -181,9 +174,6 @@ const snapAssetsAdapter = new SnapAssetsAdapter({ * Core controllers plumbing */ const coreMessenger = getMessenger(); -const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ - messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, -}); const assetsProvider = new AssetsProvider({ messenger: coreMessenger as AssetsProviderMessenger, }); @@ -192,7 +182,6 @@ const assetsService = new AssetsService({ logger, configProvider, snapAssetsAdapter, - coreMessenger, accountsService, tokenApiClient, tokenPricesService, @@ -329,7 +318,6 @@ const snapContext: SnapExecutionContext = { accountsSynchronizer, tokenHelper, coreMessenger, - remoteFeatureFlagsProvider, assetsProvider, }; @@ -349,7 +337,6 @@ export { nameResolutionService, nftService, priceApiClient, - remoteFeatureFlagsProvider, sendSolBuilder, sendSplTokenBuilder, signer, diff --git a/packages/solana-wallet-snap/src/types/core-messenger.ts b/packages/solana-wallet-snap/src/types/core-messenger.ts index 3982bfb1c..16dcdcf5e 100644 --- a/packages/solana-wallet-snap/src/types/core-messenger.ts +++ b/packages/solana-wallet-snap/src/types/core-messenger.ts @@ -4,7 +4,6 @@ import type { AssetsControllerGetAccountAssetsByScopeAction, } from '@metamask/assets-controller'; import type { Messenger } from '@metamask/messenger'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AsyncMessenger } from '@metamask/snaps-sdk'; /** @@ -14,7 +13,6 @@ export const SOLANA_WALLET_SNAP_MESSENGER_NAMESPACE = 'SolanaWalletSnap' as const; export type CoreMessengerActions = - | RemoteFeatureFlagControllerGetStateAction | AssetsControllerGetAccountAssetByIDAction | AssetsControllerGetAccountAssetsByIDsAction | AssetsControllerGetAccountAssetsByScopeAction; diff --git a/yarn.lock b/yarn.lock index d9e3043ee..803527da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3683,7 +3683,6 @@ __metadata: "@metamask/keyring-api": "npm:^23.7.0" "@metamask/keyring-snap-sdk": "npm:^9.2.1" "@metamask/messenger": "npm:^2.0.0" - "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snap-networks-utils": "workspace:^" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.0" From 1ede6a09a8751652f325d8bc20f749d9ee1d7714 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 14:03:25 +0000 Subject: [PATCH 8/8] 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 d7c3f2835..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": "5UbMR/XOp/xr10ccku+kJxErgd1zptASqMx4tavLCL8=", + "shasum": "8pC/CUT1b2BVxIsPy6IWJQ8SMrFeW13nBhWeWKriNUs=", "location": { "npm": { "filePath": "dist/bundle.js",