From 27a97362f91a13d2b742350353f64155f53c14a7 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 3 Aug 2026 00:53:16 +0100 Subject: [PATCH 1/2] feat(tron-wallet-snap): remove assets migration feature-flag routing After rollout, always route fungible reads through AssetsController via AssetsProvider. Snap-owned protocol assets remain on the Snap adapter. Remove RemoteFeatureFlagController endowment and migration stage logic. --- packages/tron-wallet-snap/CHANGELOG.md | 4 + packages/tron-wallet-snap/package.json | 2 - packages/tron-wallet-snap/snap.manifest.json | 5 +- packages/tron-wallet-snap/src/context.ts | 1 - .../src/services/assets/AssetsService.test.ts | 434 +++--------------- .../src/services/assets/AssetsService.ts | 128 +----- .../assets/adapters/SnapAssetsAdapter.ts | 66 +-- .../src/types/core-messenger.ts | 1 - 8 files changed, 77 insertions(+), 564 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 5e96235c..c7bffe91 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wire Core messenger endowment and instantiate `RemoteFeatureFlagsProvider` and `AssetsProvider` from `@metamask/snap-networks-utils` v1.0.0 (plumbing only; no Core routing yet). - Route fungible asset reads through the shared `AssetsProvider` from `@metamask/snap-networks-utils` using account-scoped `AssetsController:getAccountAssetByID`, `AssetsController:getAccountAssetsByIDs`, and `AssetsController:getAccountAssetsByScope` actions based on migration stage (TRX, TRC10, TRC20). Protocol assets (energy, bandwidth, staking, lock/withdrawal, rewards) remain Snap-owned. Resolution order: remote feature flags → Off default. +### Removed + +- Assets migration feature-flag routing. Fungible reads (`getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`) now always use Core `AssetsController` via `AssetsProvider`; snap-owned protocol assets remain on the Snap adapter. Removed `RemoteFeatureFlagController:getState` messenger endowment. + ### Changed - Update `snap.manifest.json` bundle shasum ([#82](https://github.com/MetaMask/internal-snaps/pull/82)) diff --git a/packages/tron-wallet-snap/package.json b/packages/tron-wallet-snap/package.json index 75bec324..94e1120b 100644 --- a/packages/tron-wallet-snap/package.json +++ b/packages/tron-wallet-snap/package.json @@ -57,8 +57,6 @@ "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", "@metamask/messenger": "^2.0.0", - "@metamask/remote-feature-flag-controller": "4.2.2", - "@metamask/snap-networks-utils": "workspace:^", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", "@metamask/snaps-sdk": "^11.2.0", diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 08b37a0f..e1b81744 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": "mmM+5X+wVXS5Xhoa2fhXaeB0IKoqwRduZ7vY7iU4wFs=", + "shasum": "asiK6MnbNlIxxdMCvfOEHEp+28D1ZKGXL7Dg3t2V0Xs=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -53,8 +53,7 @@ "actions": [ "AssetsController:getAccountAssetByID", "AssetsController:getAccountAssetsByIDs", - "AssetsController:getAccountAssetsByScope", - "RemoteFeatureFlagController:getState" + "AssetsController:getAccountAssetsByScope" ] } }, diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index a4e3df64..660392dc 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -117,7 +117,6 @@ const assetsService = new AssetsService({ priceApiClient, tokenApiClient, snapClient, - 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 dd9bd357..0c3a87df 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,8 +1,4 @@ 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'; @@ -59,18 +55,13 @@ 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': @@ -83,23 +74,6 @@ function createMessengerCallMock( }; } -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, @@ -263,7 +237,6 @@ type WithAssetsServiceCallback = (payload: { mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; mockCoreMessenger: jest.Mocked; - setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; /** @@ -339,15 +312,9 @@ async function withAssetsService( 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, @@ -355,10 +322,6 @@ async function withAssetsService( ), }; - const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { - migrationStage = stage; - }; - const assetsProvider = new AssetsProvider({ messenger: mockCoreMessenger as never, }); @@ -372,7 +335,6 @@ async function withAssetsService( priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, - coreMessenger: mockCoreMessenger, assetsProvider, }); @@ -386,23 +348,19 @@ async function withAssetsService( mockTokenApiClient, mockSnapClient, mockCoreMessenger, - setMigrationStage, }); } describe('AssetsService', () => { describe('fetchAssetsAndBalancesForAccount', () => { describe('inactive account fallback', () => { - it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + it('does not fall back to TRC20 balance endpoint for inactive accounts', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, - setMigrationStage, }) => { - setMigrationStage(SnapsAssetsMigrationStage.Off); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -410,20 +368,6 @@ describe('AssetsService', () => { emptyAccountResources, ); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - 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, @@ -431,35 +375,29 @@ 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 trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); + ).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('skips TRC20 fallback and returns protocol assets only when mode is controller', async () => { + it('skips TRC20 fallback and returns protocol assets only for inactive accounts', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -496,11 +434,7 @@ describe('AssetsService', () => { assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -537,11 +471,7 @@ describe('AssetsService', () => { assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -572,11 +502,7 @@ describe('AssetsService', () => { assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), ); @@ -617,11 +543,7 @@ describe('AssetsService', () => { assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( createMockTronAccount({ address: mockAccount.address, @@ -1587,22 +1509,15 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); expect(await assetsService.getAll()).toStrictEqual(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, + expect.anything(), ); }); }); - it('updates stale non-essential assets balance to 0 if missed from the latest snapshot', async () => { + it('does not zero stale fungible assets when missed from the latest snapshot', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; @@ -1628,28 +1543,6 @@ describe('AssetsService', () => { iconUrl: '', }, ]; - const finalSavedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId as TokenCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; const updatedAssets: AssetEntity[] = [savedAssets[0] as AssetEntity]; @@ -1660,38 +1553,17 @@ describe('AssetsService', () => { await assetsService.saveMany(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - finalSavedAssets, + updatedAssets, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, + expect.anything(), ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [trc20AssetId]: { - unit: 'USDT', - amount: '0', - }, - }, - }, - }, + expect.anything(), ); }, ); @@ -2079,20 +1951,10 @@ describe('AssetsService', () => { expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, + expect.anything(), ); }, ); @@ -2360,7 +2222,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, ]), removed: [], @@ -2375,10 +2236,6 @@ describe('AssetsService', () => { { balances: { [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, [KnownCaip19Id.EnergyMainnet]: { unit: 'ENERGY', amount: '35000', @@ -2456,7 +2313,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.BandwidthMainnet, ]), removed: [], @@ -2471,10 +2327,6 @@ describe('AssetsService', () => { { balances: { [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, [KnownCaip19Id.BandwidthMainnet]: { unit: 'BANDWIDTH', amount: '4700', @@ -2547,20 +2399,10 @@ describe('AssetsService', () => { expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, + expect.anything(), ); }, ); @@ -2631,7 +2473,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, ]), removed: [], @@ -2708,7 +2549,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.BandwidthMainnet, ]), removed: [], @@ -2805,7 +2645,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, ]), @@ -2821,10 +2660,6 @@ describe('AssetsService', () => { { balances: { [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '2', - }, [KnownCaip19Id.EnergyMainnet]: { unit: 'ENERGY', amount: '45000', @@ -2887,55 +2722,18 @@ describe('AssetsService', () => { }); }); - describe('assets migration mode', () => { + describe('AssetsController routing', () => { 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 () => { + it('fetchAssetsAndBalancesForAccount returns protocol assets only', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - setMigrationStage, }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( createMockTronAccount({ address: mockAccount.address, @@ -2998,18 +2796,10 @@ describe('AssetsService', () => { ); }); - it('routes fungible reads through AssetsController when mode is controller', async () => { + it('routes fungible reads through AssetsController', 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', @@ -3040,14 +2830,6 @@ describe('AssetsService', () => { mockCoreMessenger.call.mockImplementation( createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - }, - }, - }), jest.fn(), jest.fn().mockImplementation(async () => { return { @@ -3127,14 +2909,6 @@ describe('AssetsService', () => { mockCoreMessenger.call.mockImplementation( createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - }, - }, - }), jest.fn(), jest.fn().mockImplementation(async () => { return { @@ -3168,12 +2942,9 @@ describe('AssetsService', () => { ); }); - it('getByKeyringAccountId excludes fungibles when mode is controller', async () => { + it('getByKeyringAccountId excludes fungibles', async () => { await withAssetsService( - async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { - setMigrationStage( - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, - ); + async ({ assetsService, mockAssetsRepository }) => { mockAssetsRepository.getByAccountId.mockResolvedValue([ { assetType: fungibleAssetId, @@ -3213,72 +2984,12 @@ describe('AssetsService', () => { ); }); - 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('saveMany emits only snap-owned assets', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + mockState.getKey.mockResolvedValue({}); - it('falls back to snap data when controller read fails in fallback mode', async () => { - await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: - SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, - }, - }, - }), - jest.fn().mockRejectedValue(new Error('controller unavailable')), - ), - ); - const snapAsset: AssetEntity = { + const assets: AssetEntity[] = [ + { assetType: fungibleAssetId, keyringAccountId: accountId, network: Network.Mainnet, @@ -3287,61 +2998,34 @@ describe('AssetsService', () => { rawAmount: '1000000', uiAmount: '1', iconUrl: '', - }; - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - snapAsset, - ); - - const asset = await assetsService.getAccountAssetByID( - accountId, - fungibleAssetId, - ); - - expect(asset).toStrictEqual(snapAsset); - }, - ); - }); - - 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, + }, + { + assetType: snapAssetId, keyringAccountId: accountId, network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', iconUrl: '', - }; - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - snapAsset, - ); - - const asset = await assetsService.getAccountAssetByID( - accountId, - fungibleAssetId, - ); + }, + ]; - expect(asset).toStrictEqual(snapAsset); - }, - ); + await assetsService.saveMany(assets); - restoreMigrationStageEnv(originalEnvironment, originalStage); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [accountId]: { + added: [snapAssetId], + removed: [], + }, + }, + }, + ); + }); }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index d6fcdaa6..b4612b3f 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,9 +1,3 @@ -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'; @@ -13,7 +7,7 @@ import type { FungibleAssetMarketData, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; -import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; @@ -23,7 +17,6 @@ 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 { State, UnencryptedStateValue } from '../state/State'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; @@ -31,19 +24,11 @@ import type { AssetsRepository } from './AssetsRepository'; import { mapControllerAsset } from './mapControllerAsset'; import { isSnapOwnedAsset } from './snapOwnedAssets'; -/** - * 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({ @@ -55,7 +40,6 @@ export class AssetsService { priceApiClient, tokenApiClient, snapClient, - coreMessenger, assetsProvider, }: { logger: ILogger; @@ -66,10 +50,8 @@ export class AssetsService { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; - coreMessenger: CoreMessengerCaller; assetsProvider: AssetsProvider; }) { - this.#coreMessenger = coreMessenger; this.#assetsProvider = assetsProvider; this.#snapAdapter = new SnapAssetsAdapter({ @@ -81,40 +63,10 @@ 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; - } - async #getProviderAccountAssetByID( accountId: string, assetId: string, @@ -183,21 +135,6 @@ export class AssetsService { return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } - const { chainId } = parseCaipAssetType(assetId as CaipAssetType); - const stage = await this.#resolveMigrationStage(chainId); - - if (stage === SnapsAssetsMigrationStage.Off) { - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); - } - - if (stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback) { - try { - return await this.#getProviderAccountAssetByID(accountId, assetId); - } catch { - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); - } - } - return this.#getProviderAccountAssetByID(accountId, assetId); } @@ -231,36 +168,10 @@ export class AssetsService { return result; } - const { chainId } = parseCaipAssetType(fungibleIds[0] as CaipAssetType); - const stage = await this.#resolveMigrationStage(chainId); - - let fungibleResults: Record; - - if (stage === SnapsAssetsMigrationStage.Off) { - fungibleResults = await this.#snapAdapter.getAccountAssetsByIDs( - accountId, - fungibleIds, - ); - } else if ( - stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback - ) { - try { - fungibleResults = await this.#getProviderAccountAssetsByIDs( - accountId, - fungibleIds, - ); - } catch { - fungibleResults = await this.#snapAdapter.getAccountAssetsByIDs( - accountId, - fungibleIds, - ); - } - } else { - fungibleResults = await this.#getProviderAccountAssetsByIDs( - accountId, - fungibleIds, - ); - } + const fungibleResults = await this.#getProviderAccountAssetsByIDs( + accountId, + fungibleIds, + ); fungibleIds.forEach((assetId, fungibleIndex) => { const resultIndex = fungibleIndices[fungibleIndex]; @@ -283,31 +194,11 @@ export class AssetsService { const snapOwnedAssets = snapAssets.filter((asset) => isSnapOwnedAsset(asset.assetType), ); - const stage = await this.#resolveMigrationStage(scope); - - if (stage === SnapsAssetsMigrationStage.Off) { - return snapAssets; - } - - if (stage === SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback) { - try { - const coreAssets = await this.#getProviderAccountAssetsByScope( - scope, - accountId, - ); - return [ - ...coreAssets.filter((asset) => !isSnapOwnedAsset(asset.assetType)), - ...snapOwnedAssets, - ]; - } catch { - return snapAssets; - } - } - const coreAssets = await this.#getProviderAccountAssetsByScope( scope, accountId, ); + return [ ...coreAssets.filter((asset) => !isSnapOwnedAsset(asset.assetType)), ...snapOwnedAssets, @@ -319,11 +210,6 @@ export class AssetsService { Network.Mainnet, accountId, ); - const stage = await this.#resolveMigrationStage(Network.Mainnet); - - if (stage === SnapsAssetsMigrationStage.Off) { - return assets; - } return assets.filter((asset) => isSnapOwnedAsset(asset.assetType)); } @@ -379,5 +265,3 @@ export class AssetsService { return this.#snapAdapter.getMultipleTokensMarketData(assets); } } - -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 011fd657..f7e9bfd4 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, @@ -115,10 +114,6 @@ export class SnapAssetsAdapter { readonly #snapClient: SnapClient; - readonly #resolveMigrationStage: ( - chainId: string, - ) => Promise; - readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; spotPrices: number; @@ -134,7 +129,6 @@ export class SnapAssetsAdapter { priceApiClient, tokenApiClient, snapClient, - resolveMigrationStage, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -144,9 +138,6 @@ export class SnapAssetsAdapter { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; - resolveMigrationStage: ( - chainId: string, - ) => Promise; }) { this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; @@ -156,7 +147,6 @@ export class SnapAssetsAdapter { this.#priceApiClient = priceApiClient; this.#tokenApiClient = tokenApiClient; this.#snapClient = snapClient; - this.#resolveMigrationStage = resolveMigrationStage; const { cacheTtlsMilliseconds } = configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; @@ -166,10 +156,6 @@ export class SnapAssetsAdapter { return caipAssetId.includes('swift:0/iso4217:'); } - async #resolveStage(chainId: string): Promise { - return this.#resolveMigrationStage(chainId); - } - async getAccountAssetByID( accountId: string, assetId: string, @@ -223,8 +209,6 @@ export class SnapAssetsAdapter { scope, }); - const stage = await this.#resolveStage(scope); - const [ tronAccountInfoRequest, tronAccountResourcesRequest, @@ -243,31 +227,14 @@ 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 accountData = this.#buildAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback, + trc20BalancesFallback: [], stakingRewardsRequest, }); - const rawAssets = - stage === SnapsAssetsMigrationStage.Off - ? this.#extractAssets(account, scope, accountData) - : this.#extractSnapOwnedAssets(account, scope, accountData); + const rawAssets = this.#extractSnapOwnedAssets(account, scope, accountData); const assetTypes = rawAssets.map((asset) => asset.assetType); const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); @@ -1292,18 +1259,8 @@ 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); + isSnapOwnedAsset(asset.assetType); const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; @@ -1312,13 +1269,8 @@ 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); - }; + const isProtectedAsset = (asset: AssetEntity): boolean => + isSnapOwnedAsset(asset.assetType); // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. @@ -1349,13 +1301,7 @@ export class SnapAssetsAdapter { return false; } - const stage = - stagesByNetwork.get(savedAsset.network) ?? - SnapsAssetsMigrationStage.Off; - if ( - stage !== SnapsAssetsMigrationStage.Off && - !isSnapOwnedAsset(savedAsset.assetType) - ) { + if (!isSnapOwnedAsset(savedAsset.assetType)) { return false; } diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts index 54be9a9b..f13df8b4 100644 --- a/packages/tron-wallet-snap/src/types/core-messenger.ts +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -4,7 +4,6 @@ import type { AssetsControllerGetAccountAssetsByScopeAction, } 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'; /** From 03454c3a50cd000ff16e1d3c0b02ad931b943448 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 13:01:13 +0000 Subject: [PATCH 2/2] refactor(tron-wallet-snap): aggressive assets cleanup with Core AssetsProvider routing - Slim SnapAssetsAdapter to snap-owned fetch/save/read only (fetchSnapOwnedAssetsForAccount) - Move handler logic (metadata, conversions, market data, historical prices) into AssetsService - Add syncSnapOwnedAssets for cron sync; AccountsService.synchronizeAssets delegates to it - Keep PR97 fungible read routing via AssetsProvider for getAccountAssetByID/ByIDs/ByScope - Remove public saveMany, getAll, fetchAssetsAndBalancesForAccount, hasChanged from AssetsService - Drop unused state from AssetsService constructor in context.ts - Port and adapt unit tests; update eslint suppressions and changelog Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 10 + packages/tron-wallet-snap/CHANGELOG.md | 9 +- packages/tron-wallet-snap/snap.manifest.json | 2 +- packages/tron-wallet-snap/src/context.ts | 1 - .../services/accounts/AccountsService.test.ts | 59 +- .../src/services/accounts/AccountsService.ts | 19 +- .../src/services/assets/AssetsService.test.ts | 2072 +++++------------ .../src/services/assets/AssetsService.ts | 853 ++++++- .../assets/adapters/SnapAssetsAdapter.ts | 1261 +--------- 9 files changed, 1454 insertions(+), 2832 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 300550b0..176edbf2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -269,6 +269,16 @@ "count": 2 } }, + "packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts": { + "import-x/no-extraneous-dependencies": { + "count": 1 + } + }, + "packages/tron-wallet-snap/src/services/assets/AssetsService.ts": { + "import-x/no-extraneous-dependencies": { + "count": 2 + } + }, "packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 3 diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index c7bffe91..c8d1f0a9 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,14 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wire Core messenger endowment and instantiate `RemoteFeatureFlagsProvider` and `AssetsProvider` from `@metamask/snap-networks-utils` v1.0.0 (plumbing only; no Core routing yet). - Route fungible asset reads through the shared `AssetsProvider` from `@metamask/snap-networks-utils` using account-scoped `AssetsController:getAccountAssetByID`, `AssetsController:getAccountAssetsByIDs`, and `AssetsController:getAccountAssetsByScope` actions based on migration stage (TRX, TRC10, TRC20). Protocol assets (energy, bandwidth, staking, lock/withdrawal, rewards) remain Snap-owned. Resolution order: remote feature flags → Off default. -### Removed - -- Assets migration feature-flag routing. Fungible reads (`getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`) now always use Core `AssetsController` via `AssetsProvider`; snap-owned protocol assets remain on the Snap adapter. Removed `RemoteFeatureFlagController:getState` messenger endowment. - ### Changed +- Move assets handler logic (metadata, conversions, market data, historical prices) into `AssetsService`; slim `SnapAssetsAdapter` to snap-owned fetch/save/read only. Cron asset sync uses `syncSnapOwnedAssets` for protocol assets. - Update `snap.manifest.json` bundle shasum ([#82](https://github.com/MetaMask/internal-snaps/pull/82)) +### Removed + +- Assets migration feature-flag routing. Fungible reads (`getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`) now always use Core `AssetsController` via `AssetsProvider`; snap-owned protocol assets remain on the Snap adapter. Removed `RemoteFeatureFlagController:getState` messenger endowment. + ## [2.0.0] ### Changed diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index e1b81744..b3528eca 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": "asiK6MnbNlIxxdMCvfOEHEp+28D1ZKGXL7Dg3t2V0Xs=", + "shasum": "ac/9ewd8ZZXTLl5K5ZMg5PSsUeITzUwYtvfHPNjKQu4=", "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 660392dc..b0c25170 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -110,7 +110,6 @@ const securityAlertsApiClient = new SecurityAlertsApiClient( // Business Services const assetsService = new AssetsService({ logger, - state, assetsRepository, trongridApiClient, tronHttpClient, diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts index e8bdd0a7..967b12cb 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -21,7 +21,6 @@ import { import type { SnapClient } from '../../clients/snap/SnapClient'; import { Network } from '../../constants'; -import type { NativeAsset } from '../../entities/assets'; import type { TronKeyringAccount } from '../../entities/keyring-account'; import type { ILogger } from '../../utils/logger'; import { mockLogger } from '../../utils/mockLogger'; @@ -119,9 +118,7 @@ type WithAccountsServiceCallback = (payload: { >; mockConfigProvider: jest.Mocked>; mockLogger: ILogger; - mockAssetsService: jest.Mocked< - Pick - >; + mockAssetsService: jest.Mocked>; mockSnapClient: jest.Mocked< Pick >; @@ -268,10 +265,9 @@ async function withAccountsService( }; const mockAssetsService: jest.Mocked< - Pick + Pick > = { - fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), - saveMany: jest.fn().mockResolvedValue(undefined), + syncSnapOwnedAssets: jest.fn().mockResolvedValue(undefined), }; const mockTransactionsService: jest.Mocked< @@ -1194,7 +1190,7 @@ describe('AccountsService', () => { }); describe('synchronizeAssets', () => { - it('calls fetch for each account and scope, then saveMany', async () => { + it('calls syncSnapOwnedAssets with accounts and active networks', async () => { const account: TronKeyringAccount = { id: 'sync-asset-id', address: 'TSyncAsset12345678901234567', @@ -1206,18 +1202,6 @@ describe('AccountsService', () => { derivationPath: "m/44'/195'/0'/0/0", index: 0, }; - const mockAssets: NativeAsset[] = [ - { - assetType: `${Network.Mainnet}/slip44:195`, - keyringAccountId: 'sync-asset-id', - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - ]; await withAccountsService( async ({ accountsService, mockConfigProvider, mockAssetsService }) => { @@ -1225,23 +1209,15 @@ describe('AccountsService', () => { ...MOCK_CONFIG, activeNetworks: [Network.Mainnet, Network.Shasta], }); - mockAssetsService.fetchAssetsAndBalancesForAccount.mockResolvedValue( - mockAssets, - ); await accountsService.synchronizeAssets([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Shasta, account); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith( - expect.arrayContaining(mockAssets), + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledTimes( + 1, + ); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [Network.Mainnet, Network.Shasta], ); }, ); @@ -1266,10 +1242,10 @@ describe('AccountsService', () => { await accountsService.synchronizeAssets([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).not.toHaveBeenCalled(); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [], + ); }, ); }); @@ -1358,9 +1334,10 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [Network.Mainnet], + ); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledWith(Network.Mainnet, account); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 3d9bd1b7..711fe1d3 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -522,24 +522,7 @@ export class AccountsService { */ async synchronizeAssets(accounts: TronKeyringAccount[]): Promise { const scopes = this.#configProvider.get().activeNetworks; - const combinations = accounts.flatMap((account) => - scopes.map((scope) => ({ account, scope })), - ); - - const assetResponses = await Promise.allSettled( - combinations.map(async ({ account, scope }) => { - return this.#assetsService.fetchAssetsAndBalancesForAccount( - scope, - account, - ); - }), - ); - - const assets = assetResponses.flatMap((response) => - response.status === 'fulfilled' ? response.value : [], - ); - - await this.#assetsService.saveMany(assets); + await this.#assetsService.syncSnapOwnedAssets(accounts, scopes); } async synchronizeTransactions(accounts: TronKeyringAccount[]): Promise { 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 0c3a87df..7272e6b8 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -2,9 +2,7 @@ import type { Asset, Caip19AssetId } 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'; @@ -18,16 +16,7 @@ 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'; - -/** - * Subset of State methods. - */ -type MockState = { - getKey: jest.Mock; - setKey: jest.Mock; - setKeyWith: jest.Mock; -}; +import type { TokenCaipAssetType } from './types'; jest.mock('../../context', () => ({ configProvider: { @@ -100,22 +89,6 @@ function buildControllerAsset( } 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', @@ -137,6 +110,22 @@ 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. @@ -213,12 +202,12 @@ type WithAssetsServiceCallback = (payload: { Pick< AssetsRepository, | 'saveMany' + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' > >; - mockState: MockState; mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, @@ -253,6 +242,7 @@ async function withAssetsService( const mockAssetsRepository: jest.Mocked< Pick< AssetsRepository, + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' @@ -260,17 +250,12 @@ async function withAssetsService( > > = { saveMany: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue([]), getByAccountId: jest.fn().mockResolvedValue([]), getByAccountIdAndAssetType: jest.fn().mockResolvedValue(null), getByAccountIdAndAssetTypes: jest.fn().mockResolvedValue([]), }; - const mockState: MockState = { - getKey: jest.fn().mockResolvedValue({}), - setKey: jest.fn().mockResolvedValue(undefined), - setKeyWith: jest.fn().mockResolvedValue(undefined), - }; - const mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, @@ -313,23 +298,50 @@ async function withAssetsService( const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); const mockCoreMessenger: jest.Mocked = { - call: jest.fn().mockImplementation( - createMessengerCallMock( - mockGetAccountAssetByID, - mockGetAccountAssetsByIDs, - mockGetAccountAssetsByScope, + call: jest + .fn() + .mockImplementation( + createMessengerCallMock( + mockGetAccountAssetByID, + mockGetAccountAssetsByIDs, + mockGetAccountAssetsByScope, + ), ), - ), }; - const assetsProvider = new AssetsProvider({ - messenger: mockCoreMessenger as never, - }); + const assetsProvider = { + getAccountAssetByID: ( + accountId: string, + assetId: Caip19AssetId, + ): Promise => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetByID', + accountId, + assetId, + ) as Promise, + getAccountAssetsByIDs: ( + accountId: string, + assetIds: Caip19AssetId[], + ): Promise> => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetsByIDs', + accountId, + assetIds, + ) as Promise>, + getAccountAssetsByScope: ( + scope: string, + accountId: string, + ): Promise> => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetsByScope', + scope, + accountId, + ) as Promise>, + }; const assetsService = new AssetsService({ logger: mockLogger, assetsRepository: mockAssetsRepository, - state: mockState, trongridApiClient: mockTrongridApiClient, tronHttpClient: mockTronHttpClient, priceApiClient: mockPriceApiClient, @@ -341,7 +353,6 @@ async function withAssetsService( return await testFunction({ assetsService, mockAssetsRepository, - mockState, mockTrongridApiClient, mockTronHttpClient, mockPriceApiClient, @@ -351,87 +362,30 @@ async function withAssetsService( }); } +/** + * Runs syncSnapOwnedAssets and returns the assets passed to repository saveMany. + * + * @param assetsService - The assets service under test. + * @param mockAssetsRepository - The mocked assets repository. + * @returns The snap-owned assets persisted by the sync. + */ +async function syncAndGetSavedAssets( + assetsService: InstanceType, + mockAssetsRepository: jest.Mocked>, +): Promise { + await assetsService.syncSnapOwnedAssets([mockAccount], [Network.Mainnet]); + expect(mockAssetsRepository.saveMany).toHaveBeenCalled(); + return mockAssetsRepository.saveMany.mock.calls.at(-1)?.[0] as AssetEntity[]; +} + describe('AssetsService', () => { - describe('fetchAssetsAndBalancesForAccount', () => { + describe('syncSnapOwnedAssets', () => { describe('inactive account fallback', () => { - it('does not fall back to TRC20 balance endpoint for inactive accounts', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).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('skips TRC20 fallback and returns protocol assets only for inactive accounts', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).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('returns protocol resources when inactive account has empty resources', async () => { + it('returns zero snap-owned resources when account info fails (inactive account)', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -442,105 +396,38 @@ describe('AssetsService', () => { emptyAccountResources, ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( mockTrongridApiClient.getTrc20BalancesByAddress, ).not.toHaveBeenCalled(); - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, + const bandwidthAsset = findAsset( + assets, + KnownCaip19Id.BandwidthMainnet, ); + const energyAsset = findAsset(assets, KnownCaip19Id.EnergyMainnet); expect(bandwidthAsset).toBeDefined(); expect(energyAsset).toBeDefined(); - }, - ); - }); - - it('returns protocol assets when inactive account info fails', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(assets.length).toBeGreaterThan(0); expect( - assets.every((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), + assets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, ), - ).toBe(true); + ).toBe(false); }, ); }); }); describe('partial failure handling', () => { - it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - NetLimit: 0, - EnergyLimit: 0, - }); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); - expect( - assets.every((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('600'); - }, - ); - }); - it('continues with zero resources when only resources request fails', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -555,21 +442,20 @@ describe('AssetsService', () => { new Error('Resources endpoint unavailable'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, ), ).toBe(false); - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, + const bandwidthAsset = findAsset( + assets, + KnownCaip19Id.BandwidthMainnet, ); expect(bandwidthAsset).toBeDefined(); expect(bandwidthAsset?.rawAmount).toBe('0'); @@ -583,6 +469,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -591,9 +478,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -607,6 +494,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -617,9 +505,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 200 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -633,6 +521,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -643,9 +532,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -659,6 +548,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -673,9 +563,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -691,6 +581,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -699,9 +590,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -716,6 +607,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -726,9 +618,9 @@ describe('AssetsService', () => { getMockAccountResources({}), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -743,6 +635,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -753,9 +646,9 @@ describe('AssetsService', () => { getMockAccountResources({ NetLimit: 48 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -772,6 +665,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -783,9 +677,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -802,6 +696,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -816,9 +711,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -835,6 +730,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -852,9 +748,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -871,6 +767,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -887,9 +784,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -906,6 +803,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -925,9 +823,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -946,6 +844,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -957,9 +856,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -976,6 +875,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -993,9 +893,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1012,6 +912,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1029,9 +930,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1048,6 +949,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1070,9 +972,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1089,6 +991,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1108,9 +1011,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1127,20 +1030,18 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('account not found'), ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1159,6 +1060,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1167,9 +1069,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1183,6 +1085,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1193,9 +1096,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1209,6 +1112,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1219,9 +1123,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1235,6 +1139,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1245,9 +1150,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1263,6 +1168,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1271,9 +1177,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1287,6 +1193,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1297,9 +1204,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1315,6 +1222,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1324,9 +1232,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(0); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1341,6 +1249,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1350,9 +1259,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(5000000); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const stakingRewardsAsset = findAsset( @@ -1370,6 +1279,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1381,9 +1291,9 @@ describe('AssetsService', () => { new Error('API Error'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1394,772 +1304,71 @@ describe('AssetsService', () => { ); }); }); - }); - - describe('getHistoricalPrice', () => { - it('tracks historical price errors', async () => { - await withAssetsService( - async ({ assetsService, mockSnapClient, mockPriceApiClient }) => { - const error = new Error('Price error'); - - mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); - - await assetsService.getHistoricalPrice( - KnownCaip19Id.TrxMainnet, - 'tron:728126428/slip44:usd', - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - }); - - describe('saveMany', () => { - it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - mockState.getKey.mockResolvedValue(assets); + describe('persistence and events via sync', () => { + it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); - await assetsService.saveMany(assets); + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.BandwidthMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, }, }, - }, - ); - }, - ); - }); - - it('correctly updates non-essential assets with zero amounts', async () => { - await withAssetsService(async ({ assetsService, mockState }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', + ); }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: assets, - }); - - await assetsService.saveMany(assets); - - expect(await assetsService.getAll()).toStrictEqual(assets); - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - expect.anything(), ); }); - }); - it('does not zero stale fungible assets when missed from the latest snapshot', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId as TokenCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '1658250000', - uiAmount: '1658.25', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [savedAssets[0] as AssetEntity]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - expect.anything(), - ); - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - expect.anything(), - ); - }, - ); - }); - - it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.MaximumEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'MAX-ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.MaximumBandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'MAX-BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.MaximumEnergyMainnet, - KnownCaip19Id.MaximumBandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps staked assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-BANDWIDTH', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxStakedForBandwidthMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps ready for withdrawal assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxReadyForWithdrawalMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'trx-ready-for-withdrawal', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - describe('updating assets from 0 to >0', () => { - it('adds energy to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - expect.anything(), - ); - }, - ); - }); - - it('handles multiple assets updating from 0 to >0 simultaneously', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('handles staked assets updating from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '5000000', - uiAmount: '5', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '3000000', - uiAmount: '3', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - }); - - describe('updating assets going down', () => { - it('updates energy balance when it decreases but remains >0', async () => { + it('does not zero or remove TRC20 when missing from sync snapshot', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const savedAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, @@ -2172,360 +1381,167 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100000', - uiAmount: '100000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, + assetType: trc20AssetId, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', + symbol: 'USDT', decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '35000', - uiAmount: '35000', + rawAmount: '1658250000', + uiAmount: '1658.25', iconUrl: '', }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '35000', - }, - }, - }, - }, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + + const persistedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + persistedAssets.find((asset) => asset.assetType === trc20AssetId), + ).toBeUndefined(); + expect( + persistedAssets.find( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBeUndefined(); + expect( + persistedAssets.find( + (asset) => asset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).toBeDefined(); }, ); }); - it('updates bandwidth balance when it decreases but remains >0', async () => { + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '5000', - uiAmount: '5000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '4700', - uiAmount: '4700', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); - }, - ); - }); - - it('updates TRC20 token balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '50000000', - uiAmount: '50', - iconUrl: '', - }, - ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).not.toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - expect.anything(), - ); + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.MaximumEnergyMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.MaximumBandwidthMainnet, + ), + ).toBe(true); }, ); }); - it('keeps energy in the list when it drops to 0', async () => { + it('keeps staked assets even with zero amounts', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); - await assetsService.saveMany(updatedAssets); + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === + KnownCaip19Id.TrxStakedForBandwidthMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.TrxStakedForEnergyMainnet, + ), + ).toBe(true); + }, + ); + }); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + it('keeps ready for withdrawal assets even with zero amounts', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ), + ).toBe(true); }, ); }); - it('keeps bandwidth in the list when it drops to 0', async () => { + it('emits balance updates when snap-owned energy increases', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { const savedAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '300', - uiAmount: '300', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'BANDWIDTH', + symbol: 'ENERGY', decimals: 0, rawAmount: '0', uiAmount: '0', @@ -2533,26 +1549,30 @@ describe('AssetsService', () => { }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 50000 }), + ); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), - KeyringEvent.AccountAssetListUpdated, + KeyringEvent.AccountBalancesUpdated, { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, + balances: { + [mockAccount.id]: expect.objectContaining({ + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '50000', + }, + }), }, }, ); @@ -2560,98 +1580,41 @@ describe('AssetsService', () => { ); }); - it('handles both energy and bandwidth fluctuating in a transaction', async () => { + it('emits balance updates when snap-owned energy decreases but remains >0', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '80000', - uiAmount: '80000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '45000', - uiAmount: '45000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1235', - uiAmount: '1235', + rawAmount: '100000', + uiAmount: '100000', iconUrl: '', }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ + EnergyLimit: 100000, + EnergyUsed: 65000, + }), + ); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -2659,16 +1622,12 @@ describe('AssetsService', () => { KeyringEvent.AccountBalancesUpdated, { balances: { - [mockAccount.id]: { + [mockAccount.id]: expect.objectContaining({ [KnownCaip19Id.EnergyMainnet]: { unit: 'ENERGY', - amount: '45000', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', + amount: '35000', }, - }, + }), }, }, ); @@ -2727,10 +1686,11 @@ describe('AssetsService', () => { const fungibleAssetId = KnownCaip19Id.TrxMainnet; const snapAssetId = KnownCaip19Id.EnergyMainnet; - it('fetchAssetsAndBalancesForAccount returns protocol assets only', async () => { + it('syncSnapOwnedAssets returns protocol assets only', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -2745,11 +1705,14 @@ describe('AssetsService', () => { emptyAccountResources, ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + const assets = + mockAssetsRepository.saveMany.mock.calls.at(-1)?.[0] ?? []; + expect( assets.every((asset: AssetEntity) => SNAP_OWNED_ASSETS.includes(asset.assetType), @@ -2984,72 +1947,99 @@ describe('AssetsService', () => { ); }); - it('saveMany emits only snap-owned assets', async () => { - await withAssetsService(async ({ assetsService, mockState }) => { - mockState.getKey.mockResolvedValue({}); + it('syncSnapOwnedAssets emits only snap-owned assets', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockAssetsRepository.getAll.mockResolvedValue([]); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 100 }), + ); - 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.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + expect.objectContaining({ + assets: expect.objectContaining({ + [accountId]: expect.objectContaining({ + added: expect.arrayContaining([snapAssetId]), + }), + }), + }), + ); + }, + ); + }); + }); - await assetsService.saveMany(assets); + describe('getHistoricalPrice', () => { + it('tracks historical price errors', async () => { + await withAssetsService( + async ({ assetsService, mockSnapClient, mockPriceApiClient }) => { + const error = new Error('Price error'); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [accountId]: { - added: [snapAssetId], - removed: [], - }, - }, - }, - ); - }); + mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); + + await assetsService.getHistoricalPrice( + KnownCaip19Id.TrxMainnet, + 'tron:728126428/slip44:usd', + ); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); + }, + ); }); }); describe('facade delegation', () => { - it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { + it('routes fungible reads through AssetsProvider and keeps handler logic in AssetsService', async () => { await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { - const asset: AssetEntity = { - assetType: KnownCaip19Id.TrxMainnet, + async ({ + assetsService, + mockAssetsRepository, + mockCoreMessenger, + mockPriceApiClient, + }) => { + const snapAsset: AssetEntity = { + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, + symbol: 'ENERGY', + decimals: 0, rawAmount: '1', uiAmount: '1', - iconUrl: '', }; - mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ - asset, - ]); - mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( - MOCK_EXCHANGE_RATES, + mockAssetsRepository.getByAccountId.mockResolvedValue([snapAsset]); + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + jest.fn().mockResolvedValue( + buildControllerAsset(KnownCaip19Id.TrxMainnet, '1', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + ), + ), ); + mockPriceApiClient.getFiatExchangeRates.mockResolvedValue({ + usd: { value: 1 }, + }); mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( createSpotPrices({ [KnownCaip19Id.TrxMainnet]: { @@ -3061,19 +2051,41 @@ describe('AssetsService', () => { expect(AssetsService.isFiat('eip155:1/erc20:0x0')).toBe(false); 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.getAccountAssetsByIDs(mockAccount.id, []), - ).toStrictEqual([]); + await assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).toMatchObject({ + assetType: KnownCaip19Id.TrxMainnet, + rawAmount: '1', + }); expect( - await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]), - ).toBeDefined(); + await assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.EnergyMainnet, + ), + ).toStrictEqual(snapAsset); + const byKeyringAccountId = await assetsService.getByKeyringAccountId( + mockAccount.id, + ); + expect( + byKeyringAccountId.some( + (savedAsset) => + savedAsset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).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, + ); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index b4612b3f..3fb0c3b6 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -5,36 +5,79 @@ import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, + FungibleAssetMetadata, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { pick } from 'lodash'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; 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 { Network } from '../../constants'; +import { + BANDWIDTH_METADATA, + ENERGY_METADATA, + MAX_BANDWIDTH_METADATA, + MAX_ENERGY_METADATA, + TRX_IN_LOCK_PERIOD_METADATA, + TRX_METADATA, + TRX_READY_FOR_WITHDRAWAL_METADATA, + TRX_STAKED_FOR_BANDWIDTH_METADATA, + TRX_STAKED_FOR_ENERGY_METADATA, + TRX_STAKING_REWARDS_METADATA, +} from '../../constants'; +import { configProvider } from '../../context'; import type { AssetEntity } from '../../entities/assets'; +import { createPrefixedLogger } from '../../utils/logger'; 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'; import { isSnapOwnedAsset } from './snapOwnedAssets'; +import type { + InLockPeriodCaipAssetType, + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + ResourceCaipAssetType, + StakedCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from './types'; export class AssetsService { + readonly #logger: ILogger; + + readonly #priceApiClient: PriceApiClient; + + readonly #tokenApiClient: TokenApiClient; + + readonly #snapClient: SnapClient; + readonly #snapAdapter: SnapAssetsAdapter; readonly #assetsProvider: AssetsProvider; - readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; constructor({ logger, assetsRepository, - state, trongridApiClient, tronHttpClient, priceApiClient, @@ -44,7 +87,6 @@ export class AssetsService { }: { logger: ILogger; assetsRepository: AssetsRepository; - state: State; trongridApiClient: TrongridApiClient; tronHttpClient: TronHttpClient; priceApiClient: PriceApiClient; @@ -52,19 +94,20 @@ export class AssetsService { snapClient: SnapClient; assetsProvider: AssetsProvider; }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); + this.#priceApiClient = priceApiClient; + this.#tokenApiClient = tokenApiClient; + this.#snapClient = snapClient; this.#assetsProvider = assetsProvider; - this.#snapAdapter = new SnapAssetsAdapter({ logger, assetsRepository, - state, trongridApiClient, tronHttpClient, - priceApiClient, - tokenApiClient, - snapClient, }); - this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } async #getProviderAccountAssetByID( @@ -120,11 +163,7 @@ export class AssetsService { } static isFiat(caipAssetId: CaipAssetType): boolean { - return SnapAssetsAdapter.isFiat(caipAssetId); - } - - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - return SnapAssetsAdapter.hasChanged(asset, assetsLookup); + return caipAssetId.includes('swift:0/iso4217:'); } async getAccountAssetByID( @@ -214,44 +253,643 @@ export class AssetsService { return assets.filter((asset) => isSnapOwnedAsset(asset.assetType)); } - async fetchAssetsAndBalancesForAccount( - scope: Network, - account: KeyringAccount, - ): Promise { - return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); + async syncSnapOwnedAssets( + accounts: KeyringAccount[], + scopes: Network[], + ): Promise { + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); + const responses = await Promise.allSettled( + combinations.map(({ account, scope }) => + this.#snapAdapter.fetchSnapOwnedAssetsForAccount(scope, account), + ), + ); + const assets = responses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + await this.#snapAdapter.saveMany(assets); } - async saveMany(assets: AssetEntity[]): Promise { - return this.#snapAdapter.saveMany(assets); + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.info('Fetching metadata for assets', assetTypes); + + const { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + } = this.#splitAssetsByType(assetTypes); + + const nativeTokensMetadata = + this.#getNativeTokensMetadata(nativeAssetTypes); + const stakedTokensMetadata = this.#getStakedTokensMetadata( + stakedNativeAssetTypes, + ); + const readyForWithdrawalTokensMetadata = + this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); + const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( + inLockPeriodAssetTypes, + ); + const stakingRewardsMetadata = this.#getStakingRewardsMetadata( + stakingRewardsAssetTypes, + ); + const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); + const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( + maximunEnergyAssetTypes, + ); + const bandwidthTokensMetadata = + this.#getBandwidthMetadata(bandwidthAssetTypes); + const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( + maximunBandwidthAssetTypes, + ); + const tokensMetadata = await this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, + ]); + + const result = { + ...nativeTokensMetadata, + ...stakedTokensMetadata, + ...readyForWithdrawalTokensMetadata, + ...inLockPeriodTokensMetadata, + ...stakingRewardsMetadata, + ...energyTokensMetadata, + ...maximunEnergyTokensMetadata, + ...bandwidthTokensMetadata, + ...maximunBandwidthTokensMetadata, + ...tokensMetadata, + }; + + this.#logger.info('Resolved assets metadata', { assetTypes, result }); + + return result; } - async getAll(): Promise { - return this.#snapAdapter.getAll(); + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + stakedNativeAssetTypes: StakedCaipAssetType[]; + readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; + inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; + stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; + energyAssetTypes: ResourceCaipAssetType[]; + maximunEnergyAssetTypes: ResourceCaipAssetType[]; + bandwidthAssetTypes: ResourceCaipAssetType[]; + maximunBandwidthAssetTypes: ResourceCaipAssetType[]; + tokenTrc10AssetTypes: TokenCaipAssetType[]; + tokenTrc20AssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195'), + ) as NativeCaipAssetType[]; + const stakedNativeAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/slip44:195-staked-for-'), + ) as StakedCaipAssetType[]; + const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-ready-for-withdrawal'), + ) as ReadyForWithdrawalCaipAssetType[]; + const inLockPeriodAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-in-lock-period'), + ) as InLockPeriodCaipAssetType[]; + const stakingRewardsAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-staking-rewards'), + ) as StakingRewardsCaipAssetType[]; + const energyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:energy'), + ) as ResourceCaipAssetType[]; + const maximunEnergyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-energy'), + ) as ResourceCaipAssetType[]; + const bandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:bandwidth'), + ) as ResourceCaipAssetType[]; + const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-bandwidth'), + ) as ResourceCaipAssetType[]; + const tokenTrc10AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:'), + ) as TokenCaipAssetType[]; + const tokenTrc20AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc20:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc721:'), + ) as NftCaipAssetType[]; + + return { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + }; } - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + nativeTokensMetadata[assetType] = { + fungible: TRX_METADATA.fungible, + name: TRX_METADATA.name, + symbol: TRX_METADATA.symbol, + iconUrl: TRX_METADATA.iconUrl, + units: [ + { + decimals: TRX_METADATA.decimals, + symbol: TRX_METADATA.symbol, + name: TRX_METADATA.name, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + #getStakedTokensMetadata( + assetTypes: StakedCaipAssetType[], + ): Record { + // Can either be Staked for Bandwidth or Staked for Energy + const stakedTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + const isForEnergy = assetType.endsWith('staked-for-energy'); + + if (isForEnergy) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + }, + ], + }; + } + } + + return stakedTokensMetadata; + } + + #getReadyForWithdrawalTokensMetadata( + assetTypes: ReadyForWithdrawalCaipAssetType[], + ): Record { + const readyForWithdrawalTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + readyForWithdrawalTokensMetadata[assetType] = { + fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, + units: [ + { + decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + }, + ], + }; + } + + return readyForWithdrawalTokensMetadata; + } + + #getStakingRewardsMetadata( + assetTypes: StakingRewardsCaipAssetType[], + ): Record { + const stakingRewardsMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + stakingRewardsMetadata[assetType] = { + fungible: TRX_STAKING_REWARDS_METADATA.fungible, + name: TRX_STAKING_REWARDS_METADATA.name, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKING_REWARDS_METADATA.decimals, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + name: TRX_STAKING_REWARDS_METADATA.name, + }, + ], + }; + } + + return stakingRewardsMetadata; + } + + #getInLockPeriodMetadata( + assetTypes: InLockPeriodCaipAssetType[], + ): Record { + const inLockPeriodTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + inLockPeriodTokensMetadata[assetType] = { + fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, + units: [ + { + decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + }, + ], + }; + } + + return inLockPeriodTokensMetadata; + } + + #getBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const bandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + bandwidthTokensMetadata[assetType] = { + fungible: BANDWIDTH_METADATA.fungible, + name: BANDWIDTH_METADATA.name, + symbol: BANDWIDTH_METADATA.symbol, + iconUrl: BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: BANDWIDTH_METADATA.decimals, + symbol: BANDWIDTH_METADATA.symbol, + name: BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return bandwidthTokensMetadata; + } + + #getMaximunBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunBandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunBandwidthTokensMetadata[assetType] = { + fungible: MAX_BANDWIDTH_METADATA.fungible, + name: MAX_BANDWIDTH_METADATA.name, + symbol: MAX_BANDWIDTH_METADATA.symbol, + iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: MAX_BANDWIDTH_METADATA.decimals, + symbol: MAX_BANDWIDTH_METADATA.symbol, + name: MAX_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return maximunBandwidthTokensMetadata; + } + + #getEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const energyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + energyTokensMetadata[assetType] = { + fungible: ENERGY_METADATA.fungible, + name: ENERGY_METADATA.name, + symbol: ENERGY_METADATA.symbol, + iconUrl: ENERGY_METADATA.iconUrl, + units: [ + { + decimals: ENERGY_METADATA.decimals, + symbol: ENERGY_METADATA.symbol, + name: ENERGY_METADATA.name, + }, + ], + }; + } + + return energyTokensMetadata; + } + + #getMaximunEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunEnergyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunEnergyTokensMetadata[assetType] = { + fungible: MAX_ENERGY_METADATA.fungible, + name: MAX_ENERGY_METADATA.name, + symbol: MAX_ENERGY_METADATA.symbol, + iconUrl: MAX_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: MAX_ENERGY_METADATA.decimals, + symbol: MAX_ENERGY_METADATA.symbol, + name: MAX_ENERGY_METADATA.name, + }, + ], + }; + } + + return maximunEnergyTokensMetadata; + } + + async #getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + return this.#tokenApiClient.getTokensMetadata(assetTypes); + } + + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!AssetsService.isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; }> { - return this.#snapAdapter.getHistoricalPrice(from, to); + const cryptoAssets = allAssets.filter( + (asset) => !AssetsService.isFiat(asset), + ); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; } + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< Record> > { - return this.#snapAdapter.getMultipleTokenConversions(conversions); + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + result[from] ??= {}; + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (AssetsService.isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (AssetsService.isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; } - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - return this.#snapAdapter.getAssetsMetadata(assetTypes); + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ): Record => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; } async getMultipleTokensMarketData( @@ -262,6 +900,143 @@ export class AssetsService { ): Promise< Record> > { - return this.#snapAdapter.getMultipleTokensMarketData(assets); + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (AssetsService.isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + result[assetType] ??= {}; + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } + + /** + * Get historical prices for a token pair by calling the Price API. + * Similar to the Solana snap implementation. + * + * @param from - The asset to get historical prices for. + * @param to - The currency to convert prices to. + * @returns Historical price data with intervals. + */ + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; } } 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 f7e9bfd4..b273636f 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -5,86 +5,30 @@ import type { KeyringAccount, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { - AssetConversion, - AssetMetadata, - FungibleAssetMarketData, - FungibleAssetMetadata, - HistoricalPriceIntervals, -} from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; import type { CaipAssetType } from '@metamask/utils'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; -import { pick } from 'lodash'; +import { parseCaipAssetType } from '@metamask/utils'; -import type { PriceApiClient } from '../../../clients/price-api/PriceApiClient'; -import type { - FiatTicker, - SpotPrice, - SpotPrices, -} from '../../../clients/price-api/types'; -import { - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - VsCurrencyParamStruct, -} from '../../../clients/price-api/types'; -import type { SnapClient } from '../../../clients/snap/SnapClient'; -import type { TokenApiClient } from '../../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../../clients/tron-http'; import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; import type { RawTronUnfrozenV2, - Trc20Balance, TronAccount, } from '../../../clients/trongrid/types'; import type { KnownCaip19Id, Network } from '../../../constants'; -import { - BANDWIDTH_METADATA, - ENERGY_METADATA, - ESSENTIAL_ASSETS, - MAX_BANDWIDTH_METADATA, - MAX_ENERGY_METADATA, - Networks, - TokenMetadata, - TRX_IN_LOCK_PERIOD_METADATA, - TRX_METADATA, - TRX_READY_FOR_WITHDRAWAL_METADATA, - TRX_STAKED_FOR_BANDWIDTH_METADATA, - TRX_STAKED_FOR_ENERGY_METADATA, - TRX_STAKING_REWARDS_METADATA, -} from '../../../constants'; -import { configProvider } from '../../../context'; +import { ESSENTIAL_ASSETS, Networks, TokenMetadata } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; 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, - NftCaipAssetType, - ReadyForWithdrawalCaipAssetType, - ResourceCaipAssetType, - StakedCaipAssetType, - StakingRewardsCaipAssetType, - TokenCaipAssetType, -} from '../types'; /** - * Normalized account data structure that provides a consistent shape for both - * active and inactive accounts. This allows extraction functions to work - * without needing to know the account's activation state. + * Slim account data shape for snap-owned asset extraction. + * Provides a consistent shape for both active and inactive accounts. */ -type NormalizedAccountData = { - /** Native TRX balance in sun (0 for inactive accounts). */ - nativeBalance: number; - /** TRC10 token balances as `{ key: tokenId, value: balance }[]` (empty for inactive accounts). */ - trc10Balances: TronAccount['assetV2']; - /** TRC20 token balances from either account info or fallback endpoint. */ - trc20Balances: Trc20Balance[]; +type SnapOwnedAccountData = { /** Staking data including frozen balances and delegated resources. */ stakedData: { frozenV2: TronAccount['frozenV2']; @@ -102,109 +46,64 @@ export class SnapAssetsAdapter { readonly #assetsRepository: AssetsRepository; - readonly #state: State; - readonly #trongridApiClient: TrongridApiClient; readonly #tronHttpClient: TronHttpClient; - readonly #priceApiClient: PriceApiClient; - - readonly #tokenApiClient: TokenApiClient; - - readonly #snapClient: SnapClient; - - readonly cacheTtlsMilliseconds: { - fiatExchangeRates: number; - spotPrices: number; - historicalPrices: number; - }; - constructor({ logger, assetsRepository, - state, trongridApiClient, tronHttpClient, - priceApiClient, - tokenApiClient, - snapClient, }: { logger: ILogger; assetsRepository: AssetsRepository; - state: State; trongridApiClient: TrongridApiClient; tronHttpClient: TronHttpClient; - priceApiClient: PriceApiClient; - tokenApiClient: TokenApiClient; - snapClient: SnapClient; }) { this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; - this.#state = state; this.#trongridApiClient = trongridApiClient; this.#tronHttpClient = tronHttpClient; - this.#priceApiClient = priceApiClient; - this.#tokenApiClient = tokenApiClient; - this.#snapClient = snapClient; - - const { cacheTtlsMilliseconds } = configProvider.get().priceApi; - this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } - static isFiat(caipAssetId: CaipAssetType): boolean { - return caipAssetId.includes('swift:0/iso4217:'); - } - - 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, ); } /** - * Fetches all assets and balances for an account. + * Fetches snap-owned assets and balances for an account. * * Data Sources: - * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) + * - `getAccountInfoByAddress`: Staking data (active accounts only) * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) - * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) - * - * Logic Flow: - * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) - * 2. Normalize data into consistent shape via `#buildAccountData` - * 3. Extract all assets via `#extractAssets` - * 4. Fetch metadata and prices in parallel - * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` - * 6. Filter spam tokens via `#filterTokensWithoutPriceData` + * - `getReward`: Unclaimed staking rewards * * @param scope - The network to query. * @param account - The keyring account. - * @returns Promise - Array of assets with balances. + * @returns Promise - Array of snap-owned assets with balances. */ - async fetchAssetsAndBalancesForAccount( + async fetchSnapOwnedAssetsForAccount( scope: Network, account: KeyringAccount, ): Promise { - this.#logger.info('Fetching assets and balances by account', { + this.#logger.info('Fetching snap-owned assets and balances by account', { account, scope, }); @@ -219,90 +118,40 @@ export class SnapAssetsAdapter { this.#tronHttpClient.getReward(scope, account.address), ]); - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - if (isInactiveAccount) { + if (tronAccountInfoRequest.status === 'rejected') { this.#logger.info( 'Account info request failed, treating as inactive account', { account, scope }, ); } - const accountData = this.#buildAccountData({ + const accountData = this.#buildSnapOwnedAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback: [], stakingRewardsRequest, }); - const rawAssets = this.#extractSnapOwnedAssets(account, scope, accountData); - - const assetTypes = rawAssets.map((asset) => asset.assetType); - const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); - - const [assetsMetadata, spotPrices] = await Promise.all([ - this.getAssetsMetadata(assetTypes), - this.#priceApiClient - .getMultipleSpotPrices(priceableAssetTypes, 'usd') - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - return {}; - }), - ]); - - const enrichedAssets = this.#enrichAssetsWithMetadata( - rawAssets, - assetsMetadata, - ); - return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); - } - - /** - * Filters out spam tokens (those without price data). - * Essential assets are always kept. Tokens need price data to be included. - * - * @param assets - The assets to filter. - * @param spotPrices - Pre-fetched USD prices for assets. - * @returns The filtered assets. - */ - #filterTokensWithoutPriceData( - assets: AssetEntity[], - spotPrices: SpotPrices | Record, - ): AssetEntity[] { - const filtered = assets.filter((asset) => { - // Essential assets (TRX, staked, energy, bandwidth) are always kept - if (ESSENTIAL_ASSETS.includes(asset.assetType)) { - return true; - } - // Tokens: keep only if they have price data - const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; - return typeof spotPrice?.price === 'number'; - }); - - return filtered; + return this.#extractSnapOwnedAssets(account, scope, accountData); } /** - * Normalizes raw API responses into a consistent shape for both active and inactive accounts. - * This allows extraction functions to work without needing to know the account's activation state. + * Normalizes raw API responses into a slim shape for snap-owned asset extraction. * * @param params - The raw API responses to normalize. * @param params.tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. * @param params.tronAccountResourcesRequest - The settled promise result from getAccountResources. - * @param params.trc20BalancesFallback - TRC20 balances from fallback endpoint (empty for active accounts). * @param params.stakingRewardsRequest - The settled promise result from getReward. - * @returns NormalizedAccountData - Consistent data shape for extraction. + * @returns SnapOwnedAccountData - Consistent data shape for snap-owned extraction. */ - #buildAccountData({ + #buildSnapOwnedAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback, stakingRewardsRequest, }: { tronAccountInfoRequest: PromiseSettledResult; tronAccountResourcesRequest: PromiseSettledResult; - trc20BalancesFallback: Trc20Balance[]; stakingRewardsRequest: PromiseSettledResult; - }): NormalizedAccountData { + }): SnapOwnedAccountData { const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; const resources = tronAccountResourcesRequest.status === 'fulfilled' @@ -315,9 +164,6 @@ export class SnapAssetsAdapter { if (isInactiveAccount) { return { - nativeBalance: 0, - trc10Balances: [], - trc20Balances: trc20BalancesFallback, stakedData: { frozenV2: [], unfrozenV2: [], @@ -330,9 +176,6 @@ export class SnapAssetsAdapter { const tronAccountInfo = tronAccountInfoRequest.value; return { - nativeBalance: tronAccountInfo.balance ?? 0, - trc10Balances: tronAccountInfo.assetV2 ?? [], - trc20Balances: tronAccountInfo.trc20 ?? [], stakedData: { frozenV2: tronAccountInfo.frozenV2 ?? [], unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], @@ -343,32 +186,10 @@ export class SnapAssetsAdapter { }; } - /** - * Extracts all assets from normalized account data. - * Coordinates calls to individual extraction functions. - * - * @param account - The keyring account. - * @param scope - The network. - * @param data - Normalized account data. - * @returns AssetEntity[] - Array of all extracted assets. - */ - #extractAssets( - account: KeyringAccount, - scope: Network, - data: NormalizedAccountData, - ): 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, + data: SnapOwnedAccountData, ): AssetEntity[] { return [ ...this.#extractStakedNativeAssets(account, scope, data.stakedData), @@ -388,100 +209,6 @@ export class SnapAssetsAdapter { ]; } - /** - * Returns the asset types that can be priced (native, TRC10, TRC20). - * Staked, energy, and bandwidth assets have non-compliant CAIP IDs that would fail the Price API. - * - * @param assets - Array of assets to filter. - * @returns CaipAssetType[] - Array of priceable asset types. - */ - #getPriceableAssetTypes(assets: AssetEntity[]): CaipAssetType[] { - return assets - .filter( - (asset) => - asset.assetType.includes('/slip44:') || - asset.assetType.includes('/trc10:') || - asset.assetType.includes('/trc20:'), - ) - .map((asset) => asset.assetType); - } - - /** - * Enriches assets with metadata (symbol, decimals, iconUrl) and calculates uiAmount. - * - * @param assets - Raw assets to enrich. - * @param assetsMetadata - Metadata lookup by asset type. - * @returns AssetEntity[] - Enriched assets. - */ - #enrichAssetsWithMetadata( - assets: AssetEntity[], - assetsMetadata: Record, - ): AssetEntity[] { - return assets.map((asset) => { - const metadata = assetsMetadata[ - asset.assetType - ] as FungibleAssetMetadata | null; - - const { - symbol: initialSymbol, - decimals: initialDecimals = 0, - iconUrl: initialIconUrl, - } = asset; - let symbol = initialSymbol; - let decimals = initialDecimals; - let iconUrl = initialIconUrl; - - if (metadata?.fungible) { - const unit = metadata.units?.[0]; - if (unit) { - symbol = unit.symbol ?? metadata.symbol ?? symbol; - decimals = unit.decimals ?? decimals; - } else { - symbol = metadata?.symbol ?? symbol; - } - iconUrl = metadata.iconUrl ?? iconUrl; - } - - const uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); - - return { - ...asset, - symbol, - decimals, - uiAmount, - iconUrl, - }; - }); - } - - /** - * Extracts the native TRX asset from the balance. - * - * @param account - The keyring account. - * @param scope - The network. - * @param balance - The native balance in sun. - * @returns AssetEntity - The native TRX asset. - */ - #extractNativeAsset( - account: KeyringAccount, - scope: Network, - balance: number, - ): AssetEntity { - return { - assetType: Networks[scope].nativeToken.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].nativeToken.symbol, - decimals: Networks[scope].nativeToken.decimals, - rawAmount: balance.toString(), - uiAmount: toUiAmount( - balance, - Networks[scope].nativeToken.decimals, - ).toString(), - iconUrl: Networks[scope].nativeToken.iconUrl, - }; - } - /** * Extracts staked TRX assets (for bandwidth and energy). * @@ -493,7 +220,7 @@ export class SnapAssetsAdapter { #extractStakedNativeAssets( account: KeyringAccount, scope: Network, - stakedData: NormalizedAccountData['stakedData'], + stakedData: SnapOwnedAccountData['stakedData'], ): AssetEntity[] { const assets: AssetEntity[] = []; @@ -561,7 +288,7 @@ export class SnapAssetsAdapter { #extractReadyForWithdrawalAsset( account: KeyringAccount, scope: Network, - stakedData: NormalizedAccountData['stakedData'], + stakedData: SnapOwnedAccountData['stakedData'], ): AssetEntity { const currentTimestamp = Date.now(); let readyForWithdrawalAmount = 0; @@ -631,7 +358,7 @@ export class SnapAssetsAdapter { #extractInLockPeriodAsset( account: KeyringAccount, scope: Network, - stakedData: NormalizedAccountData['stakedData'], + stakedData: SnapOwnedAccountData['stakedData'], ): AssetEntity { const currentTimestamp = Date.now(); let inLockPeriodAmount = 0; @@ -761,490 +488,6 @@ export class SnapAssetsAdapter { ]; } - /** - * Extracts TRC10 assets from the balances array. - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. - * @returns AssetEntity[] - Array of TRC10 asset entities. - */ - #extractTrc10Assets( - account: KeyringAccount, - scope: Network, - trc10Balances: TronAccount['assetV2'], - ): AssetEntity[] { - return ( - trc10Balances?.flatMap((tokenObject) => { - // assetV2 has structure: { "key": "token_id", "value": "balance" } - return { - assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: tokenObject.value?.toString() ?? '0', - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }) ?? [] - ); - } - - /** - * Extracts TRC20 assets from a balances array. - * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). - * @returns AssetEntity[] - Array of TRC20 asset entities. - */ - #extractTrc20Assets( - account: KeyringAccount, - scope: Network, - trc20Balances: Trc20Balance[], - ): AssetEntity[] { - return trc20Balances.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }); - } - - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - this.#logger.info('Fetching metadata for assets', assetTypes); - - const { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - } = this.#splitAssetsByType(assetTypes); - - const nativeTokensMetadata = - this.#getNativeTokensMetadata(nativeAssetTypes); - const stakedTokensMetadata = this.#getStakedTokensMetadata( - stakedNativeAssetTypes, - ); - const readyForWithdrawalTokensMetadata = - this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); - const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( - inLockPeriodAssetTypes, - ); - const stakingRewardsMetadata = this.#getStakingRewardsMetadata( - stakingRewardsAssetTypes, - ); - const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); - const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( - maximunEnergyAssetTypes, - ); - const bandwidthTokensMetadata = - this.#getBandwidthMetadata(bandwidthAssetTypes); - const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( - maximunBandwidthAssetTypes, - ); - const tokensMetadata = await this.#getTokensMetadata([ - ...tokenTrc10AssetTypes, - ...tokenTrc20AssetTypes, - ]); - - const result = { - ...nativeTokensMetadata, - ...stakedTokensMetadata, - ...readyForWithdrawalTokensMetadata, - ...inLockPeriodTokensMetadata, - ...stakingRewardsMetadata, - ...energyTokensMetadata, - ...maximunEnergyTokensMetadata, - ...bandwidthTokensMetadata, - ...maximunBandwidthTokensMetadata, - ...tokensMetadata, - }; - - this.#logger.info('Resolved assets metadata', { assetTypes, result }); - - return result; - } - - #splitAssetsByType(assetTypes: CaipAssetType[]): { - nativeAssetTypes: NativeCaipAssetType[]; - stakedNativeAssetTypes: StakedCaipAssetType[]; - readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; - inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; - stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; - energyAssetTypes: ResourceCaipAssetType[]; - maximunEnergyAssetTypes: ResourceCaipAssetType[]; - bandwidthAssetTypes: ResourceCaipAssetType[]; - maximunBandwidthAssetTypes: ResourceCaipAssetType[]; - tokenTrc10AssetTypes: TokenCaipAssetType[]; - tokenTrc20AssetTypes: TokenCaipAssetType[]; - nftAssetTypes: NftCaipAssetType[]; - } { - const nativeAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195'), - ) as NativeCaipAssetType[]; - const stakedNativeAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/slip44:195-staked-for-'), - ) as StakedCaipAssetType[]; - const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-ready-for-withdrawal'), - ) as ReadyForWithdrawalCaipAssetType[]; - const inLockPeriodAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-in-lock-period'), - ) as InLockPeriodCaipAssetType[]; - const stakingRewardsAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-staking-rewards'), - ) as StakingRewardsCaipAssetType[]; - const energyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:energy'), - ) as ResourceCaipAssetType[]; - const maximunEnergyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-energy'), - ) as ResourceCaipAssetType[]; - const bandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:bandwidth'), - ) as ResourceCaipAssetType[]; - const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-bandwidth'), - ) as ResourceCaipAssetType[]; - const tokenTrc10AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc10:'), - ) as TokenCaipAssetType[]; - const tokenTrc20AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc20:'), - ) as TokenCaipAssetType[]; - const nftAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc721:'), - ) as NftCaipAssetType[]; - - return { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - nftAssetTypes, - }; - } - - #getNativeTokensMetadata( - assetTypes: NativeCaipAssetType[], - ): Record { - const nativeTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - nativeTokensMetadata[assetType] = { - fungible: TRX_METADATA.fungible, - name: TRX_METADATA.name, - symbol: TRX_METADATA.symbol, - iconUrl: TRX_METADATA.iconUrl, - units: [ - { - decimals: TRX_METADATA.decimals, - symbol: TRX_METADATA.symbol, - name: TRX_METADATA.name, - }, - ], - }; - } - - return nativeTokensMetadata; - } - - #getStakedTokensMetadata( - assetTypes: StakedCaipAssetType[], - ): Record { - // Can either be Staked for Bandwidth or Staked for Energy - const stakedTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); - - if (isForBandwdidth) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - const isForEnergy = assetType.endsWith('staked-for-energy'); - - if (isForEnergy) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - }, - ], - }; - } - } - - return stakedTokensMetadata; - } - - #getReadyForWithdrawalTokensMetadata( - assetTypes: ReadyForWithdrawalCaipAssetType[], - ): Record { - const readyForWithdrawalTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - readyForWithdrawalTokensMetadata[assetType] = { - fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, - units: [ - { - decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - }, - ], - }; - } - - return readyForWithdrawalTokensMetadata; - } - - #getStakingRewardsMetadata( - assetTypes: StakingRewardsCaipAssetType[], - ): Record { - const stakingRewardsMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - stakingRewardsMetadata[assetType] = { - fungible: TRX_STAKING_REWARDS_METADATA.fungible, - name: TRX_STAKING_REWARDS_METADATA.name, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKING_REWARDS_METADATA.decimals, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - name: TRX_STAKING_REWARDS_METADATA.name, - }, - ], - }; - } - - return stakingRewardsMetadata; - } - - #getInLockPeriodMetadata( - assetTypes: InLockPeriodCaipAssetType[], - ): Record { - const inLockPeriodTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - inLockPeriodTokensMetadata[assetType] = { - fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, - units: [ - { - decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - }, - ], - }; - } - - return inLockPeriodTokensMetadata; - } - - #getBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const bandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - bandwidthTokensMetadata[assetType] = { - fungible: BANDWIDTH_METADATA.fungible, - name: BANDWIDTH_METADATA.name, - symbol: BANDWIDTH_METADATA.symbol, - iconUrl: BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: BANDWIDTH_METADATA.decimals, - symbol: BANDWIDTH_METADATA.symbol, - name: BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return bandwidthTokensMetadata; - } - - #getMaximunBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunBandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunBandwidthTokensMetadata[assetType] = { - fungible: MAX_BANDWIDTH_METADATA.fungible, - name: MAX_BANDWIDTH_METADATA.name, - symbol: MAX_BANDWIDTH_METADATA.symbol, - iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: MAX_BANDWIDTH_METADATA.decimals, - symbol: MAX_BANDWIDTH_METADATA.symbol, - name: MAX_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return maximunBandwidthTokensMetadata; - } - - #getEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const energyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - energyTokensMetadata[assetType] = { - fungible: ENERGY_METADATA.fungible, - name: ENERGY_METADATA.name, - symbol: ENERGY_METADATA.symbol, - iconUrl: ENERGY_METADATA.iconUrl, - units: [ - { - decimals: ENERGY_METADATA.decimals, - symbol: ENERGY_METADATA.symbol, - name: ENERGY_METADATA.name, - }, - ], - }; - } - - return energyTokensMetadata; - } - - #getMaximunEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunEnergyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunEnergyTokensMetadata[assetType] = { - fungible: MAX_ENERGY_METADATA.fungible, - name: MAX_ENERGY_METADATA.name, - symbol: MAX_ENERGY_METADATA.symbol, - iconUrl: MAX_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: MAX_ENERGY_METADATA.decimals, - symbol: MAX_ENERGY_METADATA.symbol, - name: MAX_ENERGY_METADATA.name, - }, - ], - }; - } - - return maximunEnergyTokensMetadata; - } - - async #getTokensMetadata( - assetTypes: TokenCaipAssetType[], - ): Promise> { - return this.#tokenApiClient.getTokensMetadata(assetTypes); - } - - /** - * Checks if the asset has changed compared to passed assets lookup. - * - * @param asset - The asset to check. - * @param assetsLookup - The lookup table to check against. - * @returns True if the asset has changed, false otherwise. - */ - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - if (!savedAsset) { - return true; - } - - return savedAsset.rawAmount !== asset.rawAmount; - } - /** * Persist the latest fetched assets and emit the corresponding keyring events. * @@ -1265,12 +508,7 @@ export class SnapAssetsAdapter { const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; - const savedAssets = await this.getAll(); - const isEssentialAsset = (asset: AssetEntity): boolean => - ESSENTIAL_ASSETS.includes(asset.assetType); - - const isProtectedAsset = (asset: AssetEntity): boolean => - isSnapOwnedAsset(asset.assetType); + const savedAssets = await this.#assetsRepository.getAll(); // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. @@ -1287,17 +525,13 @@ export class SnapAssetsAdapter { assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), ); - // A saved asset is considered disappeared only if its network was part of - // this sync, it is not essential, and it is missing from the latest - // snapshot for that account. + // A saved snap-owned asset is considered disappeared only if its network was + // part of this sync and it is missing from the latest snapshot for that account. const disappearedAssets = savedAssets.filter((savedAsset) => { const syncedNetworks = syncedNetworksByAccount[savedAsset.keyringAccountId]; - if ( - !syncedNetworks?.has(savedAsset.network) || - isProtectedAsset(savedAsset) - ) { + if (!syncedNetworks?.has(savedAsset.network)) { return false; } @@ -1310,24 +544,14 @@ export class SnapAssetsAdapter { ); }); - // A token should be removed from the visible asset list only when the latest - // snapshot says its balance is zero. Essential assets stay visible even at - // zero because they are part of the permanent Tron account model. + // Snap-owned assets stay visible even at zero because they are part of the + // permanent Tron account model managed by the Snap. const shouldBeInRemovedList = (asset: AssetEntity): boolean => - hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list + hasZeroAmount(asset) && !isSnapOwnedAsset(asset.assetType); - // Assets are added to the visible list when they are non-zero and either: - // - we are doing a full non-incremental broadcast, or - // - they are brand new, or - // - they existed before with zero balance and now became non-zero. const shouldBeInAddedList = (asset: AssetEntity): boolean => !shouldBeInRemovedList(asset); - // Build the asset-list payload in two stages: - // 1. seed the removed list with assets that vanished from the latest - // 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( @@ -1345,8 +569,6 @@ export class SnapAssetsAdapter { ); 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] = { added: [ ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), @@ -1359,7 +581,6 @@ export class SnapAssetsAdapter { }; } - // If no assets were added or removed, don't emit the event. const isEmptyAccountAssetListUpdatedPayload = Object.values( assetListUpdatedPayload, ) @@ -1372,9 +593,6 @@ 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) => ({ @@ -1383,12 +601,12 @@ export class SnapAssetsAdapter { uiAmount: '0', })); - const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; - // Save assets using repository + const assetsToSave = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ]; await this.#assetsRepository.saveMany(assetsToSave); - // 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, @@ -1406,12 +624,10 @@ export class SnapAssetsAdapter { {}, ); - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .map((accountAssets) => Object.keys(accountAssets).length) .some((count) => count > 0); - // Only emit the event if some balance was changed. if (isSomeBalanceChanged) { await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { balances: balancesUpdatedPayload, @@ -1419,14 +635,6 @@ export class SnapAssetsAdapter { } } - async getAll(): Promise { - const assetsByAccount = - (await this.#state.getKey('assets')) ?? - {}; - - return Object.values(assetsByAccount).flat(); - } - /** * Creates an asset entity with zero balance from a known CAIP-19 asset ID. * Uses pre-calculated metadata from TokenMetadata. @@ -1489,375 +697,32 @@ export class SnapAssetsAdapter { return [...visibleSavedAssets, ...missingEssentialAssets]; } - /** - * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. - * - * @param caipAssetType - The CAIP-19 asset type. - * @returns The fiat ticker. - */ - #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { - if (!SnapAssetsAdapter.isFiat(caipAssetType)) { - throw new Error('Passed caipAssetType is not a fiat asset'); - } - - const fiatTicker = - parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); - - return fiatTicker as FiatTicker; - } - - /** - * Fetches fiat exchange rates and crypto prices for the given assets. - * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. - * - * @param allAssets - Array of all CAIP asset types (both fiat and crypto). - * @returns Promise resolving to fiat exchange rates and crypto prices. - */ - async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ - fiatExchangeRates: Record; - cryptoPrices: Record; - }> { - const cryptoAssets = allAssets.filter( - (asset) => !SnapAssetsAdapter.isFiat(asset), - ); - - const [fiatExchangeRates, cryptoPrices] = await Promise.all([ - this.#priceApiClient.getFiatExchangeRates(), - this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), - ]); - - return { fiatExchangeRates, cryptoPrices }; - } - - /** - * Get the token conversions for a list of asset pairs. - * It caches the results for 1 hour. - * - * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. This is not entirely accurate but it's the - * best we can do with the current API. - * - * @param conversions - The asset pairs to get the conversions for. - * @returns The token conversions. - */ - async getMultipleTokenConversions( - conversions: { from: CaipAssetType; to: CaipAssetType }[], - ): Promise< - Record> - > { - if (conversions.length === 0) { - return {}; - } - - /** - * `from` and `to` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = conversions.flatMap((conversion) => [ - conversion.from, - conversion.to, - ]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); /** - * Now that we have the data, convert the `from`s to `to`s. - * - * We need to handle the following cases: - * 1. `from` and `to` are both fiat - * 2. `from` and `to` are both crypto - * 3. `from` is fiat and `to` is crypto - * 4. `from` is crypto and `to` is fiat - * - * We also need to keep in mind that although `cryptoPrices` are indexed - * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. - * To convert fiat currency symbols to CAIP 19 IDs, we can use the - * `this.#fiatSymbolToCaip19Id` method. + * 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[] = []; - const result: Record< - CaipAssetType, - Record - > = {}; - - conversions.forEach((conversion) => { - const { from, to } = conversion; - - result[from] ??= {}; - - let fromUsdRate: BigNumber; - let toUsdRate: BigNumber; - - if (SnapAssetsAdapter.isFiat(from)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(from)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); - } - - if (SnapAssetsAdapter.isFiat(to)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(to)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); - } + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); - if (fromUsdRate.isZero() || toUsdRate.isZero()) { - result[from][to] = null; - return; + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); } - - const rate = fromUsdRate.dividedBy(toUsdRate).toString(); - - const now = Date.now(); - - result[from][to] = { - rate, - conversionTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - }); - - return result; - } - - /** - * Computes the market data object in the target currency. - * - * @param spotPrice - The spot price of the asset in source currency. - * @param rate - The rate to convert the market data to from source currency to target currency. - * @returns The market data in the target currency. - */ - #computeMarketData( - spotPrice: SpotPrice, - rate: BigNumber, - ): FungibleAssetMarketData { - const marketDataInUsd = pick(spotPrice, [ - 'marketCap', - 'totalVolume', - 'circulatingSupply', - 'allTimeHigh', - 'allTimeLow', - 'pricePercentChange1h', - 'pricePercentChange1d', - 'pricePercentChange7d', - 'pricePercentChange14d', - 'pricePercentChange30d', - 'pricePercentChange200d', - 'pricePercentChange1y', - ]); - - const toCurrency = (value: number | null | undefined): string => { - return value === null || value === undefined - ? '' - : new BigNumber(value).dividedBy(rate).toString(); - }; - - const includeIfDefined = ( - key: string, - value: number | null | undefined, - ): Record => { - return value === null || value === undefined ? {} : { [key]: value }; - }; - - // Variations in percent don't need to be converted, they are independent of the currency - const pricePercentChange = { - ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), - ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), - ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), - ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), - ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), - ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), - ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), - }; - - const marketDataInToCurrency = { - fungible: true, - marketCap: toCurrency(marketDataInUsd.marketCap), - totalVolume: toCurrency(marketDataInUsd.totalVolume), - circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert - allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), - allTimeLow: toCurrency(marketDataInUsd.allTimeLow), - // Add pricePercentChange field only if it has values - ...(Object.keys(pricePercentChange).length > 0 - ? { pricePercentChange } - : {}), - } as FungibleAssetMarketData; - - return marketDataInToCurrency; - } - - async getMultipleTokensMarketData( - assets: { - asset: CaipAssetType; - unit: CaipAssetType; - }[], - ): Promise< - Record> - > { - if (assets.length === 0) { - return {}; } - /** - * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - const result: Record< - CaipAssetType, - Record - > = {}; - - assets.forEach((asset) => { - const { asset: assetType, unit } = asset; - - // Skip if we don't have price data for the asset - if (!cryptoPrices[assetType]) { - return; - } - - let unitUsdRate: BigNumber; - - if (SnapAssetsAdapter.isFiat(unit)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; - - if (!fiatExchangeRate) { - return; - } - - unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); - } - - if (unitUsdRate.isZero()) { - return; - } - - // Initialize the nested structure for the asset if it doesn't exist - result[assetType] ??= {}; - - // Store the market data with the unit as the key - result[assetType][unit] = this.#computeMarketData( - cryptoPrices[assetType], - unitUsdRate, - ); - }); - - return result; - } - - /** - * Get historical prices for a token pair by calling the Price API. - * Similar to the Solana snap implementation. - * - * @param from - The asset to get historical prices for. - * @param to - The currency to convert prices to. - * @returns Historical price data with intervals. - */ - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - assert(from, CaipAssetTypeStruct); - assert(to, CaipAssetTypeStruct); - - const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); - assert(toTicker, VsCurrencyParamStruct); - - const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; - - // For each time period, call the Price API to fetch the historical prices - const promises = timePeriodsToFetch.map(async (timePeriod) => - this.#priceApiClient - .getHistoricalPrices({ - assetType: from, - timePeriod, - vsCurrency: toTicker, - }) - // Wrap the response in an object with the time period and the response for easier reducing - .then((response) => ({ - timePeriod, - response, - })) - // Gracefully handle individual errors to avoid breaking the entire operation - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, - error, - ); - return { - timePeriod, - response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - }; - }), - ); - - const wrappedHistoricalPrices = await Promise.all(promises); - - const intervals = wrappedHistoricalPrices.reduce( - (acc, { timePeriod, response }) => { - const iso8601Interval = `P${timePeriod.toUpperCase()}`; - acc[iso8601Interval] = response.prices.map((price) => [ - price[0], - price[1].toString(), - ]); - return acc; - }, - {}, - ); - - const now = Date.now(); - - const result = { - intervals, - updateTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - - return result; + return [...savedAssets, ...missingEssentialAssets]; } }