diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index b8b01826e..8f4307e68 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 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/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/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..0925411a3 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,7 +518,7 @@ describe('AssetsService', () => { ); }); - it('gracefully handles fallback endpoint failure', async () => { + it('returns protocol assets when inactive account info fails', async () => { await withAssetsService( async ({ assetsService, @@ -432,83 +531,8 @@ describe('AssetsService', () => { 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 () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - 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,308 @@ 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('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 ({ 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 +3138,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 +3157,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..4f8db5d5a 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,4 +1,11 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; +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 +20,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 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; - // 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,28 +67,46 @@ 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 as Caip19AssetId[], + ); + } + + 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 as Caip19AssetId, + ); + } + + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); + } + return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } @@ -78,6 +117,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 +128,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<