From 00d9e17f53fea455e6c4f1b7d25e294c520357ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 14:07:09 +0000 Subject: [PATCH 1/2] Remove Snap assets tracking after Core migration Remove AssetsRepository, SnapAssetsAdapter, and snapOwnedAssets. AssetsService now reads exclusively from AssetsProvider via mapControllerAsset. Drop assetEntities from snap state and update refreshSend, Keyring/Send tests, and exports accordingly. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 3 +- .../backgroundEvents/refreshSend.test.tsx | 13 +- .../backgroundEvents/refreshSend.tsx | 12 +- .../handlers/onKeyringRequest/Keyring.test.ts | 7 +- .../services/assets/AssetsRepository.test.ts | 264 -------- .../core/services/assets/AssetsRepository.ts | 61 -- .../services/assets/AssetsService.test.ts | 316 +-------- .../src/core/services/assets/AssetsService.ts | 98 +-- .../assets/adapters/SnapAssetsAdapter.test.ts | 105 --- .../assets/adapters/SnapAssetsAdapter.ts | 622 ------------------ .../src/core/services/assets/index.ts | 2 - .../services/assets/snapOwnedAssets.test.ts | 17 - .../core/services/assets/snapOwnedAssets.ts | 13 - .../core/services/send/SendService.test.ts | 74 +-- .../src/core/services/state/State.ts | 10 +- .../src/features/send/render.test.tsx | 12 +- .../solana-wallet-snap/src/snapContext.ts | 16 - 17 files changed, 67 insertions(+), 1578 deletions(-) delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts delete mode 100644 packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 7f1aadb9..7330c1f9 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 -- 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`. +- Remove Snap-owned asset tracking (`AssetsRepository`, `SnapAssetsAdapter`, `assetEntities` state). `AssetsService` now reads exclusively from `AssetsProvider` / `mapControllerAsset`. +- Hardcode Solana fungible asset reads through `AssetsProvider` / `mapControllerAsset` (no migration-stage or remote feature-flag routing). - 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)) 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 64877134..e735abda 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,7 +1,6 @@ import { accountsService, assetsService, - configProvider, priceApiClient, state, } from '../../../../snapContext'; @@ -43,10 +42,7 @@ jest.mock('../../../../snapContext', () => ({ getAll: jest.fn(), }, assetsService: { - getAccountAssetsByScope: jest.fn(), - }, - configProvider: { - getActiveNetworks: jest.fn(), + getAccountAssetsForAllActiveScopes: jest.fn(), }, priceApiClient: { getMultipleSpotPrices: jest.fn(), @@ -65,10 +61,9 @@ const setupTest = () => { (accountsService.getAll as jest.Mock).mockResolvedValue([ { id: 'account-1' }, ]); - (configProvider.getActiveNetworks as jest.Mock).mockResolvedValue([ - 'solana:mainnet', - ]); - (assetsService.getAccountAssetsByScope as jest.Mock).mockResolvedValue([ + ( + assetsService.getAccountAssetsForAllActiveScopes 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 8efdd9fb..8dba1452 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 @@ -4,11 +4,10 @@ import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; import { Send } from '../../../../features/send/Send'; import type { SendContext } from '../../../../features/send/types'; import { + accountsService, assetsService, - configProvider, priceApiClient, state, - accountsService, } from '../../../../snapContext'; import type { UnencryptedStateValue } from '../../../services/state/State'; import { trackError } from '../../../utils/errors'; @@ -25,10 +24,9 @@ export const refreshSend: OnCronjobHandler = async () => { logger.info(`Background event triggered`); - const [accounts, activeNetworks, mapInterfaceNameToId, preferences] = + const [keyringAccounts, mapInterfaceNameToId, preferences] = await Promise.all([ accountsService.getAll(), - configProvider.getActiveNetworks(), state.getKey( 'mapInterfaceNameToId', ), @@ -37,10 +35,8 @@ export const refreshSend: OnCronjobHandler = async () => { const assets = ( await Promise.all( - accounts.flatMap((account) => - activeNetworks.map((network) => - assetsService.getAccountAssetsByScope(network, account.id), - ), + keyringAccounts.map((account) => + assetsService.getAccountAssetsForAllActiveScopes(account.id), ), ) ).flat(); 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 3fc76e52..55ec85f9 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 @@ -101,13 +101,8 @@ describe('SolanaKeyring', () => { }); mockAssetsService = { - fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), - saveMany: jest.fn(), getAccountAssetsForAllActiveScopes: jest.fn(), getAccountAssetsByIDs: jest.fn(), - getNativeAssetTypes: jest - .fn() - .mockReturnValue([KnownCaip19Id.SolMainnet]), } as unknown as AssetsService; mockWalletService = { @@ -349,7 +344,7 @@ describe('SolanaKeyring', () => { } as unknown as AssetEntity; jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [KnownCaip19Id.SolMainnet]: invalidAsset, + [invalidAsset.assetType]: invalidAsset, }); await expect( diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts deleted file mode 100644 index 0c74ce76..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { cloneDeep } from 'lodash'; - -import { - MOCK_ASSET_ENTITIES, - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - MOCK_ASSET_ENTITY_2, -} from '../../test/mocks/asset-entities'; -import { - MOCK_SOLANA_KEYRING_ACCOUNT_0, - MOCK_SOLANA_KEYRING_ACCOUNT_1, -} from '../../test/mocks/solana-keyring-accounts'; -import { InMemoryState } from '../state/InMemoryState'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; -import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; -import { AssetsRepository } from './AssetsRepository'; - -describe('AssetsRepository', () => { - let repository: AssetsRepository; - let mockState: IStateManager; - - beforeEach(() => { - mockState = new InMemoryState(cloneDeep(DEFAULT_UNENCRYPTED_STATE)); - repository = new AssetsRepository(mockState); - }); - - describe('findByKeyringAccountId', () => { - it('returns empty array when no assets exist for the account', async () => { - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([]); - }); - - it('returns assets for the specified account', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - }); - - it('returns assets only for the requested account', async () => { - const assetForAccount1 = { - ...MOCK_ASSET_ENTITY_0, - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - }; - - await repository.saveMany([ - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - assetForAccount1, - ]); - - const assetsForAccount0 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - const assetsForAccount1 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - ); - - expect(assetsForAccount0).toStrictEqual([ - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - ]); - expect(assetsForAccount1).toStrictEqual([assetForAccount1]); - }); - }); - - describe('getAll', () => { - it('returns empty array when no assets exist', async () => { - const assets = await repository.getAll(); - - expect(assets).toStrictEqual([]); - }); - - it('returns all assets from all accounts', async () => { - const assetForAccount1 = { - ...MOCK_ASSET_ENTITY_0, - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - }; - - await repository.saveMany([ - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - assetForAccount1, - ]); - - const assets = await repository.getAll(); - - expect(assets).toStrictEqual([ - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - assetForAccount1, - ]); - }); - }); - - describe('saveMany', () => { - it('saves a single asset', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_0]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); - }); - - it('saves multiple assets', async () => { - await repository.saveMany(MOCK_ASSET_ENTITIES); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - - it('saves assets for multiple accounts', async () => { - const assetForAccount1 = { - ...MOCK_ASSET_ENTITY_0, - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - }; - - await repository.saveMany([MOCK_ASSET_ENTITY_0, assetForAccount1]); - - const assetsForAccount0 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - const assetsForAccount1 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - ); - - expect(assetsForAccount0).toStrictEqual([MOCK_ASSET_ENTITY_0]); - expect(assetsForAccount1).toStrictEqual([assetForAccount1]); - }); - - it('overrides existing assets with the same assetType and keyringAccountId', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_1]); - - const updatedAsset = { - ...MOCK_ASSET_ENTITY_1, - rawAmount: '999999999', - uiAmount: '999.999999', - }; - - await repository.saveMany([updatedAsset]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([updatedAsset]); - }); - - it('adds new asset when assetType differs', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_0]); - - await repository.saveMany([MOCK_ASSET_ENTITY_1]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - }); - - it('maintains existing assets when adding new ones', async () => { - await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - - await repository.saveMany([MOCK_ASSET_ENTITY_2]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([ - MOCK_ASSET_ENTITY_0, - MOCK_ASSET_ENTITY_1, - MOCK_ASSET_ENTITY_2, - ]); - }); - - it('updates state atomically for multiple assets', async () => { - const updatedAsset0 = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '777777777', - uiAmount: '0.777777777', - }; - const updatedAsset1 = { - ...MOCK_ASSET_ENTITY_1, - rawAmount: '888888888', - uiAmount: '888.888888', - }; - - await repository.saveMany([MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1]); - - await repository.saveMany([updatedAsset0, updatedAsset1]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - expect(assets).toStrictEqual([updatedAsset0, updatedAsset1]); - }); - - it('handles empty array input', async () => { - await repository.saveMany([]); - - const assets = await repository.getAll(); - - expect(assets).toStrictEqual([]); - }); - - it('preserves assets from different accounts when updating', async () => { - const assetForAccount1 = { - ...MOCK_ASSET_ENTITY_0, - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - }; - - await repository.saveMany([MOCK_ASSET_ENTITY_0, assetForAccount1]); - - const updatedAsset = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '555555555', - uiAmount: '0.555555555', - }; - - await repository.saveMany([updatedAsset]); - - const assetsForAccount0 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - const assetsForAccount1 = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - ); - - expect(assetsForAccount0).toStrictEqual([updatedAsset]); - expect(assetsForAccount1).toStrictEqual([assetForAccount1]); - }); - - it('handles duplicate assets in the same save operation', async () => { - const duplicateAsset = { - ...MOCK_ASSET_ENTITY_0, - rawAmount: '123456789', - uiAmount: '0.123456789', - }; - - await repository.saveMany([MOCK_ASSET_ENTITY_0, duplicateAsset]); - - const assets = await repository.findByKeyringAccountId( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - ); - - // The last occurrence should win - expect(assets).toStrictEqual([duplicateAsset]); - }); - }); -}); diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts deleted file mode 100644 index 3ed632cc..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { cloneDeep } from 'lodash'; - -import type { AssetEntity } from '../../../entities'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; - -export class AssetsRepository { - readonly #state: IStateManager; - - constructor(state: IStateManager) { - this.#state = state; - } - - async findByKeyringAccountId( - keyringAccountId: string, - ): Promise { - const assets = await this.#state.getKey( - `assetEntities.${keyringAccountId}`, - ); - - return assets ?? []; - } - - async getAll(): Promise { - const assetsByAccount = - (await this.#state.getKey( - 'assetEntities', - )) ?? {}; - - return Object.values(assetsByAccount).flat(); - } - - async saveMany(assets: AssetEntity[]): Promise { - // Update the state atomically - await this.#state.update((stateValue) => { - const newState = cloneDeep(stateValue); - for (const asset of assets) { - const { keyringAccountId } = asset; - const accountAssets = cloneDeep( - newState.assetEntities[keyringAccountId] ?? [], - ); - - // Avoid duplicates. If same asset is already saved, override it. - const existingAssetIndex = accountAssets.findIndex( - (item) => - item.assetType === asset.assetType && - item.keyringAccountId === asset.keyringAccountId, - ); - - if (existingAssetIndex === -1) { - accountAssets.push(asset); - } else { - accountAssets[existingAssetIndex] = asset; - } - - newState.assetEntities[keyringAccountId] = accountAssets; - } - return newState; - }); - } -} 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 bf305a78..29b95b35 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,52 +1,29 @@ -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { cloneDeep } from 'lodash'; - -import type { AssetEntity } from '../../../entities'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; -import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; import { Network } from '../../constants/solana'; -import type { Serializable } from '../../serialization/types'; import { - MOCK_ASSET_ENTITIES, MOCK_ASSET_ENTITY_0, MOCK_ASSET_ENTITY_1, - MOCK_ASSET_ENTITY_2, SOLANA_MOCK_TOKEN_METADATA, } from '../../test/mocks/asset-entities'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; -import type { SolanaConnection } from '../connection'; import { mockLogger } from '../mocks/logger'; -import { createMockConnection } from '../mocks/mockConnection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; -import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; -import type { AssetsRepository } from './AssetsRepository'; import { AssetsService } from './AssetsService'; -jest.mock('@metamask/keyring-snap-sdk', () => ({ - emitSnapKeyringEvent: jest.fn(), -})); - describe('AssetsService', () => { let assetsService: AssetsService; - let snapAssetsAdapter: SnapAssetsAdapter; - let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; - let mockAssetsRepository: AssetsRepository; let mockAccountsService: AccountsService; let mockTokenApiClient: TokenApiClient; let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; - let mockCache: ICache; let mockAssetsProvider: import('@metamask/snap-networks-utils').AssetsProvider; beforeEach(() => { jest.clearAllMocks(); - mockConnection = createMockConnection(); mockConfigProvider = { getActiveNetworks: jest.fn().mockResolvedValue([Network.Mainnet]), @@ -59,47 +36,15 @@ describe('AssetsService', () => { } as unknown as TokenApiClient; mockTokenPricesService = { - getMultipleTokenConversions: jest.fn().mockResolvedValue({}), getMultipleTokensMarketData: jest.fn().mockResolvedValue({}), - getHistoricalPrice: jest - .fn() - .mockResolvedValue({ intervals: {}, updateTime: 0, expirationTime: 0 }), } as unknown as TokenPricesService; - mockCache = new InMemoryCache(mockLogger); - - mockNftApiClient = { - listAddressSolanaNfts: jest - .fn() - .mockResolvedValue(MOCK_NFTS_LIST_RESPONSE_MAPPED.items), - } as unknown as NftApiClient; - - const snap = { - request: jest.fn(), - }; - (globalThis as any).snap = snap; - - mockAssetsRepository = { - findByKeyringAccountId: jest.fn(), - getAll: jest.fn(), - saveMany: jest.fn(), - } as unknown as AssetsRepository; + mockNftApiClient = {} as unknown as NftApiClient; mockAccountsService = { findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), } as unknown as AccountsService; - snapAssetsAdapter = new SnapAssetsAdapter({ - connection: mockConnection, - logger: mockLogger, - configProvider: mockConfigProvider, - assetsRepository: mockAssetsRepository, - accountsService: mockAccountsService, - tokenApiClient: mockTokenApiClient, - cache: mockCache, - nftApiClient: mockNftApiClient, - }); - mockAssetsProvider = { getAccountAssetByID: jest.fn(), getAccountAssetsByIDs: jest.fn(), @@ -109,7 +54,6 @@ describe('AssetsService', () => { assetsService = new AssetsService({ logger: mockLogger, configProvider: mockConfigProvider, - snapAssetsAdapter, accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, tokenPricesService: mockTokenPricesService, @@ -118,162 +62,8 @@ describe('AssetsService', () => { }); }); - describe('fetch', () => { - it('returns an empty array because Snap fungible tracking is disabled', async () => { - const fetchSpy = jest.spyOn(snapAssetsAdapter, 'fetch'); - - const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - - expect(assets).toStrictEqual([]); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - }); - - describe('save', () => { - 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(saveManySpy).not.toHaveBeenCalled(); - }); - }); - - describe('saveMany', () => { - 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(); - }); - }); - - 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(AssetsService.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(AssetsService.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(AssetsService.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(AssetsService.hasChanged(asset, assetsLookup)).toBe(false); - }); - }); - - describe('getAll', () => { - it('delegates to repository and returns all assets', async () => { - jest - .spyOn(mockAssetsRepository, 'getAll') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); - - const assets = await assetsService.getAll(); - - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - }); - - describe('findByAccount', () => { - it('returns saved assets for the account when they exist', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); - - const assets = await assetsService.findByAccount( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); - - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - - it('includes placeholder native assets when no assets exist', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce([]); - - const assets = await assetsService.findByAccount( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); - - expect(assets).toStrictEqual([ - { - assetType: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - network: Network.Mainnet, - address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }, - ]); - }); - - it('includes placeholder native assets with zero balance when no native assets exist', async () => { - const nonNativeAssets = [MOCK_ASSET_ENTITY_1, MOCK_ASSET_ENTITY_2]; - - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(nonNativeAssets); - - const assets = await assetsService.findByAccount( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); - - expect(assets).toHaveLength(nonNativeAssets.length + 1); - expect(assets).toStrictEqual( - expect.arrayContaining([ - ...nonNativeAssets, - { - assetType: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - network: Network.Mainnet, - address: MOCK_SOLANA_KEYRING_ACCOUNT_0.address, - symbol: 'SOL', - decimals: 9, - rawAmount: '0', - uiAmount: '0', - }, - ]), - ); - }); - - it('does not add placeholder native assets when they already exist', async () => { - jest - .spyOn(mockAssetsRepository, 'findByKeyringAccountId') - .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); - - const assets = await assetsService.findByAccount( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); - - expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); - }); - }); - describe('getAccountAssetByID', () => { - it('routes fungible assets through AssetsProvider', async () => { + it('routes reads through AssetsProvider', async () => { jest.spyOn(mockAssetsProvider, 'getAccountAssetByID').mockResolvedValue({ id: MOCK_ASSET_ENTITY_1.assetType, chainId: Network.Mainnet, @@ -303,45 +93,10 @@ describe('AssetsService', () => { rawAmount: MOCK_ASSET_ENTITY_1.rawAmount, }); }); - - it('routes NFT assets through SnapAssetsAdapter', async () => { - const nftAssetType = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - const snapSpy = jest - .spyOn(snapAssetsAdapter, 'getAccountAssetByID') - .mockResolvedValueOnce(null); - - await assetsService.getAccountAssetByID( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - nftAssetType, - ); - - expect(snapSpy).toHaveBeenCalledWith( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - nftAssetType, - ); - expect(mockAssetsProvider.getAccountAssetByID).not.toHaveBeenCalled(); - }); - - it('returns null when the fungible asset is missing from Core', async () => { - jest - .spyOn(mockAssetsProvider, 'getAccountAssetByID') - .mockResolvedValueOnce(null); - - const asset = await assetsService.getAccountAssetByID( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - MOCK_ASSET_ENTITY_1.assetType, - ); - - expect(asset).toBeNull(); - }); }); describe('getAccountAssetsByIDs', () => { - it('routes fungible and NFT asset IDs to the correct adapters', async () => { - const nftAssetType = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - + it('returns keyed results from AssetsProvider', async () => { jest.spyOn(mockAssetsProvider, 'getAccountAssetsByIDs').mockResolvedValue({ [MOCK_ASSET_ENTITY_0.assetType]: { id: MOCK_ASSET_ENTITY_0.assetType, @@ -357,50 +112,26 @@ describe('AssetsService', () => { 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], + [MOCK_ASSET_ENTITY_0.assetType], ); expect(mockAssetsProvider.getAccountAssetsByIDs).toHaveBeenCalledWith( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, [MOCK_ASSET_ENTITY_0.assetType], ); - expect(snapAssetsAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( - MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - [nftAssetType], - ); expect(assets).toStrictEqual({ [MOCK_ASSET_ENTITY_0.assetType]: expect.objectContaining({ assetType: MOCK_ASSET_ENTITY_0.assetType, }), - [nftAssetType]: null, }); }); }); describe('getAccountAssetsByScope', () => { - it('merges fungible Core assets with Snap-owned NFT assets for the scope', async () => { - const nftAssetType = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/nft:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - const nftAsset = { - assetType: nftAssetType, - keyringAccountId: MOCK_SOLANA_KEYRING_ACCOUNT_0.id, - network: Network.Mainnet, - mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - pubkey: '9wt9PfjPD3JCy5r7o4K1cTGiuTG7fq2pQhdDCdQALKjg', - symbol: 'NFT', - decimals: 0, - rawAmount: '1', - uiAmount: '1', - } as AssetEntity; - + it('routes scope reads through AssetsProvider', async () => { jest.spyOn(mockAssetsProvider, 'getAccountAssetsByScope').mockResolvedValue({ [MOCK_ASSET_ENTITY_0.assetType]: { id: MOCK_ASSET_ENTITY_0.assetType, @@ -429,9 +160,6 @@ describe('AssetsService', () => { fiatValue: 0, }, } as never); - jest - .spyOn(snapAssetsAdapter, 'getAccountAssetsByScope') - .mockResolvedValueOnce([MOCK_ASSET_ENTITY_2, nftAsset]); const assets = await assetsService.getAccountAssetsByScope( Network.Mainnet, @@ -442,7 +170,6 @@ describe('AssetsService', () => { Network.Mainnet, MOCK_SOLANA_KEYRING_ACCOUNT_0.id, ); - expect(assets).toHaveLength(3); expect(assets).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -451,28 +178,13 @@ describe('AssetsService', () => { 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; - + it('aggregates scope reads across active networks', async () => { jest.spyOn(mockAssetsProvider, 'getAccountAssetsByScope').mockResolvedValue({ [MOCK_ASSET_ENTITY_0.assetType]: { id: MOCK_ASSET_ENTITY_0.assetType, @@ -488,9 +200,6 @@ describe('AssetsService', () => { fiatValue: 0, }, } as never); - jest - .spyOn(snapAssetsAdapter, 'getAccountAssetsForAllActiveScopes') - .mockResolvedValueOnce([MOCK_ASSET_ENTITY_1, nftAsset]); const assets = await assetsService.getAccountAssetsForAllActiveScopes( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -500,14 +209,11 @@ describe('AssetsService', () => { Network.Mainnet, MOCK_SOLANA_KEYRING_ACCOUNT_0.id, ); - expect(assets).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - assetType: MOCK_ASSET_ENTITY_0.assetType, - }), - nftAsset, - ]), - ); + expect(assets).toEqual([ + expect.objectContaining({ + assetType: MOCK_ASSET_ENTITY_0.assetType, + }), + ]); }); }); }); 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 4b63813a..0897db2f 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -8,7 +8,7 @@ import type { import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; -import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; +import type { AssetEntity } from '../../../entities'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; import { SolanaCaip19Tokens } from '../../constants/solana'; @@ -23,22 +23,14 @@ 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 { isSnapOwnedAsset } from './snapOwnedAssets'; import type { AssetMetadata, NonFungibleAssetMetadata } from './types'; -function isFungibleProviderAsset(assetId: string): boolean { - return !isSnapOwnedAsset(assetId); -} - export class AssetsService { readonly #logger: ILogger; readonly #configProvider: ConfigProvider; - readonly #snapAdapter: SnapAssetsAdapter; - readonly #assetsProvider: AssetsProvider; readonly #accountsService: AccountsService; @@ -52,7 +44,6 @@ export class AssetsService { constructor({ logger, configProvider, - snapAssetsAdapter, accountsService, tokenApiClient, tokenPricesService, @@ -61,7 +52,6 @@ export class AssetsService { }: { logger: ILogger; configProvider: ConfigProvider; - snapAssetsAdapter: SnapAssetsAdapter; accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; @@ -70,7 +60,6 @@ export class AssetsService { }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); this.#configProvider = configProvider; - this.#snapAdapter = snapAssetsAdapter; this.#accountsService = accountsService; this.#assetsProvider = assetsProvider; this.#tokenApiClient = tokenApiClient; @@ -104,20 +93,15 @@ export class AssetsService { assetIds: string[], accountAddress: string, ): Promise> { - const fungibleAssetIds = assetIds.filter(isFungibleProviderAsset); - const providerAssets = fungibleAssetIds.length + const providerAssets = assetIds.length ? await this.#assetsProvider.getAccountAssetsByIDs( accountId, - fungibleAssetIds as Caip19AssetId[], + assetIds 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; @@ -146,12 +130,8 @@ export class AssetsService { accountId, ); - const supportedEntries = Object.entries(providerAssets).filter( - ([assetId]) => isFungibleProviderAsset(assetId), - ); - return Promise.all( - supportedEntries.map(([assetId, asset]) => + Object.entries(providerAssets).map(([assetId, asset]) => mapControllerAsset( accountId, assetId as CaipAssetType, @@ -279,10 +259,6 @@ export class AssetsService { }; } - async fetch(_account: SolanaKeyringAccount): Promise { - return []; - } - async fetchAssetsMarketData( assets: { asset: CaipAssetType; @@ -298,30 +274,10 @@ export class AssetsService { return marketData; } - async save(_asset: AssetEntity): Promise { - // Fungible assets are tracked by Core; Snap persistence is disabled. - } - - async saveMany(_assets: AssetEntity[]): Promise { - // Fungible assets are tracked by Core; Snap persistence is disabled. - } - - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - return SnapAssetsAdapter.hasChanged(asset, assetsLookup); - } - - async getAll(): Promise { - return this.#snapAdapter.getAll(); - } - async getAccountAssetByID( accountId: string, assetId: string, ): Promise { - if (isSnapOwnedAsset(assetId)) { - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); - } - const account = await this.#accountsService.findById(accountId); if (!account) { return null; @@ -347,25 +303,11 @@ export class AssetsService { return Object.fromEntries(assetIds.map((assetId) => [assetId, null])); } - const snapOwnedIds = assetIds.filter(isSnapOwnedAsset); - const fungibleIds = assetIds.filter( - (assetId) => !isSnapOwnedAsset(assetId), + return this.#getCoreAccountAssetsByIDs( + accountId, + assetIds, + account.address, ); - - 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 { ...snapResults, ...fungibleResults }; } async getAccountAssetsByScope( @@ -377,16 +319,7 @@ export class AssetsService { return []; } - const [fungibleAssets, snapAssets] = await Promise.all([ - this.#getCoreAccountAssetsByScope(scope, accountId, account.address), - this.#snapAdapter.getAccountAssetsByScope(scope, accountId), - ]); - - const nftAssets = snapAssets.filter((asset) => - isSnapOwnedAsset(asset.assetType), - ); - - return [...fungibleAssets, ...nftAssets]; + return this.#getCoreAccountAssetsByScope(scope, accountId, account.address); } async getAccountAssetsForAllActiveScopes( @@ -402,21 +335,12 @@ export class AssetsService { account.scopes.includes(chainId), ); - const fungibleByScope = await Promise.all( + const assetsByScope = await Promise.all( relevantChainIds.map((scope) => this.#getCoreAccountAssetsByScope(scope, accountId, account.address), ), ); - const snapAssets = - await this.#snapAdapter.getAccountAssetsForAllActiveScopes(accountId); - const nftAssets = snapAssets.filter((asset) => - isSnapOwnedAsset(asset.assetType), - ); - - return [...fungibleByScope.flat(), ...nftAssets]; - } - async findByAccount(account: SolanaKeyringAccount): Promise { - return this.#snapAdapter.findByAccount(account); + return assetsByScope.flat(); } } 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 deleted file mode 100644 index c1cad8f2..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index d24a04a8..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts +++ /dev/null @@ -1,622 +0,0 @@ -/* 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 494c206b..89c0ffc7 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/index.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/index.ts @@ -1,4 +1,2 @@ -export * from './adapters/SnapAssetsAdapter'; -export * from './AssetsRepository'; export * from './AssetsService'; export * from './TokenHelper'; 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 deleted file mode 100644 index 3b249e59..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 872deb88..00000000 --- a/packages/solana-wallet-snap/src/core/services/assets/snapOwnedAssets.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Returns whether an asset remains exclusively managed by the Snap. - * - * AssetsController does not persist Solana NFT balances. NFT assets must always - * be read, synchronized, persisted, and published by the Snap, regardless of - * the assets migration stage. - * - * @param assetId - CAIP-19 asset ID. - * @returns Whether the asset is exclusively managed by the Snap. - */ -export function isSnapOwnedAsset(assetId: string): boolean { - return assetId.includes('/nft:'); -} diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index 7ef7660c..517cef19 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 @@ -289,18 +289,21 @@ describe('SendService', () => { }, ]; - beforeEach(() => { + const mockGetAccountAssetsByIDs = (balances: AssetEntity[]) => { jest .spyOn(mockAssetsService, 'getAccountAssetsByIDs') .mockImplementation(async (_accountId, assetIds) => Object.fromEntries( - assetIds.map((assetId) => [ - assetId, - mockAssetBalances.find((asset) => asset.assetType === assetId) ?? - null, + assetIds.map((id) => [ + id, + balances.find((asset) => asset.assetType === id) ?? null, ]), ), ); + }; + + beforeEach(() => { + mockGetAccountAssetsByIDs(mockAssetBalances); jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ @@ -333,10 +336,7 @@ describe('SendService', () => { }); it('rejects when asset balance not found', async () => { - jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [mockRequest.params.assetId]: null, - [Networks[Network.Mainnet].nativeToken.caip19Id]: null, - }); + mockGetAccountAssetsByIDs([]); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( `Balance not found for asset ${mockRequest.params.assetId} and account ${mockAccount.id}`, @@ -349,18 +349,8 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.000001' }, }; - jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [Networks[Network.Mainnet].nativeToken.caip19Id]: { - 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', - }, - [mockRequest.params.assetId]: { + mockGetAccountAssetsByIDs([ + { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.00001', keyringAccountId: mockAccount.id, @@ -370,7 +360,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '999999999999999999', }, - }); + ]); const result = await sendService.onAmountInput(lowBalanceRequest); @@ -418,8 +408,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [Networks[Network.Mainnet].nativeToken.caip19Id]: { + mockGetAccountAssetsByIDs([ + { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.1', keyringAccountId: mockAccount.id, @@ -429,7 +419,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - [KnownCaip19Id.UsdcMainnet]: { + { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '0.001', keyringAccountId: mockAccount.id, @@ -440,7 +430,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '1000000', }, - }); + ]); const result = await sendService.onAmountInput(zeroBalanceRequest); @@ -456,18 +446,8 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.1' }, }; - 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]: { + mockGetAccountAssetsByIDs([ + { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0', keyringAccountId: mockAccount.id, @@ -477,7 +457,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '0', }, - }); + ]); const result = await sendService.onAmountInput(zeroSolRequest); @@ -496,8 +476,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [KnownCaip19Id.UsdcMainnet]: { + mockGetAccountAssetsByIDs([ + { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -508,7 +488,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - [Networks[Network.Mainnet].nativeToken.caip19Id]: { + { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '1.0', keyringAccountId: mockAccount.id, @@ -518,7 +498,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - }); + ]); const result = await sendService.onAmountInput(tokenRequest); @@ -537,8 +517,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ - [KnownCaip19Id.UsdcMainnet]: { + mockGetAccountAssetsByIDs([ + { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -549,7 +529,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - [Networks[Network.Mainnet].nativeToken.caip19Id]: { + { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.0001', keyringAccountId: mockAccount.id, @@ -559,7 +539,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - }); + ]); const result = await sendService.onAmountInput(tokenRequest); diff --git a/packages/solana-wallet-snap/src/core/services/state/State.ts b/packages/solana-wallet-snap/src/core/services/state/State.ts index 2271a5ce..4d076c49 100644 --- a/packages/solana-wallet-snap/src/core/services/state/State.ts +++ b/packages/solana-wallet-snap/src/core/services/state/State.ts @@ -6,11 +6,7 @@ import type { MutexInterface } from 'async-mutex'; import { Mutex } from 'async-mutex'; import { omit, unset } from 'lodash'; -import type { - AssetEntity, - SolanaKeyringAccount, - Subscription, -} from '../../../entities'; +import type { SolanaKeyringAccount, Subscription } from '../../../entities'; import type { EventEmitter } from '../../../infrastructure'; import type { SpotPrices } from '../../clients/price-api/types'; import { deserialize } from '../../serialization/deserialize'; @@ -28,7 +24,6 @@ export type UnencryptedStateValue = { // we need to store the exhaustive list of signatures (including spam) // to keep track of the transactions per account. The field transactions above only stores non-spam transactions, which break the refreshAccounts cronjob logic. signatures: Record; - assetEntities: Record; tokenPrices: SpotPrices; subscriptions: Record; webSocketConnections: { @@ -41,7 +36,6 @@ export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { mapInterfaceNameToId: {}, transactions: {}, signatures: {}, - assetEntities: {}, tokenPrices: {}, subscriptions: {}, webSocketConnections: { @@ -143,7 +137,7 @@ export class State< async #migrateState() { await this.update((state) => { - return omit(state as any, ['assets']); + return omit(state as any, ['assets', 'assetEntities']); }); } diff --git a/packages/solana-wallet-snap/src/features/send/render.test.tsx b/packages/solana-wallet-snap/src/features/send/render.test.tsx index 7e98e1e3..9b583dc3 100644 --- a/packages/solana-wallet-snap/src/features/send/render.test.tsx +++ b/packages/solana-wallet-snap/src/features/send/render.test.tsx @@ -245,10 +245,6 @@ describe('Send', () => { entropySource: 'alternative', }, }, - assetEntities: { - [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: mockAssetEntities, - [MOCK_SOLANA_KEYRING_ACCOUNT_1.id]: mockAssetEntities, - }, }; const mockPreferences: Preferences = { @@ -487,7 +483,8 @@ describe('Send', () => { describe('Send tracking', () => { const setupTest = () => { - const originalAssetsGetAll = assetsService.getAll; + const originalAssetsGetAccountAssetsByScope = + assetsService.getAccountAssetsByScope; const originalAssetsGetAssetsMetadata = assetsService.getAssetsMetadata; const originalAccountsGetAll = accountsService.getAll; const originalConnectionGetRpc = connection.getRpc; @@ -530,7 +527,7 @@ describe('Send tracking', () => { }); jest - .spyOn(assetsService, 'getAll') + .spyOn(assetsService, 'getAccountAssetsByScope') .mockImplementation() .mockResolvedValue([ { @@ -578,7 +575,8 @@ describe('Send tracking', () => { connection, priceApiClient, cleanup: () => { - assetsService.getAll = originalAssetsGetAll; + assetsService.getAccountAssetsByScope = + originalAssetsGetAccountAssetsByScope; assetsService.getAssetsMetadata = originalAssetsGetAssetsMetadata; accountsService.getAll = originalAccountsGetAll; connection.getRpc = originalConnectionGetRpc; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 80242a8e..d7cb605b 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -17,8 +17,6 @@ import { AccountsService, AccountsSynchronizer, ApproveTokenService, - SnapAssetsAdapter, - AssetsRepository, AssetsService, KeyringAccountMonitor, MonitoredAccountsInitializer, @@ -154,22 +152,9 @@ 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 snapAssetsAdapter = new SnapAssetsAdapter({ - connection, - logger, - configProvider, - assetsRepository, - accountsService, - tokenApiClient, - cache: inMemoryCache, - nftApiClient, -}); - /** * Core controllers plumbing */ @@ -181,7 +166,6 @@ const assetsProvider = new AssetsProvider({ const assetsService = new AssetsService({ logger, configProvider, - snapAssetsAdapter, accountsService, tokenApiClient, tokenPricesService, From a0651007119a6dd4d8ca70086a5848c79313e568 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 14:07:48 +0000 Subject: [PATCH 2/2] Update snap.manifest.json 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 c7c83247..83bce494 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": "8pC/CUT1b2BVxIsPy6IWJQ8SMrFeW13nBhWeWKriNUs=", + "shasum": "0IUwopMf3Y3cIYkZa2ROWzoWPWQU0QI33uM0CW/g7A4=", "location": { "npm": { "filePath": "dist/bundle.js",