From fa9985155bd59fb73b1ed74bc454855e4206ada1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:22:25 +0000 Subject: [PATCH 1/8] fix(tron-wallet-snap): sync CI manifest shasum Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/snap.manifest.json | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 30ac1291..5d618aeb 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -16,16 +16,22 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": ["locales/en.json"] + "locales": [ + "locales/en.json" + ] }, "initialConnections": { "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"], + "allowedOrigins": [ + "https://portfolio.metamask.io" + ], "capabilities": { - "scopes": ["tron:728126428"], + "scopes": [ + "tron:728126428" + ], "privateKey": { "exportFormats": [ { @@ -42,7 +48,11 @@ }, "snap_getBip32Entropy": [ { - "path": ["m", "44'", "195'"], + "path": [ + "m", + "44'", + "195'" + ], "curve": "secp256k1" } ], @@ -62,7 +72,9 @@ ] }, "endowment:assets": { - "scopes": ["tron:728126428"] + "scopes": [ + "tron:728126428" + ] }, "endowment:messenger": { "actions": [ From 53bf6030bc0dd11f9938da7fbdbd1556e4b21644 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:29:40 +0000 Subject: [PATCH 2/8] fix(tron-wallet-snap): format manifest and sync CI shasum Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/snap.manifest.json | 22 +++++--------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 5d618aeb..30ac1291 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -16,22 +16,16 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": [ - "locales/en.json" - ] + "locales": ["locales/en.json"] }, "initialConnections": { "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": [ - "https://portfolio.metamask.io" - ], + "allowedOrigins": ["https://portfolio.metamask.io"], "capabilities": { - "scopes": [ - "tron:728126428" - ], + "scopes": ["tron:728126428"], "privateKey": { "exportFormats": [ { @@ -48,11 +42,7 @@ }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "44'", - "195'" - ], + "path": ["m", "44'", "195'"], "curve": "secp256k1" } ], @@ -72,9 +62,7 @@ ] }, "endowment:assets": { - "scopes": [ - "tron:728126428" - ] + "scopes": ["tron:728126428"] }, "endowment:messenger": { "actions": [ From c3d77af0ee49184def456381f9a187e32d13ac4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:11:46 +0000 Subject: [PATCH 3/8] feat(tron-wallet-snap): route fungible reads through Core AssetsController Introduce migration-stage routing for fungible asset reads (Off / ReadWithFallback / Read) via AssetsProvider, with mapControllerAsset and SnapAssetsAdapter updates. Snap-owned assets remain on the Snap adapter. Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/CHANGELOG.md | 1 + packages/tron-wallet-snap/snap.manifest.json | 6 +- packages/tron-wallet-snap/src/context.ts | 9 +- .../src/services/assets/AssetsService.test.ts | 903 +++++++++++++----- .../src/services/assets/AssetsService.ts | 181 +++- .../assets/adapters/SnapAssetsAdapter.ts | 188 ++-- .../assets/mapControllerAsset.test.ts | 84 ++ .../src/services/assets/mapControllerAsset.ts | 35 + .../src/types/core-messenger.ts | 14 +- 9 files changed, 1096 insertions(+), 325 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 4cc38345..20c27e81 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Core messenger plumbing (`getMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) +- Route fungible asset reads through Core AssetsController based on migration stage ([#96](https://github.com/MetaMask/internal-snaps/pull/96)) ## [3.0.0] diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 30ac1291..7ce3e9f9 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": "B9kqmuUcYgPAD3ojseIm2HymqK77Q4NEzULU876+5Wc=", + "shasum": "aMPCWbmb3WNcpfaNwjufCq13IpXbUJK5c1pK+wIx0EE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -66,10 +66,10 @@ }, "endowment:messenger": { "actions": [ - "RemoteFeatureFlagController:getState", "AssetsController:getAccountAssetByID", "AssetsController:getAccountAssetsByIDs", - "AssetsController:getAccountAssetsByScope" + "AssetsController:getAccountAssetsByScope", + "RemoteFeatureFlagController:getState" ] } }, diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 278d549b..0a983ed3 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -39,7 +39,10 @@ import { TransactionScanService } from './services/transaction-scan/TransactionS import { TransactionsRepository } from './services/transactions/TransactionsRepository'; import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; -import type { CoreMessenger } from './types/core-messenger'; +import type { + CoreMessenger, + CoreMessengerMessenger, +} from './types/core-messenger'; import logger, { noOpLogger } from './utils/logger'; /** @@ -96,7 +99,7 @@ const tokenApiClient = new TokenApiClient(configProvider); /** * Core controllers plumbing */ -const coreMessenger = getMessenger(); +const coreMessenger = getMessenger(); const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, }); @@ -120,7 +123,7 @@ const assetsService = new AssetsService({ priceApiClient, tokenApiClient, snapClient, - remoteFeatureFlagsProvider, + coreMessenger, assetsProvider, }); 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 549b55ec..5e185983 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,7 +1,14 @@ +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 } from '@metamask/snap-networks-utils'; +import { MOCK_EXCHANGE_RATES } from '../../clients/price-api/mocks/exchange-rates'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SpotPrices } from '../../clients/price-api/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -9,9 +16,10 @@ 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 { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; @@ -51,6 +59,89 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); +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 restoreMigrationStageEnv( + originalEnvironment: string | undefined, + originalStage: string | undefined, +): void { + /* eslint-disable n/no-process-env */ + if (originalEnvironment === undefined) { + delete process.env.ENVIRONMENT; + } else { + process.env.ENVIRONMENT = originalEnvironment; + } + delete process.env.TRON_ASSETS_MIGRATION_STAGE; + if (originalStage !== undefined) { + process.env.TRON_ASSETS_MIGRATION_STAGE = originalStage; + } + /* eslint-enable n/no-process-env */ +} + +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', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', @@ -72,22 +163,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. @@ -187,6 +262,8 @@ type WithAssetsServiceCallback = (payload: { >; mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; + mockCoreMessenger: jest.Mocked; + setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; /** @@ -259,6 +336,33 @@ 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 assetsService = new AssetsService({ logger: mockLogger, assetsRepository: mockAssetsRepository, @@ -268,6 +372,8 @@ async function withAssetsService( priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, + coreMessenger: mockCoreMessenger, + assetsProvider, }); return await testFunction({ @@ -279,6 +385,8 @@ async function withAssetsService( mockPriceApiClient, mockTokenApiClient, mockSnapClient, + mockCoreMessenger, + setMigrationStage, }); } @@ -292,7 +400,9 @@ describe('AssetsService', () => { mockTrongridApiClient, mockTronHttpClient, mockPriceApiClient, + setMigrationStage, }) => { + setMigrationStage(SnapsAssetsMigrationStage.Off); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -330,10 +440,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'); @@ -341,22 +449,23 @@ describe('AssetsService', () => { ); }); - it('returns zero TRX and resources when fallback also returns empty', async () => { + it('skips TRC20 fallback and returns protocol assets only when mode is controller', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, + setMigrationStage, }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, @@ -365,99 +474,74 @@ describe('AssetsService', () => { 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, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(energyAsset).toBeDefined(); + ).not.toHaveBeenCalled(); + 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('gracefully handles fallback endpoint failure', async () => { + it('returns protocol resources when inactive account has empty resources', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, + setMigrationStage, }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); 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'); + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).not.toHaveBeenCalled(); - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - error, + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, ); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const energyAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.EnergyMainnet, ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + expect(bandwidthAsset).toBeDefined(); + expect(energyAsset).toBeDefined(); }, ); }); - 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, + setMigrationStage, }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -465,51 +549,34 @@ describe('AssetsService', () => { 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( Network.Mainnet, 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.every((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, + setMigrationStage, }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -520,20 +587,6 @@ describe('AssetsService', () => { 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( Network.Mainnet, mockAccount, @@ -541,20 +594,19 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + ).not.toHaveBeenCalled(); + expect( + assets.every((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'); }, ); }); @@ -565,7 +617,11 @@ describe('AssetsService', () => { assetsService, mockTrongridApiClient, mockTronHttpClient, + setMigrationStage, }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( createMockTronAccount({ address: mockAccount.address, @@ -582,12 +638,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(false); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -598,38 +654,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', () => { @@ -1518,7 +1542,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, ]), @@ -1634,9 +1657,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( @@ -1725,7 +1745,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.MaximumEnergyMainnet, KnownCaip19Id.MaximumBandwidthMainnet, ]), @@ -1786,7 +1805,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), @@ -1837,7 +1855,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxReadyForWithdrawalMainnet, ]), removed: [], @@ -1915,7 +1932,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, ]), removed: [], @@ -1992,7 +2008,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.BandwidthMainnet, ]), removed: [], @@ -2190,10 +2205,8 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, - trc20AssetId, ]), removed: [], }, @@ -2269,7 +2282,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), removed: [], @@ -2831,8 +2843,478 @@ 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 mode', () => { + const accountId = mockAccount.id; + const fungibleAssetId = KnownCaip19Id.TrxMainnet; + const snapAssetId = KnownCaip19Id.EnergyMainnet; + + it('fetchAssetsAndBalancesForAccount returns fungibles when mode is snap', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + setMigrationStage, + }) => { + setMigrationStage(SnapsAssetsMigrationStage.Off); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(true); + }, + ); + }); + + it('fetchAssetsAndBalancesForAccount returns protocol assets only when mode is controller', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + setMigrationStage, + }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + trc20: [{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000' }], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(false); + }, + ); + }); + + it('routes snap-owned reads through the repository', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + snapAssetId, + ); + + expect(asset).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( + 'AssetsController:getAccountAssetByID', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('routes fungible reads through AssetsController when mode is controller', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + }, + }, + }), + 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('getAccountAssetsByIDs uses a single AssetsController:getAccountAssetsByIDs call for fungibles', 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: + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + }, + }, + }), + 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('getAccountAssetsByIDs batches snap-owned reads without calling AssetsController', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ + snapAsset, + ]); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + snapAssetId, + ]); + + expect(results[0]).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('getAccountAssetsByIDs routes all asset IDs through AssetsController when mode is controller', async () => { + await withAssetsService( + async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + }, + }, + }), + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '3000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + [snapAssetId as Caip19AssetId]: buildControllerAsset( + snapAssetId, + '250', + { + symbol: 'ENERGY', + name: 'Energy', + decimals: 0, + }, + ), + }; + }), + ), + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + fungibleAssetId, + snapAssetId, + ]); + + expect(results[0]?.rawAmount).toBe('3000000'); + expect(results[1]?.rawAmount).toBe('250'); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [fungibleAssetId, snapAssetId], + ); + }, + ); + }); + + it('getByKeyringAccountId reads from AssetsController when mode is controller', async () => { + await withAssetsService( + async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + }, + }, + }), + jest.fn(), + jest.fn(), + jest.fn().mockResolvedValue({ + [snapAssetId as Caip19AssetId]: buildControllerAsset( + snapAssetId, + '100', + { + symbol: 'ENERGY', + name: 'Energy', + decimals: 0, + }, + ), + }), + ), + ); + + const assets = await assetsService.getByKeyringAccountId(accountId); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Mainnet, + ); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === snapAssetId, + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(false); + }, + ); + }); + + it('saveMany emits only snap-owned assets when mode is controller', async () => { + await withAssetsService( + async ({ assetsService, mockState, setMigrationStage }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + mockState.getKey.mockResolvedValue({}); + + const assets: AssetEntity[] = [ + { + assetType: fungibleAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }, + ]; + + await assetsService.saveMany(assets); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [accountId]: { + added: [snapAssetId], + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('ignores TRON_ASSETS_MIGRATION_STAGE in production', async () => { + /* eslint-disable n/no-process-env */ + const originalEnvironment = process.env.ENVIRONMENT; + const originalStage = process.env.TRON_ASSETS_MIGRATION_STAGE; + process.env.ENVIRONMENT = 'production'; + process.env.TRON_ASSETS_MIGRATION_STAGE = '2'; + /* eslint-enable n/no-process-env */ + + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ remoteFeatureFlags: {} }), + jest.fn(), + ), + ); + const snapAsset: AssetEntity = { + assetType: fungibleAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + fungibleAssetId, + ); + + expect(asset).toStrictEqual(snapAsset); + }, + ); + + restoreMigrationStageEnv(originalEnvironment, originalStage); + }); + }); + 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 = { @@ -2843,18 +3325,15 @@ describe('AssetsService', () => { decimals: 6, rawAmount: '1', uiAmount: '1', + iconUrl: '', }; - mockAssetsRepository.getByAccountId.mockResolvedValue([asset]); mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ asset, ]); - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - asset, + mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( + MOCK_EXCHANGE_RATES, ); - mockPriceApiClient.getFiatExchangeRates.mockResolvedValue({ - usd: { value: 1 }, - }); mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( createSpotPrices({ [KnownCaip19Id.TrxMainnet]: { @@ -2868,39 +3347,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); - expect( - await assetsService.getAccountAssets(mockAccount.id), - ).toStrictEqual([asset]); + 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 byKeyringAccountId = await assetsService.getByKeyringAccountId( - mockAccount.id, - ); - expect( - byKeyringAccountId.some( - (savedAsset) => savedAsset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); - 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 77f63dc8..3039fa84 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,36 +1,48 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; import { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + getSnapsAssetsMigrationNamespace, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; +import type { Caip19AssetId } from '@metamask/assets-controller'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; -import type { CaipAssetType } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Network } from '../../constants'; +import { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreMessengerCaller } from '../../types/core-messenger'; import type { ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; +import { mapControllerAsset } from './mapControllerAsset'; /** - * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter routing can be introduced later - * without changing callers. + * Assets migration stage used when no remote feature flag is set for the chain. + * Change this value to test Stage 0 / 1 / 2 locally. */ +const ASSETS_MIGRATION_STAGE = SnapsAssetsMigrationStage.Off; + export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; + readonly #assetsProvider: AssetsProvider; + + readonly #coreMessenger: CoreMessengerCaller; + readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; constructor({ @@ -42,6 +54,8 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, + coreMessenger, + assetsProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -51,9 +65,12 @@ export class AssetsService { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + coreMessenger: CoreMessengerCaller; assetsProvider: AssetsProvider; }) { + this.#coreMessenger = coreMessenger; + this.#assetsProvider = assetsProvider; + this.#snapAdapter = new SnapAssetsAdapter({ logger, assetsRepository, @@ -63,10 +80,40 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, + resolveMigrationStage: ( + chainId: string, + ): Promise => + this.#resolveMigrationStage(chainId), }); this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } + async #resolveMigrationStage( + chainId: string, + ): Promise { + const { remoteFeatureFlags } = await this.#coreMessenger.call( + 'RemoteFeatureFlagController:getState', + ); + + const namespace = getSnapsAssetsMigrationNamespace(chainId as CaipChainId); + + if (namespace) { + const flagKey = SNAPS_ASSETS_MIGRATION_FLAG_KEYS[namespace]; + + if (Object.hasOwn(remoteFeatureFlags, flagKey)) { + const remoteStage = parseSnapsAssetsMigrationStage( + remoteFeatureFlags[flagKey] as Json | undefined, + ); + + if (remoteStage !== undefined) { + return remoteStage; + } + } + } + + return ASSETS_MIGRATION_STAGE; + } + static isFiat(caipAssetId: CaipAssetType): boolean { return SnapAssetsAdapter.isFiat(caipAssetId); } @@ -75,22 +122,84 @@ export class AssetsService { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } - async getAccountAssets(accountId: string): Promise { - return this.#snapAdapter.getAccountAssets(accountId); + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + const { chainId } = parseCaipAssetType(assetId as CaipAssetType); + const stage = await this.#resolveMigrationStage(chainId); + + if (stage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + const asset = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); } async getAccountAssetsByIDs( accountId: string, - assetTypes: string[], + assetIds: string[], ): Promise<(AssetEntity | null)[]> { - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetTypes); + if (assetIds.length === 0) { + return []; + } + + const { chainId } = parseCaipAssetType(assetIds[0] as CaipAssetType); + const stage = await this.#resolveMigrationStage(chainId); + + if (stage === SnapsAssetsMigrationStage.Off) { + const results = await this.#snapAdapter.getAccountAssetsByIDs( + accountId, + assetIds, + ); + + return assetIds.map((assetId) => results[assetId] ?? null); + } + + const controllerAssets = await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + return assetIds.map((assetId) => { + const controllerAsset = controllerAssets[assetId as Caip19AssetId]; + return controllerAsset + ? mapControllerAsset(accountId, controllerAsset) + : null; + }); } - async getAccountAssetByID( + async getAccountAssetsByScope( + scope: Network, accountId: string, - assetType: string, - ): Promise { - return this.#snapAdapter.getAccountAssetByID(accountId, assetType); + ): Promise { + const stage = await this.#resolveMigrationStage(scope); + + if (stage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); + } + + const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( + scope, + accountId, + ); + + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(accountId, asset), + ); + } + + async getByKeyringAccountId(accountId: string): Promise { + return this.getAccountAssetsByScope(Network.Mainnet, accountId); } async fetchAssetsAndBalancesForAccount( @@ -100,12 +209,6 @@ export class AssetsService { return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - return this.#snapAdapter.getAssetsMetadata(assetTypes); - } - async saveMany(assets: AssetEntity[]): Promise { return this.#snapAdapter.saveMany(assets); } @@ -114,8 +217,15 @@ export class AssetsService { return this.#snapAdapter.getAll(); } - async getByKeyringAccountId(accountId: string): Promise { - return this.#snapAdapter.getByKeyringAccountId(accountId); + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + return this.#snapAdapter.getHistoricalPrice(from, to); } async getMultipleTokenConversions( @@ -126,6 +236,12 @@ export class AssetsService { return this.#snapAdapter.getMultipleTokenConversions(conversions); } + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + return this.#snapAdapter.getAssetsMetadata(assetTypes); + } + async getMultipleTokensMarketData( assets: { asset: CaipAssetType; @@ -136,15 +252,6 @@ export class AssetsService { > { return this.#snapAdapter.getMultipleTokensMarketData(assets); } - - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - return this.#snapAdapter.getHistoricalPrice(from, to); - } } + +export { SnapsAssetsMigrationStage }; diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts index 0590aa2c..011fd657 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -1,3 +1,4 @@ +import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; import { KeyringEvent } from '@metamask/keyring-api'; import type { AccountAssetListUpdatedEvent, @@ -61,6 +62,7 @@ import { createPrefixedLogger } from '../../../utils/logger'; import type { ILogger } from '../../../utils/logger'; import type { State, UnencryptedStateValue } from '../../state/State'; import type { AssetsRepository } from '../AssetsRepository'; +import { isSnapOwnedAsset } from '../snapOwnedAssets'; import type { InLockPeriodCaipAssetType, NativeCaipAssetType, @@ -113,6 +115,10 @@ export class SnapAssetsAdapter { readonly #snapClient: SnapClient; + readonly #resolveMigrationStage: ( + chainId: string, + ) => Promise; + readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; spotPrices: number; @@ -128,6 +134,7 @@ export class SnapAssetsAdapter { priceApiClient, tokenApiClient, snapClient, + resolveMigrationStage, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -137,6 +144,9 @@ export class SnapAssetsAdapter { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; + resolveMigrationStage: ( + chainId: string, + ) => Promise; }) { this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; @@ -146,6 +156,7 @@ export class SnapAssetsAdapter { this.#priceApiClient = priceApiClient; this.#tokenApiClient = tokenApiClient; this.#snapClient = snapClient; + this.#resolveMigrationStage = resolveMigrationStage; const { cacheTtlsMilliseconds } = configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; @@ -155,27 +166,31 @@ export class SnapAssetsAdapter { return caipAssetId.includes('swift:0/iso4217:'); } - async getAccountAssets(accountId: string): Promise { - return this.#assetsRepository.getByAccountId(accountId); + async #resolveStage(chainId: string): Promise { + return this.#resolveMigrationStage(chainId); } - async getAccountAssetsByIDs( + async getAccountAssetByID( accountId: string, - assetTypes: string[], - ): Promise<(AssetEntity | null)[]> { - return this.#assetsRepository.getByAccountIdAndAssetTypes( + assetId: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( accountId, - assetTypes, + assetId, ); } - async getAccountAssetByID( + async getAccountAssetsByIDs( accountId: string, - assetType: string, - ): Promise { - return this.#assetsRepository.getByAccountIdAndAssetType( + assetIds: string[], + ): Promise> { + const assets = await this.#assetsRepository.getByAccountIdAndAssetTypes( accountId, - assetType, + assetIds, + ); + + return Object.fromEntries( + assetIds.map((assetId, index) => [assetId, assets[index] ?? null]), ); } @@ -208,6 +223,8 @@ export class SnapAssetsAdapter { scope, }); + const stage = await this.#resolveStage(scope); + const [ tronAccountInfoRequest, tronAccountResourcesRequest, @@ -226,18 +243,19 @@ export class SnapAssetsAdapter { ); } - const trc20BalancesFallback = isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; + const trc20BalancesFallback = + stage === SnapsAssetsMigrationStage.Off && isInactiveAccount + ? await this.#trongridApiClient + .getTrc20BalancesByAddress(scope, account.address) + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + 'Failed to fetch TRC20 balances for inactive account', + { error, account, scope }, + ); + return []; + }) + : []; const accountData = this.#buildAccountData({ tronAccountInfoRequest, @@ -246,7 +264,10 @@ export class SnapAssetsAdapter { stakingRewardsRequest, }); - const rawAssets = this.#extractAssets(account, scope, accountData); + const rawAssets = + stage === SnapsAssetsMigrationStage.Off + ? this.#extractAssets(account, scope, accountData) + : this.#extractSnapOwnedAssets(account, scope, accountData); const assetTypes = rawAssets.map((asset) => asset.assetType); const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); @@ -371,12 +392,22 @@ export class SnapAssetsAdapter { ): AssetEntity[] { return [ this.#extractNativeAsset(account, scope, data.nativeBalance), + ...this.#extractSnapOwnedAssets(account, scope, data), + ...this.#extractTrc10Assets(account, scope, data.trc10Balances), + ...this.#extractTrc20Assets(account, scope, data.trc20Balances), + ]; + } + + #extractSnapOwnedAssets( + account: KeyringAccount, + scope: Network, + data: NormalizedAccountData, + ): AssetEntity[] { + return [ ...this.#extractStakedNativeAssets(account, scope, data.stakedData), this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), this.#extractInLockPeriodAsset(account, scope, data.stakedData), this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), - ...this.#extractTrc10Assets(account, scope, data.trc10Balances), - ...this.#extractTrc20Assets(account, scope, data.trc20Balances), ...this.#extractBandwidth({ account, scope, @@ -1261,6 +1292,19 @@ export class SnapAssetsAdapter { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); + const stagesByNetwork = new Map(); + await Promise.all( + [...new Set(assets.map((asset) => asset.network))].map( + async (network) => { + stagesByNetwork.set(network, await this.#resolveStage(network)); + }, + ), + ); + + const shouldEmitAsset = (asset: AssetEntity): boolean => + (stagesByNetwork.get(asset.network) ?? SnapsAssetsMigrationStage.Off) === + SnapsAssetsMigrationStage.Off || isSnapOwnedAsset(asset.assetType); + const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; @@ -1268,6 +1312,14 @@ export class SnapAssetsAdapter { const isEssentialAsset = (asset: AssetEntity): boolean => ESSENTIAL_ASSETS.includes(asset.assetType); + const isProtectedAsset = (asset: AssetEntity): boolean => { + const stage = + stagesByNetwork.get(asset.network) ?? SnapsAssetsMigrationStage.Off; + return stage === SnapsAssetsMigrationStage.Off + ? isEssentialAsset(asset) + : isSnapOwnedAsset(asset.assetType); + }; + // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. const syncedNetworksByAccount = assets.reduce>>( @@ -1292,7 +1344,17 @@ export class SnapAssetsAdapter { if ( !syncedNetworks?.has(savedAsset.network) || - isEssentialAsset(savedAsset) + isProtectedAsset(savedAsset) + ) { + return false; + } + + const stage = + stagesByNetwork.get(savedAsset.network) ?? + SnapsAssetsMigrationStage.Off; + if ( + stage !== SnapsAssetsMigrationStage.Off && + !isSnapOwnedAsset(savedAsset.assetType) ) { return false; } @@ -1320,23 +1382,23 @@ export class SnapAssetsAdapter { // snapshot entirely // 2. fold in the current assets to report additions and explicit zero-balance // removals in the same event - const assetListUpdatedPayload = disappearedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); + const assetListUpdatedPayload = disappearedAssets + .filter(shouldEmitAsset) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); - for (const asset of assets) { + for (const asset of assets.filter(shouldEmitAsset)) { // Merge the current snapshot into the pre-seeded payload so each account // ends up with one consolidated added/removed diff. assetListUpdatedPayload[asset.keyringAccountId] = { @@ -1367,11 +1429,13 @@ export class SnapAssetsAdapter { // Emit synthetic zero-balance entries for disappeared assets so clients can // clear cached balances even when the backend omits zero-balance tokens // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); + const removedAssetsWithZeroBalance = disappearedAssets + .filter(shouldEmitAsset) + .map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; // Save assets using repository @@ -1379,9 +1443,10 @@ export class SnapAssetsAdapter { // Broadcast the current snapshot plus synthetic zero-balance removals so the // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = assetsToSave.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( + const balancesUpdatedPayload = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ].reduce( (acc, asset) => ({ ...acc, [asset.keyringAccountId]: { @@ -1442,19 +1507,26 @@ export class SnapAssetsAdapter { } as AssetEntity; } - async getByKeyringAccountId( + async getAccountAssetsByScope( + scope: Network, keyringAccountId: string, ): Promise { const savedAssets = await this.#assetsRepository.getByAccountId(keyringAccountId); - /** - * Ensure the special assets are always present whether they have been synced or not. - * These are assets that should be visible to the user even with zero balance. - */ + const visibleSavedAssets = savedAssets.filter( + (asset) => asset.network === scope, + ); + const missingEssentialAssets: AssetEntity[] = []; for (const essentialAssetId of ESSENTIAL_ASSETS) { + const { chainId } = parseCaipAssetType(essentialAssetId as CaipAssetType); + + if ((chainId as Network) !== scope) { + continue; + } + const savedAsset = savedAssets.find( (asset) => (asset.assetType as string) === essentialAssetId, ); @@ -1468,7 +1540,7 @@ export class SnapAssetsAdapter { } } - return [...savedAssets, ...missingEssentialAssets]; + return [...visibleSavedAssets, ...missingEssentialAssets]; } /** diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts new file mode 100644 index 00000000..852b6534 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts @@ -0,0 +1,84 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { CaipAssetType } from '@metamask/utils'; + +import { KnownCaip19Id, Network, TokenMetadata } from '../../constants'; +import { mapControllerAsset } from './mapControllerAsset'; + +describe('mapControllerAsset', () => { + const accountId = 'account-id'; + const knownAssetId = KnownCaip19Id.TrxMainnet; + const unknownAssetId = 'tron:728126428/trc20:unknown'; + + it('maps controller metadata when present', () => { + const asset = { + id: unknownAssetId, + chainId: Network.Mainnet, + balance: { amount: '1234567' }, + metadata: { + type: 'fungible', + symbol: 'TKN', + name: 'Token', + decimals: 6, + image: 'https://example.com/token.png', + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: unknownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TKN', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + iconUrl: 'https://example.com/token.png', + }); + }); + + it('falls back to TokenMetadata when controller metadata is missing', () => { + const asset = { + id: knownAssetId, + chainId: Network.Mainnet, + balance: { amount: '2000000' }, + metadata: { type: 'fungible', name: 'TRON' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: knownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: TokenMetadata[knownAssetId].symbol, + decimals: TokenMetadata[knownAssetId].decimals, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: TokenMetadata[knownAssetId].iconUrl, + }); + }); + + it('uses empty defaults when metadata is missing everywhere', () => { + const assetId = 'tron:728126428/trc20:missing' as CaipAssetType; + const asset = { + id: assetId, + chainId: Network.Mainnet, + balance: { amount: '42' }, + metadata: { type: 'fungible', name: 'Missing' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: assetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: '', + decimals: 0, + rawAmount: '42', + uiAmount: '42', + iconUrl: '', + }); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts new file mode 100644 index 00000000..6b09e488 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts @@ -0,0 +1,35 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { Network, TokenMetadata } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { toUiAmount } from '../../utils/conversion'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * @param accountId - Keyring account ID. + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export function mapControllerAsset( + accountId: string, + asset: Asset, +): AssetEntity { + const assetId = asset.id; + const knownMetadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const decimals = asset.metadata.decimals ?? knownMetadata?.decimals ?? 0; + const symbol = asset.metadata.symbol ?? knownMetadata?.symbol ?? ''; + const iconUrl = asset.metadata.image ?? knownMetadata?.iconUrl ?? ''; + const { amount } = asset.balance; + + return { + assetType: assetId, + keyringAccountId: accountId, + network: asset.chainId as Network, + symbol, + decimals, + rawAmount: amount, + uiAmount: toUiAmount(amount, decimals).toString(), + iconUrl, + } as AssetEntity; +} diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts index 7c826d76..62ac03e2 100644 --- a/packages/tron-wallet-snap/src/types/core-messenger.ts +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -5,6 +5,7 @@ import type { } from '@metamask/assets-controller'; import type { Messenger } from '@metamask/messenger'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; /** * Namespace for this Snap's Core messenger endowment. @@ -20,7 +21,18 @@ export type CoreMessengerActions = /** * Messenger type passed to `getMessenger` for Core controller actions. */ -export type CoreMessenger = Messenger< +export type CoreMessengerMessenger = Messenger< typeof TRON_WALLET_SNAP_MESSENGER_NAMESPACE, CoreMessengerActions >; + +/** + * Typed async messenger for Core controller actions available to this Snap via + * `endowment:messenger` / `getMessenger`. + */ +export type CoreMessenger = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; From bc142e4651a948fd94d2a87d7869abe0f8b4f7ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:29:43 +0000 Subject: [PATCH 4/8] fix(tron-wallet-snap): format manifest and sync CI shasum Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 7ce3e9f9..5edfb815 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": "aMPCWbmb3WNcpfaNwjufCq13IpXbUJK5c1pK+wIx0EE=", + "shasum": "7nTrlZaA1AUK1SwS66buFFwKxs4nZJG4xWzj49NPkIk=", "location": { "npm": { "filePath": "dist/bundle.js", From 0408ec98d8d71c7d2e3300c50627245d4a1398cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:35:05 +0000 Subject: [PATCH 5/8] fix(tron-wallet-snap): prettier AssetsService tests and changelog PR link Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- .../src/services/assets/AssetsService.test.ts | 96 +++++++++---------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 20c27e81..a7812003 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Core messenger plumbing (`getMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) -- Route fungible asset reads through Core AssetsController based on migration stage ([#96](https://github.com/MetaMask/internal-snaps/pull/96)) +- Route fungible asset reads through Core AssetsController based on migration stage ([#127](https://github.com/MetaMask/internal-snaps/pull/127)) ## [3.0.0] 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 5e185983..30e208b2 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3113,58 +3113,56 @@ describe('AssetsService', () => { }); it('getAccountAssetsByIDs routes all asset IDs through AssetsController when mode is controller', async () => { - await withAssetsService( - async ({ assetsService, mockCoreMessenger }) => { - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - }, + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, }, - }), - jest.fn(), - jest.fn().mockImplementation(async () => { - return { - [fungibleAssetId as Caip19AssetId]: buildControllerAsset( - fungibleAssetId, - '3000000', - { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }, - ), - [snapAssetId as Caip19AssetId]: buildControllerAsset( - snapAssetId, - '250', - { - symbol: 'ENERGY', - name: 'Energy', - decimals: 0, - }, - ), - }; - }), - ), - ); + }, + }), + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '3000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + [snapAssetId as Caip19AssetId]: buildControllerAsset( + snapAssetId, + '250', + { + symbol: 'ENERGY', + name: 'Energy', + decimals: 0, + }, + ), + }; + }), + ), + ); - const results = await assetsService.getAccountAssetsByIDs(accountId, [ - fungibleAssetId, - snapAssetId, - ]); + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + fungibleAssetId, + snapAssetId, + ]); - expect(results[0]?.rawAmount).toBe('3000000'); - expect(results[1]?.rawAmount).toBe('250'); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByIDs', - accountId, - [fungibleAssetId, snapAssetId], - ); - }, - ); + expect(results[0]?.rawAmount).toBe('3000000'); + expect(results[1]?.rawAmount).toBe('250'); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [fungibleAssetId, snapAssetId], + ); + }); }); it('getByKeyringAccountId reads from AssetsController when mode is controller', async () => { From 19fca1eb1ba63556a80678b47eb27b2a3b6b92de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:41:10 +0000 Subject: [PATCH 6/8] fix(tron-wallet-snap): declare @metamask/utils dependency Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/package.json | 3 +++ yarn.lock | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/tron-wallet-snap/package.json b/packages/tron-wallet-snap/package.json index cd070615..cc67ce03 100644 --- a/packages/tron-wallet-snap/package.json +++ b/packages/tron-wallet-snap/package.json @@ -47,6 +47,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/utils": "^11.9.0" + }, "devDependencies": { "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", diff --git a/yarn.lock b/yarn.lock index 3c395809..fd2359b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3792,6 +3792,7 @@ __metadata: "@metamask/snaps-jest": "npm:^10.2.0" "@metamask/snaps-sdk": "npm:^11.2.0" "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.9.0" "@types/lodash": "npm:^4.17.15" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.3.1" From ad12801c856126eeba1ca025927558113205d9f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:48:02 +0000 Subject: [PATCH 7/8] fix: prune obsolete eslint suppressions Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 102 +-------------------------------------- 1 file changed, 1 insertion(+), 101 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8cffb868..c9f04d7b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1510,15 +1510,7 @@ "count": 20 } }, - "packages/tron-wallet-snap/src/caching/InMemoryCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/caching/StateCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 1 } @@ -1533,20 +1525,7 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/clients/price-api/types.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 5 } @@ -1559,11 +1538,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts": { "require-atomic-updates": { "count": 1 @@ -1582,16 +1556,6 @@ "count": 42 } }, - "packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1610,11 +1574,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/accounts/AccountsService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1628,16 +1587,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/config/ConfigProvider.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts": { "no-restricted-syntax": { "count": 1 @@ -1651,11 +1600,6 @@ "count": 15 } }, - "packages/tron-wallet-snap/src/services/send/SendService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -1666,11 +1610,6 @@ "count": 7 } }, - "packages/tron-wallet-snap/src/services/staking/StakingService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/state/State.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -1679,11 +1618,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts": { "no-restricted-syntax": { "count": 5 @@ -1700,14 +1634,6 @@ }, "@typescript-eslint/no-explicit-any": { "count": 7 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/wallet/WalletService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx": { @@ -1726,9 +1652,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 3 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx": { @@ -1739,14 +1662,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 14 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx": { @@ -1762,26 +1677,11 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts": { "n/no-process-env": { "count": 3 } }, - "packages/tron-wallet-snap/src/validation/keyring-structs.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/validation/transaction.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1792,4 +1692,4 @@ "count": 2 } } -} +} \ No newline at end of file From 703eabc6edcacdf84a2e729a6f36e0401f8a1d31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:54:16 +0000 Subject: [PATCH 8/8] fix: prettier-format eslint-suppressions.json Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 2 +- packages/tron-wallet-snap/snap.manifest.json | 2 +- packages/tron-wallet-snap/src/context.ts | 9 +- .../src/handlers/keyring/keyring.test.ts | 4 +- .../src/handlers/keyring/keyring.ts | 4 +- .../src/services/assets/AssetsService.test.ts | 280 ++++-------------- .../src/services/assets/AssetsService.ts | 181 ++++------- .../assets/adapters/CoreAssetsAdapter.ts | 95 ++++++ .../assets/adapters/SnapAssetsAdapter.ts | 208 ++++++------- .../src/services/assets/mapControllerAsset.ts | 9 +- .../src/types/core-messenger.ts | 10 +- 11 files changed, 325 insertions(+), 479 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c9f04d7b..e41cd0cb 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1692,4 +1692,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 5edfb815..d5a50170 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": "7nTrlZaA1AUK1SwS66buFFwKxs4nZJG4xWzj49NPkIk=", + "shasum": "k5lq+GDbWWSEZxifSQullQs4m3RpswLU82p0Pdnm4Co=", "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 0a983ed3..7f6c1af0 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -41,7 +41,7 @@ import { TransactionsService } from './services/transactions/TransactionsService import { WalletService } from './services/wallet/WalletService'; import type { CoreMessenger, - CoreMessengerMessenger, + CoreMessengerClient, } from './types/core-messenger'; import logger, { noOpLogger } from './utils/logger'; @@ -99,7 +99,7 @@ const tokenApiClient = new TokenApiClient(configProvider); /** * Core controllers plumbing */ -const coreMessenger = getMessenger(); +const coreMessenger = getMessenger(); const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, }); @@ -123,8 +123,9 @@ const assetsService = new AssetsService({ priceApiClient, tokenApiClient, snapClient, - coreMessenger, + configProvider, assetsProvider, + remoteFeatureFlagsProvider, }); const transactionsService = new TransactionsService({ @@ -265,7 +266,7 @@ export type SnapExecutionContext = { /** * Core messenger plumbing (routing wired in a follow-up PR). */ - coreMessenger: CoreMessenger; + coreMessenger: CoreMessengerClient; remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; assetsProvider: AssetsProvider; /** diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts index 47fd5452..de310fec 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -85,7 +85,7 @@ describe('KeyringHandler', () => { }), } as unknown as jest.Mocked; mockAssetsService = { - getByKeyringAccountId: jest.fn().mockResolvedValue([]), + getAccountAssets: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; mockTransactionsService = { checkAddressActivity: jest.fn(), @@ -649,7 +649,7 @@ describe('KeyringHandler', () => { const result = await keyringHandler.getAccountAssets(mockAccount.id); expect(result).toStrictEqual([]); - expect(mockAssetsService.getByKeyringAccountId).toHaveBeenCalledWith( + expect(mockAssetsService.getAccountAssets).toHaveBeenCalledWith( mockAccount.id, ); }); diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 2c9b0b43..4a1fdc02 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -191,7 +191,7 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.info('Listing account assets', { accountId }); const assetEntities = - await this.#assetsService.getByKeyringAccountId(accountId); + await this.#assetsService.getAccountAssets(accountId); const result = assetEntities .filter( (asset) => @@ -281,7 +281,7 @@ export class KeyringHandler implements KeyringSnapRpc { await this.#getAccountOrThrow(accountId); const assetsList = - await this.#assetsService.getByKeyringAccountId(accountId); + await this.#assetsService.getAccountAssets(accountId); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) 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 30e208b2..c1fc3211 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -6,7 +6,7 @@ import { import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { AssetsProvider } from '@metamask/snap-networks-utils'; +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'; @@ -23,6 +23,7 @@ import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; +import type { ConfigProvider } from '../config'; /** * Subset of State methods. @@ -33,23 +34,6 @@ type MockState = { setKeyWith: jest.Mock; }; -jest.mock('../../context', () => ({ - configProvider: { - get() { - return { - priceApi: { - cacheTtlsMilliseconds: { - fiatExchangeRates: 3600000, - spotPrices: 3600000, - historicalPrices: 3600000, - }, - }, - activeNetworks: [], - }; - }, - }, -})); - jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); @@ -362,6 +346,22 @@ async function withAssetsService( 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 assetsService = new AssetsService({ logger: mockLogger, @@ -372,8 +372,9 @@ async function withAssetsService( priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, - coreMessenger: mockCoreMessenger, + configProvider: mockConfigProvider, assetsProvider, + remoteFeatureFlagsProvider, }); return await testFunction({ @@ -449,7 +450,7 @@ describe('AssetsService', () => { ); }); - it('skips TRC20 fallback and returns protocol assets only when mode is controller', async () => { + it('still fetches all assets including TRC20 fallback when mode is controller', async () => { await withAssetsService( async ({ assetsService, @@ -466,6 +467,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, @@ -474,18 +478,12 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalled(); expect( - assets.every((asset: AssetEntity) => + assets.some((asset: AssetEntity) => SNAP_OWNED_ASSETS.includes(asset.assetType), ), ).toBe(true); - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(false); }, ); }); @@ -507,16 +505,15 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, ); - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); - const bandwidthAsset = assets.find( (asset: AssetEntity) => asset.assetType === KnownCaip19Id.BandwidthMainnet, @@ -548,6 +545,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, @@ -556,7 +556,7 @@ describe('AssetsService', () => { expect(assets.length).toBeGreaterThan(0); expect( - assets.every((asset: AssetEntity) => + assets.some((asset: AssetEntity) => SNAP_OWNED_ASSETS.includes(asset.assetType), ), ).toBe(true); @@ -586,6 +586,9 @@ describe('AssetsService', () => { NetLimit: 0, EnergyLimit: 0, }); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, @@ -594,9 +597,9 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalled(); expect( - assets.every((asset: AssetEntity) => + assets.some((asset: AssetEntity) => SNAP_OWNED_ASSETS.includes(asset.assetType), ), ).toBe(true); @@ -643,7 +646,7 @@ describe('AssetsService', () => { (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, ), - ).toBe(false); + ).toBe(true); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -2890,7 +2893,6 @@ describe('AssetsService', () => { describe('assets migration mode', () => { const accountId = mockAccount.id; const fungibleAssetId = KnownCaip19Id.TrxMainnet; - const snapAssetId = KnownCaip19Id.EnergyMainnet; it('fetchAssetsAndBalancesForAccount returns fungibles when mode is snap', async () => { await withAssetsService( @@ -2925,7 +2927,7 @@ describe('AssetsService', () => { ); }); - it('fetchAssetsAndBalancesForAccount returns protocol assets only when mode is controller', async () => { + it('fetchAssetsAndBalancesForAccount still syncs all assets when mode is controller', async () => { await withAssetsService( async ({ assetsService, @@ -2953,7 +2955,7 @@ describe('AssetsService', () => { ); expect( - assets.every((asset: AssetEntity) => + assets.some((asset: AssetEntity) => SNAP_OWNED_ASSETS.includes(asset.assetType), ), ).toBe(true); @@ -2961,39 +2963,7 @@ describe('AssetsService', () => { assets.some( (asset: AssetEntity) => asset.assetType === fungibleAssetId, ), - ).toBe(false); - }, - ); - }); - - it('routes snap-owned reads through the repository', async () => { - await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { - const snapAsset: AssetEntity = { - assetType: snapAssetId, - keyringAccountId: accountId, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100', - uiAmount: '100', - iconUrl: '', - }; - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - snapAsset, - ); - - const asset = await assetsService.getAccountAssetByID( - accountId, - snapAssetId, - ); - - expect(asset).toStrictEqual(snapAsset); - expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( - 'AssetsController:getAccountAssetByID', - expect.anything(), - expect.anything(), - ); + ).toBe(true); }, ); }); @@ -3081,91 +3051,7 @@ describe('AssetsService', () => { }); }); - it('getAccountAssetsByIDs batches snap-owned reads without calling AssetsController', async () => { - await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { - const snapAsset: AssetEntity = { - assetType: snapAssetId, - keyringAccountId: accountId, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100', - uiAmount: '100', - iconUrl: '', - }; - mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ - snapAsset, - ]); - - const results = await assetsService.getAccountAssetsByIDs(accountId, [ - snapAssetId, - ]); - - expect(results[0]).toStrictEqual(snapAsset); - expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByIDs', - expect.anything(), - expect.anything(), - ); - }, - ); - }); - - it('getAccountAssetsByIDs routes all asset IDs through AssetsController when mode is controller', async () => { - await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - }, - }, - }), - jest.fn(), - jest.fn().mockImplementation(async () => { - return { - [fungibleAssetId as Caip19AssetId]: buildControllerAsset( - fungibleAssetId, - '3000000', - { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }, - ), - [snapAssetId as Caip19AssetId]: buildControllerAsset( - snapAssetId, - '250', - { - symbol: 'ENERGY', - name: 'Energy', - decimals: 0, - }, - ), - }; - }), - ), - ); - - const results = await assetsService.getAccountAssetsByIDs(accountId, [ - fungibleAssetId, - snapAssetId, - ]); - - expect(results[0]?.rawAmount).toBe('3000000'); - expect(results[1]?.rawAmount).toBe('250'); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByIDs', - accountId, - [fungibleAssetId, snapAssetId], - ); - }); - }); - - it('getByKeyringAccountId reads from AssetsController when mode is controller', async () => { + it('getAccountAssets reads from AssetsController when mode is controller', async () => { await withAssetsService( async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { setMigrationStage( @@ -3185,85 +3071,41 @@ describe('AssetsService', () => { jest.fn(), jest.fn(), jest.fn().mockResolvedValue({ - [snapAssetId as Caip19AssetId]: buildControllerAsset( - snapAssetId, - '100', + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '2000000', { - symbol: 'ENERGY', - name: 'Energy', - decimals: 0, + symbol: 'TRX', + name: 'TRON', + decimals: 6, }, ), }), ), ); - const assets = await assetsService.getByKeyringAccountId(accountId); + const assets = await assetsService.getAccountAssets(accountId); expect(mockCoreMessenger.call).toHaveBeenCalledWith( 'AssetsController:getAccountAssetsByScope', accountId, Network.Mainnet, ); - expect( - assets.some( - (asset: AssetEntity) => asset.assetType === snapAssetId, - ), - ).toBe(true); + 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(false); - }, - ); - }); - - it('saveMany emits only snap-owned assets when mode is controller', async () => { - await withAssetsService( - async ({ assetsService, mockState, setMigrationStage }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); - mockState.getKey.mockResolvedValue({}); - - const assets: AssetEntity[] = [ - { - assetType: fungibleAssetId, - keyringAccountId: accountId, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: snapAssetId, - keyringAccountId: accountId, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100', - uiAmount: '100', - iconUrl: '', - }, - ]; - - await assetsService.saveMany(assets); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [accountId]: { - added: [snapAssetId], - removed: [], - }, - }, - }, - ); + ).toBe(true); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 3039fa84..e5df1f9e 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,20 +1,17 @@ import { SNAPS_ASSETS_MIGRATION_FLAG_KEYS, SnapsAssetsMigrationStage, - getSnapsAssetsMigrationNamespace, parseSnapsAssetsMigrationStage, } from '@metamask/assets-controller'; -import type { Caip19AssetId } from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; -import type { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; -import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -23,25 +20,19 @@ import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; -import type { CoreMessengerCaller } from '../../types/core-messenger'; import type { ILogger } from '../../utils/logger'; +import type { ConfigProvider } from '../config'; import type { State, UnencryptedStateValue } from '../state/State'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; +import { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; -import { mapControllerAsset } from './mapControllerAsset'; - -/** - * Assets migration stage used when no remote feature flag is set for the chain. - * Change this value to test Stage 0 / 1 / 2 locally. - */ -const ASSETS_MIGRATION_STAGE = SnapsAssetsMigrationStage.Off; export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; - readonly #assetsProvider: AssetsProvider; + readonly #coreAdapter: CoreAssetsAdapter; - readonly #coreMessenger: CoreMessengerCaller; + readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; @@ -54,8 +45,9 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, - coreMessenger, + configProvider, assetsProvider, + remoteFeatureFlagsProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -65,11 +57,11 @@ export class AssetsService { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; - coreMessenger: CoreMessengerCaller; + configProvider: ConfigProvider; assetsProvider: AssetsProvider; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; }) { - this.#coreMessenger = coreMessenger; - this.#assetsProvider = assetsProvider; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; this.#snapAdapter = new SnapAssetsAdapter({ logger, @@ -80,38 +72,20 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, - resolveMigrationStage: ( - chainId: string, - ): Promise => - this.#resolveMigrationStage(chainId), + configProvider, + }); + this.#coreAdapter = new CoreAssetsAdapter({ + logger, + assetsProvider, }); this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } - async #resolveMigrationStage( - chainId: string, - ): Promise { - const { remoteFeatureFlags } = await this.#coreMessenger.call( - 'RemoteFeatureFlagController:getState', + async #getAssetsMigrationStage(): Promise { + const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron, ); - - const namespace = getSnapsAssetsMigrationNamespace(chainId as CaipChainId); - - if (namespace) { - const flagKey = SNAPS_ASSETS_MIGRATION_FLAG_KEYS[namespace]; - - if (Object.hasOwn(remoteFeatureFlags, flagKey)) { - const remoteStage = parseSnapsAssetsMigrationStage( - remoteFeatureFlags[flagKey] as Json | undefined, - ); - - if (remoteStage !== undefined) { - return remoteStage; - } - } - } - - return ASSETS_MIGRATION_STAGE; + return parseSnapsAssetsMigrationStage(flagValue); } static isFiat(caipAssetId: CaipAssetType): boolean { @@ -122,27 +96,17 @@ export class AssetsService { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } - async getAccountAssetByID( + async getAccountAssetsByScope( + scope: Network, accountId: string, - assetId: string, - ): Promise { - const { chainId } = parseCaipAssetType(assetId as CaipAssetType); - const stage = await this.#resolveMigrationStage(chainId); - - if (stage === SnapsAssetsMigrationStage.Off) { - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); - } - - const asset = await this.#assetsProvider.getAccountAssetByID( - accountId, - assetId as Caip19AssetId, - ); + ): Promise { + const migrationStage = await this.#getAssetsMigrationStage(); - if (!asset) { - return null; + if (migrationStage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); } - return mapControllerAsset(accountId, asset); + return this.#coreAdapter.getAccountAssetsByScope(scope, accountId); } async getAccountAssetsByIDs( @@ -153,53 +117,26 @@ export class AssetsService { return []; } - const { chainId } = parseCaipAssetType(assetIds[0] as CaipAssetType); - const stage = await this.#resolveMigrationStage(chainId); - - if (stage === SnapsAssetsMigrationStage.Off) { - const results = await this.#snapAdapter.getAccountAssetsByIDs( - accountId, - assetIds, - ); + const migrationStage = await this.#getAssetsMigrationStage(); - return assetIds.map((assetId) => results[assetId] ?? null); + if (migrationStage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } - const controllerAssets = await this.#assetsProvider.getAccountAssetsByIDs( - accountId, - assetIds as Caip19AssetId[], - ); - - return assetIds.map((assetId) => { - const controllerAsset = controllerAssets[assetId as Caip19AssetId]; - return controllerAsset - ? mapControllerAsset(accountId, controllerAsset) - : null; - }); + return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); } - async getAccountAssetsByScope( - scope: Network, + async getAccountAssetByID( accountId: string, - ): Promise { - const stage = await this.#resolveMigrationStage(scope); + assetId: string, + ): Promise { + const migrationStage = await this.#getAssetsMigrationStage(); - if (stage === SnapsAssetsMigrationStage.Off) { - return this.#snapAdapter.getAccountAssetsByScope(scope, accountId); + if (migrationStage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } - const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( - scope, - accountId, - ); - - return Object.values(controllerAssets).map((asset) => - mapControllerAsset(accountId, asset), - ); - } - - async getByKeyringAccountId(accountId: string): Promise { - return this.getAccountAssetsByScope(Network.Mainnet, accountId); + return this.#coreAdapter.getAccountAssetByID(accountId, assetId); } async fetchAssetsAndBalancesForAccount( @@ -209,6 +146,12 @@ export class AssetsService { return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + return this.#snapAdapter.getAssetsMetadata(assetTypes); + } + async saveMany(assets: AssetEntity[]): Promise { return this.#snapAdapter.saveMany(assets); } @@ -217,15 +160,14 @@ export class AssetsService { return this.#snapAdapter.getAll(); } - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - return this.#snapAdapter.getHistoricalPrice(from, to); + async getAccountAssets(accountId: string): Promise { + const migrationStage = await this.#getAssetsMigrationStage(); + + if (migrationStage === SnapsAssetsMigrationStage.Off) { + return this.#snapAdapter.getAccountAssets(accountId); + } + + return this.#coreAdapter.getAccountAssets(accountId); } async getMultipleTokenConversions( @@ -236,12 +178,6 @@ export class AssetsService { return this.#snapAdapter.getMultipleTokenConversions(conversions); } - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - return this.#snapAdapter.getAssetsMetadata(assetTypes); - } - async getMultipleTokensMarketData( assets: { asset: CaipAssetType; @@ -252,6 +188,15 @@ export class AssetsService { > { return this.#snapAdapter.getMultipleTokensMarketData(assets); } -} -export { SnapsAssetsMigrationStage }; + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + return this.#snapAdapter.getHistoricalPrice(from, to); + } +} diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts new file mode 100644 index 00000000..0cfb5acd --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -0,0 +1,95 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; + +import { Network } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import { mapControllerAsset } from '../mapControllerAsset'; + +/** + * Uses the AssetsController for all asset reads. + */ +export class CoreAssetsAdapter { + readonly #logger: ILogger; + + readonly #assetsProvider: AssetsProvider; + + constructor({ + logger, + assetsProvider, + }: { + logger: ILogger; + assetsProvider: AssetsProvider; + }) { + this.#logger = createPrefixedLogger(logger, '[CoreAssetsAdapter]'); + this.#assetsProvider = assetsProvider; + } + + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + this.#logger.info('Getting account asset by ID', { accountId, assetId }); + const asset = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise<(AssetEntity | null)[]> { + this.#logger.info('Getting account assets by IDs', { accountId, assetIds }); + const assets = await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + return assetIds.map((assetId) => { + const asset = assets[assetId as Caip19AssetId]; + return asset ? mapControllerAsset(accountId, asset) : null; + }); + } + + async getAccountAssetsByScope( + scope: Network, + keyringAccountId: string, + ): Promise { + this.#logger.info('Getting account assets by scope', { scope, keyringAccountId }); + const controllerAssets = + await this.#assetsProvider.getAccountAssetsByScope( + scope, + keyringAccountId, + ); + + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(keyringAccountId, asset), + ); + } + + async getAccountAssets(accountId: string): Promise { + this.#logger.info('Getting account assets', { accountId }); + const [ + mainnetAssets, + nileAssets, + shastaAssets, + ] = await Promise.all([ + this.#assetsProvider.getAccountAssetsByScope(Network.Mainnet, accountId), + this.#assetsProvider.getAccountAssetsByScope(Network.Nile, accountId), + this.#assetsProvider.getAccountAssetsByScope(Network.Shasta, accountId), + ]) + + const allUnmappedAssets = [...Object.values(mainnetAssets), ...Object.values(nileAssets), ...Object.values(shastaAssets)]; + const allAssets = allUnmappedAssets.map((asset) => mapControllerAsset(accountId, asset)); + + return allAssets; + } +} diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts index 011fd657..06310f74 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -1,4 +1,3 @@ -import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; import { KeyringEvent } from '@metamask/keyring-api'; import type { AccountAssetListUpdatedEvent, @@ -55,14 +54,13 @@ import { TRX_STAKED_FOR_ENERGY_METADATA, TRX_STAKING_REWARDS_METADATA, } from '../../../constants'; -import { configProvider } from '../../../context'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; import { createPrefixedLogger } from '../../../utils/logger'; import type { ILogger } from '../../../utils/logger'; +import type { ConfigProvider } from '../../config'; import type { State, UnencryptedStateValue } from '../../state/State'; import type { AssetsRepository } from '../AssetsRepository'; -import { isSnapOwnedAsset } from '../snapOwnedAssets'; import type { InLockPeriodCaipAssetType, NativeCaipAssetType, @@ -115,9 +113,7 @@ export class SnapAssetsAdapter { readonly #snapClient: SnapClient; - readonly #resolveMigrationStage: ( - chainId: string, - ) => Promise; + readonly #configProvider: ConfigProvider; readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; @@ -134,7 +130,7 @@ export class SnapAssetsAdapter { priceApiClient, tokenApiClient, snapClient, - resolveMigrationStage, + configProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -144,9 +140,7 @@ export class SnapAssetsAdapter { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; - resolveMigrationStage: ( - chainId: string, - ) => Promise; + configProvider: ConfigProvider; }) { this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; @@ -156,9 +150,9 @@ export class SnapAssetsAdapter { this.#priceApiClient = priceApiClient; this.#tokenApiClient = tokenApiClient; this.#snapClient = snapClient; - this.#resolveMigrationStage = resolveMigrationStage; + this.#configProvider = configProvider; - const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + const { cacheTtlsMilliseconds } = this.#configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } @@ -166,31 +160,23 @@ export class SnapAssetsAdapter { return caipAssetId.includes('swift:0/iso4217:'); } - async #resolveStage(chainId: string): Promise { - return this.#resolveMigrationStage(chainId); - } - - async getAccountAssetByID( + async getAccountAssetsByIDs( accountId: string, - assetId: string, - ): Promise { - return this.#assetsRepository.getByAccountIdAndAssetType( + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + return this.#assetsRepository.getByAccountIdAndAssetTypes( accountId, - assetId, + assetTypes, ); } - async getAccountAssetsByIDs( + async getAccountAssetByID( accountId: string, - assetIds: string[], - ): Promise> { - const assets = await this.#assetsRepository.getByAccountIdAndAssetTypes( + assetType: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( accountId, - assetIds, - ); - - return Object.fromEntries( - assetIds.map((assetId, index) => [assetId, assets[index] ?? null]), + assetType, ); } @@ -223,8 +209,6 @@ export class SnapAssetsAdapter { scope, }); - const stage = await this.#resolveStage(scope); - const [ tronAccountInfoRequest, tronAccountResourcesRequest, @@ -243,19 +227,18 @@ export class SnapAssetsAdapter { ); } - const trc20BalancesFallback = - stage === SnapsAssetsMigrationStage.Off && isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; + const trc20BalancesFallback = isInactiveAccount + ? await this.#trongridApiClient + .getTrc20BalancesByAddress(scope, account.address) + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + 'Failed to fetch TRC20 balances for inactive account', + { error, account, scope }, + ); + return []; + }) + : []; const accountData = this.#buildAccountData({ tronAccountInfoRequest, @@ -264,10 +247,7 @@ export class SnapAssetsAdapter { stakingRewardsRequest, }); - const rawAssets = - stage === SnapsAssetsMigrationStage.Off - ? this.#extractAssets(account, scope, accountData) - : this.#extractSnapOwnedAssets(account, scope, accountData); + const rawAssets = this.#extractAssets(account, scope, accountData); const assetTypes = rawAssets.map((asset) => asset.assetType); const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); @@ -392,22 +372,12 @@ export class SnapAssetsAdapter { ): AssetEntity[] { return [ this.#extractNativeAsset(account, scope, data.nativeBalance), - ...this.#extractSnapOwnedAssets(account, scope, data), - ...this.#extractTrc10Assets(account, scope, data.trc10Balances), - ...this.#extractTrc20Assets(account, scope, data.trc20Balances), - ]; - } - - #extractSnapOwnedAssets( - account: KeyringAccount, - scope: Network, - data: NormalizedAccountData, - ): AssetEntity[] { - return [ ...this.#extractStakedNativeAssets(account, scope, data.stakedData), this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), this.#extractInLockPeriodAsset(account, scope, data.stakedData), this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), + ...this.#extractTrc10Assets(account, scope, data.trc10Balances), + ...this.#extractTrc20Assets(account, scope, data.trc20Balances), ...this.#extractBandwidth({ account, scope, @@ -1292,19 +1262,6 @@ export class SnapAssetsAdapter { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); - const stagesByNetwork = new Map(); - await Promise.all( - [...new Set(assets.map((asset) => asset.network))].map( - async (network) => { - stagesByNetwork.set(network, await this.#resolveStage(network)); - }, - ), - ); - - const shouldEmitAsset = (asset: AssetEntity): boolean => - (stagesByNetwork.get(asset.network) ?? SnapsAssetsMigrationStage.Off) === - SnapsAssetsMigrationStage.Off || isSnapOwnedAsset(asset.assetType); - const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; @@ -1312,14 +1269,6 @@ export class SnapAssetsAdapter { const isEssentialAsset = (asset: AssetEntity): boolean => ESSENTIAL_ASSETS.includes(asset.assetType); - const isProtectedAsset = (asset: AssetEntity): boolean => { - const stage = - stagesByNetwork.get(asset.network) ?? SnapsAssetsMigrationStage.Off; - return stage === SnapsAssetsMigrationStage.Off - ? isEssentialAsset(asset) - : isSnapOwnedAsset(asset.assetType); - }; - // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. const syncedNetworksByAccount = assets.reduce>>( @@ -1344,17 +1293,7 @@ export class SnapAssetsAdapter { if ( !syncedNetworks?.has(savedAsset.network) || - isProtectedAsset(savedAsset) - ) { - return false; - } - - const stage = - stagesByNetwork.get(savedAsset.network) ?? - SnapsAssetsMigrationStage.Off; - if ( - stage !== SnapsAssetsMigrationStage.Off && - !isSnapOwnedAsset(savedAsset.assetType) + isEssentialAsset(savedAsset) ) { return false; } @@ -1382,23 +1321,23 @@ export class SnapAssetsAdapter { // snapshot entirely // 2. fold in the current assets to report additions and explicit zero-balance // removals in the same event - const assetListUpdatedPayload = disappearedAssets - .filter(shouldEmitAsset) - .reduce( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); + const assetListUpdatedPayload = disappearedAssets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); - for (const asset of assets.filter(shouldEmitAsset)) { + for (const asset of assets) { // Merge the current snapshot into the pre-seeded payload so each account // ends up with one consolidated added/removed diff. assetListUpdatedPayload[asset.keyringAccountId] = { @@ -1429,13 +1368,11 @@ export class SnapAssetsAdapter { // Emit synthetic zero-balance entries for disappeared assets so clients can // clear cached balances even when the backend omits zero-balance tokens // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets - .filter(shouldEmitAsset) - .map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); + const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; // Save assets using repository @@ -1443,10 +1380,9 @@ export class SnapAssetsAdapter { // Broadcast the current snapshot plus synthetic zero-balance removals so the // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = [ - ...assets.filter(shouldEmitAsset), - ...removedAssetsWithZeroBalance, - ].reduce( + const balancesUpdatedPayload = assetsToSave.reduce< + AccountBalancesUpdatedEvent['params']['balances'] + >( (acc, asset) => ({ ...acc, [asset.keyringAccountId]: { @@ -1531,16 +1467,44 @@ export class SnapAssetsAdapter { (asset) => (asset.assetType as string) === essentialAssetId, ); + if (!savedAsset) { + missingEssentialAssets.push( + this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ), + ); + } + } + + return [...visibleSavedAssets, ...missingEssentialAssets]; + } + + async getAccountAssets(accountId: string): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(accountId); + + /** + * Ensure the special assets are always present whether they have been synced or not. + * These are assets that should be visible to the user even with zero balance. + */ + const missingEssentialAssets: AssetEntity[] = []; + + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); + if (!savedAsset) { const zeroBalanceAsset = this.#createZeroBalanceAsset( essentialAssetId as KnownCaip19Id, - keyringAccountId, + accountId, ); missingEssentialAssets.push(zeroBalanceAsset); } } - return [...visibleSavedAssets, ...missingEssentialAssets]; + return [...savedAssets, ...missingEssentialAssets]; } /** diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts index 6b09e488..fe15d57b 100644 --- a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts @@ -1,6 +1,6 @@ import type { Asset } from '@metamask/assets-controller'; -import { Network, TokenMetadata } from '../../constants'; +import { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import { toUiAmount } from '../../utils/conversion'; @@ -16,10 +16,9 @@ export function mapControllerAsset( asset: Asset, ): AssetEntity { const assetId = asset.id; - const knownMetadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; - const decimals = asset.metadata.decimals ?? knownMetadata?.decimals ?? 0; - const symbol = asset.metadata.symbol ?? knownMetadata?.symbol ?? ''; - const iconUrl = asset.metadata.image ?? knownMetadata?.iconUrl ?? ''; + const decimals = asset.metadata.decimals ?? 0; + const symbol = asset.metadata.symbol ?? ''; + const iconUrl = asset.metadata.image ?? ''; const { amount } = asset.balance; return { diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts index 62ac03e2..0f326c23 100644 --- a/packages/tron-wallet-snap/src/types/core-messenger.ts +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -21,18 +21,18 @@ export type CoreMessengerActions = /** * Messenger type passed to `getMessenger` for Core controller actions. */ -export type CoreMessengerMessenger = Messenger< +export type CoreMessenger = Messenger< typeof TRON_WALLET_SNAP_MESSENGER_NAMESPACE, CoreMessengerActions >; /** - * Typed async messenger for Core controller actions available to this Snap via - * `endowment:messenger` / `getMessenger`. + * Async messenger returned by `getMessenger` for Core controller actions + * available to this Snap via `endowment:messenger`. */ -export type CoreMessenger = AsyncMessenger; +export type CoreMessengerClient = AsyncMessenger; /** * Narrow dependency for services that only need to invoke Core actions. */ -export type CoreMessengerCaller = Pick; +export type CoreMessengerCaller = Pick;