From 333a019daab05b43d0172521e0d26e6d67ec1fba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 15:50:34 +0000 Subject: [PATCH 1/3] feat(solana-wallet-snap): route asset reads through Core when migration is on Pass RemoteFeatureFlagsProvider into AssetsService and route getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, getAccountAssets, fetch, and saveMany through CoreAssetsAdapter when the Solana assets migration flag is active. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../services/assets/AssetsService.test.ts | 168 +++++++++++++++++- .../src/core/services/assets/AssetsService.ts | 54 +++++- .../solana-wallet-snap/src/snapContext.ts | 3 +- 4 files changed, 218 insertions(+), 8 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index b33c306b..3c007f13 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Route Solana asset reads, fetch, and save through `CoreAssetsAdapter` when the assets migration feature flag is active. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) - Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads 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 `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) 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 8bc60907..fd948a36 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,5 +1,10 @@ +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 type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { ICache } from '../../caching/ICache'; @@ -7,7 +12,7 @@ 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 { KnownCaip19Id, Network } from '../../constants/solana'; import type { Serializable } from '../../serialization/types'; import { MOCK_ASSET_ENTITIES, @@ -36,6 +41,8 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ describe('AssetsService', () => { let assetsService: AssetsService; let snapAssetsAdapter: SnapAssetsAdapter; + let coreAdapter: CoreAssetsAdapter; + let mockGetFeatureFlag: jest.Mock; let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; @@ -45,6 +52,10 @@ describe('AssetsService', () => { let mockNftApiClient: NftApiClient; let mockCache: ICache; + const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { + mockGetFeatureFlag.mockResolvedValue({ stage }); + }; + beforeEach(() => { jest.clearAllMocks(); mockConnection = createMockConnection(); @@ -102,7 +113,7 @@ describe('AssetsService', () => { nftApiClient: mockNftApiClient, }); - const coreAdapter = new CoreAssetsAdapter({ + coreAdapter = new CoreAssetsAdapter({ getAccountAssetByID: jest.fn().mockResolvedValue(null), getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.fn().mockResolvedValue({}), @@ -111,9 +122,16 @@ describe('AssetsService', () => { mockConfigProvider.getActiveNetworks.bind(mockConfigProvider), }); + mockGetFeatureFlag = jest.fn().mockResolvedValue({ + stage: SnapsAssetsMigrationStage.Off, + }); + assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter, + remoteFeatureFlagsProvider: { + getFeatureFlag: mockGetFeatureFlag, + } as unknown as RemoteFeatureFlagsProvider, }); }); @@ -824,4 +842,150 @@ describe('AssetsService', () => { ).not.toHaveBeenCalled(); }); }); + + describe('assets migration', () => { + const accountId = MOCK_SOLANA_KEYRING_ACCOUNT_0.id; + const activeMigrationStage = + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; + + it('routes getAccountAssetByID through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssetByID') + .mockResolvedValue(MOCK_ASSET_ENTITY_0); + + const asset = await assetsService.getAccountAssetByID( + accountId, + KnownCaip19Id.SolMainnet, + ); + + expect(coreAdapter.getAccountAssetByID).toHaveBeenCalledWith( + accountId, + KnownCaip19Id.SolMainnet, + ); + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_0); + }); + + it('routes getAccountAssetsByIDs through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.SolMainnet]: MOCK_ASSET_ENTITY_0, + [KnownCaip19Id.UsdcMainnet]: MOCK_ASSET_ENTITY_1, + }); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + KnownCaip19Id.SolMainnet, + KnownCaip19Id.UsdcMainnet, + ]); + + expect(coreAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + accountId, + [KnownCaip19Id.SolMainnet, KnownCaip19Id.UsdcMainnet], + ); + expect(results[KnownCaip19Id.SolMainnet]).toStrictEqual( + MOCK_ASSET_ENTITY_0, + ); + expect(results[KnownCaip19Id.UsdcMainnet]).toStrictEqual( + MOCK_ASSET_ENTITY_1, + ); + }); + + it('routes getAccountAssetsByScope through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssetsByScope') + .mockResolvedValue([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + accountId, + ); + + expect(coreAdapter.getAccountAssetsByScope).toHaveBeenCalledWith( + Network.Mainnet, + accountId, + ); + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + + it('routes getAccountAssets through Core when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest + .spyOn(coreAdapter, 'getAccountAssets') + .mockResolvedValue([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssets(accountId); + + expect(coreAdapter.getAccountAssets).toHaveBeenCalledWith(accountId); + expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); + }); + + it('fetches only snap-owned assets when migration is active', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'fetch').mockResolvedValue([]); + + const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); + + expect(coreAdapter.fetch).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0, + ); + expect(assets).toStrictEqual([]); + }); + + it('emits only snap-owned assets and does not persist when migration is active', async () => { + setMigrationStage(activeMigrationStage); + + const nftAsset = { + assetType: `${Network.Mainnet}/nft:NftMintAddress`, + keyringAccountId: accountId, + network: Network.Mainnet, + mint: 'NftMintAddress', + pubkey: 'NftTokenAccount', + symbol: 'NFT', + rawAmount: '1', + uiAmount: '1', + } as const; + + await assetsService.saveMany([MOCK_ASSET_ENTITY_0, nftAsset]); + + expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [accountId]: { + added: [nftAsset.assetType], + removed: [], + }, + }, + }, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [accountId]: { + [nftAsset.assetType]: { + unit: 'NFT', + amount: '1', + }, + }, + }, + }, + ); + }); + + it('reads the Solana migration flag key', async () => { + setMigrationStage(activeMigrationStage); + jest.spyOn(coreAdapter, 'getAccountAssets').mockResolvedValue([]); + + await assetsService.getAccountAssets(accountId); + + expect(mockGetFeatureFlag).toHaveBeenCalledWith( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana, + ); + }); + }); }); 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 af43b6f0..c8c51e31 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,10 @@ /* eslint-disable jsdoc/require-returns */ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { FungibleAssetMarketData } from '@metamask/snaps-sdk'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; @@ -8,31 +14,45 @@ import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; /** - * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter is initialized for upcoming - * routing without changing callers. + * Assets domain facade. Reads, fetch, and save use the Snap adapter while + * migration is off, and the Core adapter once migration is active. When + * migration is active, fetch returns only snap-owned assets and save publishes + * them via keyring events without local persistence. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; - // Initialized for upcoming Core routing; not read until the migration PR lands. - // eslint-disable-next-line no-unused-private-class-members -- reserved adapter slot readonly #coreAdapter: CoreAssetsAdapter; + readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + readonly cacheTtlsMilliseconds: typeof SnapAssetsAdapter.cacheTtlsMilliseconds; constructor({ snapAdapter, coreAdapter, + remoteFeatureFlagsProvider, }: { snapAdapter: SnapAssetsAdapter; coreAdapter: CoreAssetsAdapter; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; }) { this.#snapAdapter = snapAdapter; this.#coreAdapter = coreAdapter; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; this.cacheTtlsMilliseconds = SnapAssetsAdapter.cacheTtlsMilliseconds; } + async #shouldReturnAssetsFromCore(): Promise { + const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana, + ); + return ( + parseSnapsAssetsMigrationStage(flagValue) !== + SnapsAssetsMigrationStage.Off + ); + } + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } @@ -44,6 +64,10 @@ export class AssetsService { } async fetch(account: SolanaKeyringAccount): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.fetch(account); + } + return this.#snapAdapter.fetch(account); } @@ -63,6 +87,10 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.saveMany(assets); + } + return this.#snapAdapter.saveMany(assets); } @@ -80,6 +108,10 @@ export class AssetsService { accountId: string, assetId: CaipAssetType, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetByID(accountId, assetId); + } + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } @@ -94,6 +126,10 @@ export class AssetsService { accountId: string, assetIds: CaipAssetType[], ): Promise> { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); + } + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } @@ -107,6 +143,10 @@ export class AssetsService { scope: CaipChainId, accountId: string, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByScope(scope, accountId); + } + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } @@ -116,6 +156,10 @@ export class AssetsService { * @param accountId - Keyring account ID. */ async getAccountAssets(accountId: string): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssets(accountId); + } + return this.#snapAdapter.getAccountAssets(accountId); } diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 7eb51ded..70ba0ec9 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -94,7 +94,7 @@ export type SnapExecutionContext = { accountsSynchronizer: AccountsSynchronizer; tokenHelper: TokenHelper; /** - * Core messenger plumbing (routing wired in a follow-up PR). + * Core messenger plumbing. */ coreMessenger: CoreMessengerClient; remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; @@ -206,6 +206,7 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter: coreAssetsAdapter, + remoteFeatureFlagsProvider, }); const transactionsRepository = new TransactionsRepository(state); From feeef1d035f8b7df5f8d9075c1633b3ec48f9070 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:16:16 +0000 Subject: [PATCH 2/3] feat(solana-wallet-snap): skip Snap asset persistence when Core migration is on Route reads through CoreAssetsAdapter when the Solana assets flag is active. Fetch and save are no-ops because Solana has no snap-owned assets. KeyringAccountMonitor still discovers transactions but no longer persists balances from websocket notifications. Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- .../services/assets/AssetsService.test.ts | 60 +++++-------------- .../src/core/services/assets/AssetsService.ts | 22 +++++-- .../KeyringAccountMonitor.test.ts | 34 +++++++++++ .../subscriptions/KeyringAccountMonitor.ts | 32 ++++++---- 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 3c007f13..204a7324 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Route Solana asset reads, fetch, and save through `CoreAssetsAdapter` when the assets migration feature flag is active. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) +- Route Solana asset reads through `CoreAssetsAdapter` when the assets migration feature flag is active. Fetch and save become no-ops (Solana has no snap-owned assets), and `KeyringAccountMonitor` stops persisting balances from websocket notifications. ([#123](https://github.com/MetaMask/internal-snaps/pull/123)) - Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads 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 `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) 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 fd948a36..52b894d8 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 @@ -920,61 +920,33 @@ describe('AssetsService', () => { expect(assets).toStrictEqual([MOCK_ASSET_ENTITY_0]); }); - it('fetches only snap-owned assets when migration is active', async () => { + it('returns no assets from fetch when migration is active', async () => { setMigrationStage(activeMigrationStage); - jest.spyOn(coreAdapter, 'fetch').mockResolvedValue([]); + const snapFetchSpy = jest.spyOn(snapAssetsAdapter, 'fetch'); const assets = await assetsService.fetch(MOCK_SOLANA_KEYRING_ACCOUNT_0); - expect(coreAdapter.fetch).toHaveBeenCalledWith( - MOCK_SOLANA_KEYRING_ACCOUNT_0, - ); + expect(snapFetchSpy).not.toHaveBeenCalled(); expect(assets).toStrictEqual([]); }); - it('emits only snap-owned assets and does not persist when migration is active', async () => { + it('does not persist or emit when saveMany is called and migration is active', async () => { setMigrationStage(activeMigrationStage); - const nftAsset = { - assetType: `${Network.Mainnet}/nft:NftMintAddress`, - keyringAccountId: accountId, - network: Network.Mainnet, - mint: 'NftMintAddress', - pubkey: 'NftTokenAccount', - symbol: 'NFT', - rawAmount: '1', - uiAmount: '1', - } as const; - - await assetsService.saveMany([MOCK_ASSET_ENTITY_0, nftAsset]); + await assetsService.saveMany([MOCK_ASSET_ENTITY_0]); expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [accountId]: { - added: [nftAsset.assetType], - removed: [], - }, - }, - }, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [accountId]: { - [nftAsset.assetType]: { - unit: 'NFT', - amount: '1', - }, - }, - }, - }, - ); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }); + + it('reports Core assets as active when the migration flag is on', async () => { + setMigrationStage(activeMigrationStage); + + await expect(assetsService.isUsingCoreAssets()).resolves.toBe(true); + }); + + it('reports Core assets as inactive when the migration flag is off', async () => { + await expect(assetsService.isUsingCoreAssets()).resolves.toBe(false); }); it('reads the Solana migration flag key', async () => { 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 c8c51e31..08139b35 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -14,10 +14,10 @@ import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetMetadata } from './types'; /** - * Assets domain facade. Reads, fetch, and save use the Snap adapter while - * migration is off, and the Core adapter once migration is active. When - * migration is active, fetch returns only snap-owned assets and save publishes - * them via keyring events without local persistence. + * Assets domain facade. Reads use the Snap adapter while migration is off, and + * the Core adapter once migration is active. Solana has no snap-owned assets, + * so when migration is active fetch returns nothing and save is a no-op — + * Core owns fungible balances and the Snap does not persist or publish them. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; @@ -53,6 +53,16 @@ export class AssetsService { ); } + /** + * Whether asset reads come from AssetsController. When true, the Snap must + * not fetch, persist, or websocket-monitor balances — Core already does. + * + * @returns Whether the Solana assets migration flag is active. + */ + async isUsingCoreAssets(): Promise { + return this.#shouldReturnAssetsFromCore(); + } + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } @@ -65,7 +75,7 @@ export class AssetsService { async fetch(account: SolanaKeyringAccount): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.fetch(account); + return []; } return this.#snapAdapter.fetch(account); @@ -88,7 +98,7 @@ export class AssetsService { async saveMany(assets: AssetEntity[]): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.saveMany(assets); + return; } return this.#snapAdapter.saveMany(assets); diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.test.ts index 578e2dca..aa0331b2 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 @@ -127,6 +127,7 @@ describe('KeyringAccountMonitor', () => { mockAssetsService = { getTokenAccountsByOwnerMultiple: jest.fn(), save: jest.fn(), + isUsingCoreAssets: jest.fn().mockResolvedValue(false), getAssetsMetadata: jest.fn().mockImplementation((assetType) => ({ [assetType]: { symbol: 'USDC', @@ -384,6 +385,22 @@ describe('KeyringAccountMonitor', () => { ); }); + it('does not persist native balances when Core assets migration is active', async () => { + jest + .spyOn(mockAssetsService, 'isUsingCoreAssets') + .mockResolvedValue(true); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = accountNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).not.toHaveBeenCalled(); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + it('fetches and saves the transaction that caused the native asset balance to change', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); @@ -546,6 +563,23 @@ describe('KeyringAccountMonitor', () => { ); }); + it('does not persist token balances when Core assets migration is active', async () => { + jest + .spyOn(mockAssetsService, 'isUsingCoreAssets') + .mockResolvedValue(true); + + await keyringAccountMonitor.setMonitoredAccounts([account.id]); + + const handler = programNotificationHandlers[0]!; + await handler(mockNotification, mockSubscription); + + expect(mockAssetsService.save).not.toHaveBeenCalled(); + expect(mockTokenHelper.amountToUiAmountForMint).not.toHaveBeenCalled(); + expect(mockTransactionsService.save).toHaveBeenCalledWith( + mockCausingTransaction, + ); + }); + it('fetches and saves the transaction that caused the token asset to change', async () => { await keyringAccountMonitor.setMonitoredAccounts([account.id]); diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts index 38eb4c32..b9b71601 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts @@ -34,7 +34,8 @@ 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. + * - While Snap still owns balances, it saves the new balance. Under the hood, AssetsService also notifies the extension. + * - Once Core assets migration is active, balance persistence is skipped (Core already tracks fungibles). Transaction discovery continues. * - 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 { @@ -328,17 +329,21 @@ export class KeyringAccountMonitor { const decimals = 9; + const persistAssets = !(await this.#assetsService.isUsingCoreAssets()); + 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), - }), + persistAssets + ? this.#assetsService.save({ + assetType: `${network}/${SolanaCaip19Tokens.SOL}`, + keyringAccountId: keyringAccount.id, + network, + address, + symbol: 'SOL', + decimals, + rawAmount: accountLamports.toString(), + uiAmount: fromTokenUnits(accountLamports, decimals), + }) + : Promise.resolve(), this.#saveCausingTransaction(keyringAccount, network, address), ]); } @@ -386,6 +391,11 @@ export class KeyringAccountMonitor { throw new Error(`No keyring account found with address: ${owner}`); } + if (await this.#assetsService.isUsingCoreAssets()) { + await this.#saveCausingTransaction(keyringAccount, network, pubkey); + return; + } + /** * 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). From e7c52db753e34073f46adf985d03f4c0a1fd0649 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 16:17:24 +0000 Subject: [PATCH 3/3] fix(solana-wallet-snap): lint Core migration routing and sync manifest shasum Co-authored-by: Ulisses Ferreira --- packages/solana-wallet-snap/snap.manifest.json | 2 +- .../src/core/services/assets/AssetsService.test.ts | 4 ++-- .../src/core/services/assets/AssetsService.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 182c8617..ad36f14f 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": "2ZJZAnGhLs7mB0gCCb9ffito6qcx8KMGbteQ0wwtsYw=", + "shasum": "SWB+3GxlGtesIJB2wxYfykU0SmJbn6oonAilJxUEqBk=", "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 52b894d8..7f8296b8 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 @@ -942,11 +942,11 @@ describe('AssetsService', () => { it('reports Core assets as active when the migration flag is on', async () => { setMigrationStage(activeMigrationStage); - await expect(assetsService.isUsingCoreAssets()).resolves.toBe(true); + expect(await assetsService.isUsingCoreAssets()).toBe(true); }); it('reports Core assets as inactive when the migration flag is off', async () => { - await expect(assetsService.isUsingCoreAssets()).resolves.toBe(false); + expect(await assetsService.isUsingCoreAssets()).toBe(false); }); it('reads the Solana migration flag key', async () => { 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 08139b35..94690f93 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -101,7 +101,7 @@ export class AssetsService { return; } - return this.#snapAdapter.saveMany(assets); + await this.#snapAdapter.saveMany(assets); } async getAll(): Promise {