From dcd202c751439d0f67328ffac90633709ea24671 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:12:48 +0100 Subject: [PATCH 1/4] feat(tron-wallet-snap): route asset reads through Core when migration is on Add feature-flag resolution and route fungible reads / snap-owned saveMany through CoreAssetsAdapter when the Tron assets migration flag is active. --- packages/tron-wallet-snap/CHANGELOG.md | 1 + packages/tron-wallet-snap/src/context.ts | 1 + .../src/services/assets/AssetsService.test.ts | 684 +++++++++++------- .../src/services/assets/AssetsService.ts | 66 +- 4 files changed, 491 insertions(+), 261 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index b8b01826e..4e62ad4e7 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Route fungible asset reads through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) - Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) ([#144](https://github.com/MetaMask/internal-snaps/pull/144)) ### Changed diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 8a0484310..a561609b4 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -142,6 +142,7 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter: coreAssetsAdapter, + remoteFeatureFlagsProvider, }); const transactionsService = new TransactionsService({ diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index 3657af081..ce9c4a97c 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,6 +1,15 @@ +import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { + AssetsProvider, + RemoteFeatureFlagsProvider, +} from '@metamask/snap-networks-utils'; import { MOCK_EXCHANGE_RATES } from '../../clients/price-api/mocks/exchange-rates'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; @@ -10,10 +19,14 @@ import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; import { TrongridAccountNotFoundError } from '../../clients/trongrid/errors'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network } from '../../constants'; +import type { TronAccount } from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; +import type { ConfigProvider } from '../config'; +import { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; @@ -58,14 +71,74 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ (global as any).snap = {}; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { configProvider } = require('../../context'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { CoreAssetsAdapter } = require('./adapters/CoreAssetsAdapter'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { SnapAssetsAdapter } = require('./adapters/SnapAssetsAdapter'); + +const TRON_FLAG_KEY = SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron; + +function createMessengerCallMock( + getState: () => unknown, + getAccountAssetByID: jest.Mock, + getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), +): CoreMessengerCaller['call'] { + return async (actionType, ...args) => { + switch (actionType) { + case 'RemoteFeatureFlagController:getState': + return getState() as Awaited>; + case 'AssetsController:getAccountAssetByID': + return getAccountAssetByID(...args); + case 'AssetsController:getAccountAssetsByIDs': + return getAccountAssetsByIDs(...args); + case 'AssetsController:getAccountAssetsByScope': + return getAccountAssetsByScope(...args); + default: + return undefined; + } + }; +} + +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { + symbol: string; + name: string; + decimals: number; + image?: string; + }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.name, + decimals: metadata.decimals, + image: metadata.image, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + +/** + * Builds a SpotPrices map for test mocks. + * + * @param entries - Map of asset ID to price info. + * @returns SpotPrices object. + */ +const createSpotPrices = ( + entries: Record, +): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); const mockAccount: KeyringAccount = { id: 'test-account-id', @@ -88,22 +161,6 @@ const emptyAccountResources: AccountResources = { TotalEnergyWeight: 0, }; -/** - * Creates properly typed SpotPrices for tests. - * - * @param entries - Map of asset ID to price info. - * @returns SpotPrices object. - */ -const createSpotPrices = ( - entries: Record, -): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - /** * Creates a properly typed TronAccount for tests. * Uses snake_case property names to match Tron API response format. @@ -208,6 +265,8 @@ type WithAssetsServiceCallback = (payload: { >; mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; + mockCoreMessenger: jest.Mocked; + setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; /** @@ -280,26 +339,77 @@ async function withAssetsService( trackError: jest.fn().mockResolvedValue(undefined), }; + const mockGetAccountAssetByID = jest.fn(); + const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); + const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); + let migrationStage = SnapsAssetsMigrationStage.Off; + const mockCoreMessenger: jest.Mocked = { + call: jest.fn().mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { stage: migrationStage }, + }, + }), + mockGetAccountAssetByID, + mockGetAccountAssetsByIDs, + mockGetAccountAssetsByScope, + ), + ), + }; + + const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { + migrationStage = stage; + }; + + const assetsProvider = new AssetsProvider({ + messenger: mockCoreMessenger as never, + }); + const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: mockCoreMessenger as never, + }); + + const mockConfigProvider: jest.Mocked> = { + get: jest.fn().mockReturnValue({ + priceApi: { + cacheTtlsMilliseconds: { + fiatExchangeRates: 3600000, + spotPrices: 3600000, + historicalPrices: 3600000, + }, + }, + activeNetworks: [], + }), + }; + const snapAdapter = new SnapAssetsAdapter({ logger: mockLogger, - assetsRepository: mockAssetsRepository, - state: mockState, - trongridApiClient: mockTrongridApiClient, - tronHttpClient: mockTronHttpClient, - priceApiClient: mockPriceApiClient, - tokenApiClient: mockTokenApiClient, - snapClient: mockSnapClient, - configProvider, + assetsRepository: mockAssetsRepository as never, + state: mockState as never, + trongridApiClient: mockTrongridApiClient as never, + tronHttpClient: mockTronHttpClient as never, + priceApiClient: mockPriceApiClient as never, + tokenApiClient: mockTokenApiClient as never, + snapClient: mockSnapClient as never, + configProvider: mockConfigProvider as never, }); const coreAdapter = new CoreAssetsAdapter({ - getAccountAssetByID: jest.fn().mockResolvedValue(null), - getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), - getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + getAccountAssetByID: + assetsProvider.getAccountAssetByID.bind(assetsProvider), + getAccountAssetsByIDs: + assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), + getAccountAssetsByScope: + assetsProvider.getAccountAssetsByScope.bind(assetsProvider), getAddressInfo: mockTrongridApiClient.getAccountInfoByAddress, getAddressResources: mockTronHttpClient.getAccountResources, getAddressStakingRewards: mockTronHttpClient.getReward, }); - const assetsService = new AssetsService({ snapAdapter, coreAdapter }); + + const assetsService = new AssetsService({ + snapAdapter, + coreAdapter, + remoteFeatureFlagsProvider, + }); return await testFunction({ assetsService, @@ -310,6 +420,8 @@ async function withAssetsService( mockPriceApiClient, mockTokenApiClient, mockSnapClient, + mockCoreMessenger, + setMigrationStage, }); } @@ -361,10 +473,8 @@ describe('AssetsService', () => { expect(trxAsset).toBeDefined(); expect(trxAsset?.rawAmount).toBe('0'); - const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const trc20Asset = assets.find( - (asset: AssetEntity) => - asset.assetType === expectedTrc20AssetType, + (asset: AssetEntity) => asset.assetType === trc20AssetId, ); expect(trc20Asset).toBeDefined(); expect(trc20Asset?.rawAmount).toBe('24249143'); @@ -372,7 +482,7 @@ describe('AssetsService', () => { ); }); - it('returns zero TRX and resources when fallback also returns empty', async () => { + it('returns protocol resources when inactive account has empty resources', async () => { await withAssetsService( async ({ assetsService, @@ -394,17 +504,6 @@ describe('AssetsService', () => { mockAccount, ); - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - const bandwidthAsset = assets.find( (asset: AssetEntity) => asset.assetType === KnownCaip19Id.BandwidthMainnet, @@ -419,75 +518,12 @@ describe('AssetsService', () => { ); }); - it('gracefully handles fallback endpoint failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - new Error('Network error'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('tracks fallback endpoint errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const error = new Error('Network error'); - - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - error, - ); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - - it('filters out TRC20 tokens without price data from inactive account', async () => { + it('returns protocol assets when inactive account info fails', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -495,20 +531,8 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); - - const trc20BalancesWithSpam: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price - { TSpamToken123456789: '1000000000' }, // Spam token - no price - ]; mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam, - ); - - const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, - }), + [], ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -516,30 +540,24 @@ describe('AssetsService', () => { mockAccount, ); - const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const usdtAsset = assets.find( - (asset: AssetEntity) => asset.assetType === usdtAssetType, - ); - expect(usdtAsset).toBeDefined(); - - const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; - const spamAsset = assets.find( - (asset: AssetEntity) => asset.assetType === spamAssetType, - ); - expect(spamAsset).toBeUndefined(); + expect(assets.length).toBeGreaterThan(0); + expect( + assets.some((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); }, ); }); }); describe('partial failure handling', () => { - it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -550,19 +568,8 @@ describe('AssetsService', () => { NetLimit: 0, EnergyLimit: 0, }); - - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), + [], ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -572,20 +579,19 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + ).toHaveBeenCalled(); + expect( + assets.some((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); - const trxAsset = assets.find( + const bandwidthAsset = assets.find( (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, + asset.assetType === KnownCaip19Id.BandwidthMainnet, ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('100000'); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('600'); }, ); }); @@ -613,12 +619,12 @@ describe('AssetsService', () => { mockAccount, ); - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('1000000'); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -629,38 +635,6 @@ describe('AssetsService', () => { }, ); }); - - it('tracks spot price errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - const error = new Error('Spot price endpoint unavailable'); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockPriceApiClient.getMultipleSpotPrices.mockRejectedValue(error); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); }); describe('bandwidth', () => { @@ -1549,7 +1523,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, ]), @@ -1594,7 +1567,6 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); - expect(await assetsService.getAll()).toStrictEqual(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1665,9 +1637,6 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - // If an asset is missing from the received list - // - emits the event 'notify:accountAssetListUpdated' with the asset in the 'removed' property - // - emits the event 'notify:accountBalancesUpdated' with the balance for the removed asset sets to 0 await assetsService.saveMany(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( @@ -1756,7 +1725,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.MaximumEnergyMainnet, KnownCaip19Id.MaximumBandwidthMainnet, ]), @@ -1817,7 +1785,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), @@ -1868,7 +1835,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxReadyForWithdrawalMainnet, ]), removed: [], @@ -1946,7 +1912,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, ]), removed: [], @@ -2023,7 +1988,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.BandwidthMainnet, ]), removed: [], @@ -2221,10 +2185,8 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, - trc20AssetId, ]), removed: [], }, @@ -2300,7 +2262,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), removed: [], @@ -2862,8 +2823,259 @@ describe('AssetsService', () => { }); }); + describe('getAssetsMetadata', () => { + it('resolves metadata for native, protocol, and token asset types', async () => { + await withAssetsService(async ({ assetsService, mockTokenApiClient }) => { + const trc20 = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; + const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; + + mockTokenApiClient.getTokensMetadata.mockResolvedValue({ + [trc20]: { + fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, + }, + [trc10]: { + fungible: { symbol: 'T', name: 'Token', decimals: 0 }, + }, + } as never); + + const assetTypes = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + trc10, + trc20, + ]; + + const metadata = await assetsService.getAssetsMetadata(assetTypes); + + expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); + expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); + expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ + trc10, + trc20, + ]); + }); + }); + }); + + describe('assets migration', () => { + const accountId = mockAccount.id; + const fungibleAssetId = KnownCaip19Id.TrxMainnet; + const activeMigrationStage = + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; + + it('routes getAccountAssetByID through AssetsController when migration is active', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn().mockResolvedValue( + buildControllerAsset(fungibleAssetId, '2000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + ), + ), + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + fungibleAssetId, + ); + + expect(asset).toMatchObject({ + assetType: fungibleAssetId, + rawAmount: '2000000', + uiAmount: '2', + }); + }); + }); + + it('routes getAccountAssetsByIDs through AssetsController when migration is active', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + const trx = KnownCaip19Id.TrxMainnet; + const usdt = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { + symbol: 'USDT', + name: 'Tether', + decimals: 6, + }), + }; + }), + ), + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + trx, + usdt, + ]); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [trx, usdt], + ); + expect(results[0]?.rawAmount).toBe('1000000'); + expect(results[1]?.rawAmount).toBe('500000'); + }); + }); + + it('routes getAccountAssets through AssetsController when migration is active', async () => { + await withAssetsService( + async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { + setMigrationStage(activeMigrationStage); + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn(), + jest.fn(), + jest.fn().mockResolvedValue({ + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '2000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + }), + ), + ); + + const assets = await assetsService.getAccountAssets(accountId); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Mainnet, + ); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Nile, + ); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Shasta, + ); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(true); + }, + ); + }); + + it('emits only snap-owned assets and does not persist when migration is active', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + setMigrationStage, + }) => { + setMigrationStage(activeMigrationStage); + + const specialAsset: AssetEntity = { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '600', + uiAmount: '600', + iconUrl: '', + }; + const fungibleAsset: AssetEntity = { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }; + + await assetsService.saveMany([specialAsset, fungibleAsset]); + + expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.BandwidthMainnet], + removed: [], + }, + }, + }, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '600', + }, + }, + }, + }, + ); + }, + ); + }); + }); + describe('facade delegation', () => { - it('delegates repository reads and market helpers to SnapAssetsAdapter', async () => { + it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { await withAssetsService( async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { const asset: AssetEntity = { @@ -2877,13 +3089,9 @@ describe('AssetsService', () => { uiAmount: '1', }; - mockAssetsRepository.getByAccountId.mockResolvedValue([asset]); mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ asset, ]); - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - asset, - ); mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( MOCK_EXCHANGE_RATES, ); @@ -2900,37 +3108,17 @@ describe('AssetsService', () => { expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); - - const accountAssets = await assetsService.getAccountAssets( - mockAccount.id, - ); expect( - accountAssets.some( - (savedAsset: AssetEntity) => - savedAsset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); + await assetsService.getAccountAssetsByIDs(mockAccount.id, []), + ).toStrictEqual([]); expect( - await assetsService.getAccountAssetsByIDs(mockAccount.id, [ - KnownCaip19Id.TrxMainnet, + await assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, ]), - ).toStrictEqual([asset]); - expect( - await assetsService.getAccountAssetByID( - mockAccount.id, - KnownCaip19Id.TrxMainnet, - ), - ).toStrictEqual(asset); - const marketData = await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]); - expect(marketData[KnownCaip19Id.TrxMainnet]).toBeDefined(); - expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( - 3600000, - ); + ).toBeDefined(); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 9af372ec6..2e3c80a48 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,4 +1,10 @@ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, @@ -13,31 +19,45 @@ import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** - * 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 use the Snap adapter while migration is off, and + * the Core adapter once migration is active. Fetch always uses the Snap adapter. + * When migration is active, save routes snap-owned assets through Core (emit-only, + * no 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: SnapAssetsAdapter['cacheTtlsMilliseconds']; constructor({ snapAdapter, coreAdapter, + remoteFeatureFlagsProvider, }: { snapAdapter: SnapAssetsAdapter; coreAdapter: CoreAssetsAdapter; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; }) { this.#snapAdapter = snapAdapter; this.#coreAdapter = coreAdapter; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } + async #shouldReturnAssetsFromCore(): Promise { + const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron, + ); + return ( + parseSnapsAssetsMigrationStage(flagValue) !== + SnapsAssetsMigrationStage.Off + ); + } + static isFiat(caipAssetId: CaipAssetType): boolean { return SnapAssetsAdapter.isFiat(caipAssetId); } @@ -46,22 +66,30 @@ export class AssetsService { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } - async getAccountAssets(accountId: string): Promise { - return this.#snapAdapter.getAccountAssets(accountId); - } - async getAccountAssetsByIDs( accountId: string, - assetTypes: string[], + assetIds: string[], ): Promise<(AssetEntity | null)[]> { - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetTypes); + if (assetIds.length === 0) { + return []; + } + + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); + } + + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } async getAccountAssetByID( accountId: string, - assetType: string, + assetId: string, ): Promise { - return this.#snapAdapter.getAccountAssetByID(accountId, assetType); + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetByID(accountId, assetId); + } + + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } async fetchAssetsAndBalancesForAccount( @@ -78,6 +106,10 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.saveMany(assets); + } + return this.#snapAdapter.saveMany(assets); } @@ -85,6 +117,14 @@ export class AssetsService { return this.#snapAdapter.getAll(); } + async getAccountAssets(accountId: string): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssets(accountId); + } + + return this.#snapAdapter.getAccountAssets(accountId); + } + async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< From b62b2e17ed398482c030ec21bca47011e18bdbc2 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:17:55 +0100 Subject: [PATCH 2/4] feat(tron-wallet-snap): route snap-owned fetch through Core when migration is on When the Tron assets migration flag is active, fetchAssetsAndBalancesForAccount uses CoreAssetsAdapter so only snap-owned assets are fetched on-chain. --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- .../src/services/assets/AssetsService.test.ts | 53 +++++++++++++++++++ .../src/services/assets/AssetsService.ts | 12 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 4e62ad4e7..8f4307e68 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Route fungible asset reads through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) +- Route fungible asset reads and snap-owned fetch/save through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) - Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) ([#144](https://github.com/MetaMask/internal-snaps/pull/144)) ### Changed diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index ce9c4a97c..3efe66acb 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3010,6 +3010,59 @@ describe('AssetsService', () => { ); }); + it('fetches only snap-owned assets when migration is active', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + setMigrationStage, + }) => { + setMigrationStage(activeMigrationStage); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ + address: mockAccount.address, + balance: 5_000_000, + trc20: [ + { + TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000000', + }, + ], + assetV2: [], + frozenV2: [], + unfrozenV2: [], + } as unknown as TronAccount); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, + freeNetLimit: 600, + EnergyLimit: 1000, + }); + mockTronHttpClient.getReward.mockResolvedValue(0); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).not.toHaveBeenCalled(); + expect(assets.length).toBeGreaterThan(0); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); + }, + ); + }); + it('emits only snap-owned assets and does not persist when migration is active', async () => { await withAssetsService( async ({ diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 2e3c80a48..10d207815 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -19,10 +19,10 @@ import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** - * Assets domain facade. Reads use the Snap adapter while migration is off, and - * the Core adapter once migration is active. Fetch always uses the Snap adapter. - * When migration is active, save routes snap-owned assets through Core (emit-only, - * no local persistence). + * Assets domain facade. Reads and snap-owned fetch/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; @@ -96,6 +96,10 @@ export class AssetsService { scope: Network, account: KeyringAccount, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); + } + return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } From 084b8c7dc28658855c37f3c423895e6b3df79b79 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:35:00 +0100 Subject: [PATCH 3/4] fix(tron-wallet-snap): sync manifest shasum and format tests after rebase --- .../src/services/assets/AssetsService.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index 3efe66acb..0925411a3 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3065,11 +3065,7 @@ describe('AssetsService', () => { it('emits only snap-owned assets and does not persist when migration is active', async () => { await withAssetsService( - async ({ - assetsService, - mockAssetsRepository, - setMigrationStage, - }) => { + async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { setMigrationStage(activeMigrationStage); const specialAsset: AssetEntity = { From 285641c7e03b13a22554a65566c6f322efda50f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 10:06:28 +0000 Subject: [PATCH 4/4] fix(tron-wallet-snap): align Core asset ID types and sync manifest shasum Cast facade asset IDs to Caip19AssetId for the Core adapter constructor shape on main, and refresh the snap bundle shasum after the rebase. Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/assets/AssetsService.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 3e41a75cf..112f6f550 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "tjWVt/iPBoSqDOS1uPATvu4MiZ4mkgwuEEZlMNDAwHc=", + "shasum": "7PXRWlPNNRfaP4T3FSepipHGBOdovgS4UUsVVtDQQmI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 10d207815..4f8db5d5a 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,3 +1,4 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; import { SNAPS_ASSETS_MIGRATION_FLAG_KEYS, SnapsAssetsMigrationStage, @@ -75,7 +76,10 @@ export class AssetsService { } if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); + return this.#coreAdapter.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); } return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); @@ -86,7 +90,10 @@ export class AssetsService { assetId: string, ): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.getAccountAssetByID(accountId, assetId); + return this.#coreAdapter.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); } return this.#snapAdapter.getAccountAssetByID(accountId, assetId);