From c73cb09b5a8b423f3330fb9e895567a680722509 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 10:30:42 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(tron-wallet-snap):=20slim=20AssetsServ?= =?UTF-8?q?ice=20tests=20to=20fa=C3=A7ade=20delegation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Snap-path behavior coverage into SnapAssetsAdapter tests and keep AssetsService tests focused on adapter routing based on the migration flag. Co-authored-by: Ulisses Ferreira --- .../src/services/assets/AssetsService.test.ts | 3375 ++--------------- .../assets/adapters/SnapAssetsAdapter.test.ts | 2630 +++++++++++++ 2 files changed, 2937 insertions(+), 3068 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.test.ts 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 0925411a..02da28e2 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,3174 +1,413 @@ -import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; import { SNAPS_ASSETS_MIGRATION_FLAG_KEYS, SnapsAssetsMigrationStage, } from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; -import { KeyringEvent } from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; +import type { CaipAssetType } from '@metamask/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'; -import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; -import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; -import { TrongridAccountNotFoundError } from '../../clients/trongrid/errors'; -import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; +import { KnownCaip19Id, Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; -import type { CoreMessengerCaller } from '../../types/core-messenger'; -import { mockLogger } from '../../utils/mockLogger'; -import type { ConfigProvider } from '../config'; -import { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; -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; -}; - -jest.mock('../../context', () => ({ - configProvider: { - get(): { - priceApi: { - cacheTtlsMilliseconds: { - fiatExchangeRates: number; - spotPrices: number; - historicalPrices: number; - }; - }; - activeNetworks: never[]; - } { - return { - priceApi: { - cacheTtlsMilliseconds: { - fiatExchangeRates: 3600000, - spotPrices: 3600000, - historicalPrices: 3600000, - }, - }, - activeNetworks: [], - }; - }, - }, -})); - -jest.mock('@metamask/keyring-snap-sdk', () => ({ - emitSnapKeyringEvent: jest.fn(), -})); - -(global as any).snap = {}; - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { AssetsService } = require('./AssetsService'); +import { AssetsService } from './AssetsService'; const TRON_FLAG_KEY = SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron; -function createMessengerCallMock( - getState: () => unknown, - getAccountAssetByID: jest.Mock, - getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), - getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), -): CoreMessengerCaller['call'] { - return async (actionType, ...args) => { - switch (actionType) { - case 'RemoteFeatureFlagController:getState': - return getState() as Awaited>; - case 'AssetsController:getAccountAssetByID': - return getAccountAssetByID(...args); - case 'AssetsController:getAccountAssetsByIDs': - return getAccountAssetsByIDs(...args); - case 'AssetsController:getAccountAssetsByScope': - return getAccountAssetsByScope(...args); - default: - return undefined; - } - }; -} - -function buildControllerAsset( - assetId: string, - amount: string, - metadata: { - symbol: string; - name: string; - decimals: number; - image?: string; - }, -): Asset { - return { - id: assetId as Asset['id'], - chainId: Network.Mainnet as Asset['chainId'], - balance: { amount }, - metadata: { - type: 'fungible', - symbol: metadata.symbol, - name: metadata.name, - decimals: metadata.decimals, - image: metadata.image, - }, - price: { price: 0, lastUpdated: 0 }, - fiatValue: 0, - } as Asset; -} - -/** - * Builds a SpotPrices map for test mocks. - * - * @param entries - Map of asset ID to price info. - * @returns SpotPrices object. - */ -const createSpotPrices = ( - entries: Record, -): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - const mockAccount: KeyringAccount = { id: 'test-account-id', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', type: 'eip155:eoa', options: {}, methods: [], - scopes: ['tron:728126428'], + scopes: [Network.Mainnet], }; -const emptyAccountResources: AccountResources = { - freeNetUsed: 0, - freeNetLimit: 0, - NetLimit: 0, - TotalNetLimit: 0, - TotalNetWeight: 0, - tronPowerUsed: 0, - tronPowerLimit: 0, - TotalEnergyLimit: 0, - TotalEnergyWeight: 0, +const mockAsset: AssetEntity = { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1', + uiAmount: '1', + iconUrl: '', }; /** - * Creates a properly typed TronAccount for tests. - * Uses snake_case property names to match Tron API response format. + * Builds an AssetsService that delegates to mock adapters. * - * @param overrides - Partial TronAccount with required address. - * @returns A complete TronAccount. + * @param migrationOn - Whether the Tron assets migration flag should be active. + * @returns The service and the mocks it was constructed with. */ - -const createMockTronAccount = ( - overrides: Partial & { address: string }, -): TronAccount => ({ - owner_permission: { keys: [], threshold: 1, permission_name: 'owner' }, - account_resource: { - energy_window_optimized: false, - energy_window_size: 0, - }, - active_permission: [], - create_time: 0, - latest_opration_time: 0, - frozenV2: [], - unfrozenV2: [], - balance: 0, - trc20: [], - latest_consume_free_time: 0, - votes: [], - latest_withdraw_time: 0, - net_window_size: 0, - net_window_optimized: false, - ...overrides, -}); - -// Convenience alias used by bandwidth/energy tests -const minimalTronAccount = createMockTronAccount({ - address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', -}); - -/** - * Builds a mock AccountResources object matching the shape returned by - * POST https://api.trongrid.io/wallet/getaccountresource. - * - * The Tron full node omits fields with zero values, so all - * account-level fields are optional. Network-level totals use - * sensible mainnet defaults. - * - * @see https://developers.tron.network/reference/getaccountresource - * @param overrides - Account-specific fields to set. - * @returns A mock AccountResources object. - */ -function getMockAccountResources( - overrides: Record = {}, -): Record { - return { - freeNetLimit: 600, - TotalNetLimit: 0, - TotalNetWeight: 0, - TotalEnergyLimit: 0, - TotalEnergyWeight: 0, - ...overrides, +function createAssetsService(migrationOn: boolean): { + assetsService: AssetsService; + snapAdapter: { + cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; + getAccountAssetsByIDs: jest.Mock; + getAccountAssetByID: jest.Mock; + fetchAssetsAndBalancesForAccount: jest.Mock; + getAssetsMetadata: jest.Mock; + saveMany: jest.Mock; + getAll: jest.Mock; + getAccountAssets: jest.Mock; + getMultipleTokenConversions: jest.Mock; + getMultipleTokensMarketData: jest.Mock; + getHistoricalPrice: jest.Mock; }; -} - -/** - * Finds an asset by its CAIP-19 asset type. - * - * @param assets - The list of assets to search. - * @param assetType - The CAIP-19 asset type to match. - * @returns The matching asset, or undefined. - */ -function findAsset( - assets: AssetEntity[], - assetType: KnownCaip19Id, -): AssetEntity | undefined { - return assets.find((a: AssetEntity) => a.assetType === assetType); -} - -type WithAssetsServiceCallback = (payload: { - assetsService: InstanceType; - mockAssetsRepository: jest.Mocked< - Pick< - AssetsRepository, - | 'saveMany' - | 'getByAccountId' - | 'getByAccountIdAndAssetType' - | 'getByAccountIdAndAssetTypes' - > - >; - mockState: MockState; - mockTrongridApiClient: jest.Mocked< - Pick< - TrongridApiClient, - 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' - > - >; - mockTronHttpClient: jest.Mocked< - Pick - >; - mockPriceApiClient: jest.Mocked< - Pick< - PriceApiClient, - 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' - > - >; - mockTokenApiClient: jest.Mocked>; - mockSnapClient: jest.Mocked>; - mockCoreMessenger: jest.Mocked; - setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; -}) => Promise | ReturnValue; - -/** - * Wraps tests for AssetsService by creating a fresh service with all mock - * dependencies. The callback receives the service and all mocks for - * test configuration. - * - * @param testFunction - The test body receiving the service and mocks. - * @returns The return value of the callback. - */ -async function withAssetsService( - testFunction: WithAssetsServiceCallback, -): Promise { - const mockAssetsRepository: jest.Mocked< - Pick< - AssetsRepository, - | 'getByAccountId' - | 'getByAccountIdAndAssetType' - | 'getByAccountIdAndAssetTypes' - | 'saveMany' - > - > = { - saveMany: jest.fn().mockResolvedValue(undefined), - getByAccountId: jest.fn().mockResolvedValue([]), - getByAccountIdAndAssetType: jest.fn().mockResolvedValue(null), - getByAccountIdAndAssetTypes: jest.fn().mockResolvedValue([]), + coreAdapter: { + getAccountAssetsByIDs: jest.Mock; + getAccountAssetByID: jest.Mock; + fetchAssetsAndBalancesForAccount: jest.Mock; + saveMany: jest.Mock; + getAccountAssets: jest.Mock; }; - - const mockState: MockState = { - getKey: jest.fn().mockResolvedValue({}), - setKey: jest.fn().mockResolvedValue(undefined), - setKeyWith: jest.fn().mockResolvedValue(undefined), - }; - - const mockTrongridApiClient: jest.Mocked< - Pick< - TrongridApiClient, - 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' - > - > = { - getAccountInfoByAddress: jest.fn(), - getTrc20BalancesByAddress: jest.fn(), + remoteFeatureFlagsProvider: { + getFeatureFlag: jest.Mock; }; - - const mockTronHttpClient: jest.Mocked< - Pick - > = { - getAccountResources: jest.fn(), - getReward: jest.fn().mockResolvedValue(0), - }; - - const mockPriceApiClient: jest.Mocked< - Pick< - PriceApiClient, - 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' - > - > = { - getFiatExchangeRates: jest.fn(), - getHistoricalPrices: jest.fn(), - getMultipleSpotPrices: jest.fn().mockResolvedValue({}), - }; - - const mockTokenApiClient: jest.Mocked< - Pick - > = { - getTokensMetadata: jest.fn().mockResolvedValue({}), - }; - - const mockSnapClient: jest.Mocked> = { - trackError: jest.fn().mockResolvedValue(undefined), - }; - - const mockGetAccountAssetByID = jest.fn(); - const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); - const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); - let migrationStage = SnapsAssetsMigrationStage.Off; - const mockCoreMessenger: jest.Mocked = { - call: jest.fn().mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { stage: migrationStage }, - }, - }), - mockGetAccountAssetByID, - mockGetAccountAssetsByIDs, - mockGetAccountAssetsByScope, - ), - ), +} { + const snapAdapter = { + cacheTtlsMilliseconds: { + fiatExchangeRates: 1, + spotPrices: 2, + historicalPrices: 3, + }, + getAccountAssetsByIDs: jest.fn().mockResolvedValue([]), + getAccountAssetByID: jest.fn().mockResolvedValue(null), + fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), + getAssetsMetadata: jest.fn().mockResolvedValue({}), + saveMany: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue([]), + getAccountAssets: jest.fn().mockResolvedValue([]), + getMultipleTokenConversions: jest.fn().mockResolvedValue({}), + getMultipleTokensMarketData: jest.fn().mockResolvedValue({}), + getHistoricalPrice: jest.fn().mockResolvedValue({ + intervals: {}, + updateTime: 0, + }), }; - const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { - migrationStage = stage; + const coreAdapter = { + getAccountAssetsByIDs: jest.fn().mockResolvedValue([]), + getAccountAssetByID: jest.fn().mockResolvedValue(null), + fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), + saveMany: jest.fn().mockResolvedValue(undefined), + getAccountAssets: jest.fn().mockResolvedValue([]), }; - const assetsProvider = new AssetsProvider({ - messenger: mockCoreMessenger as never, - }); - const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ - messenger: mockCoreMessenger as never, - }); - - const mockConfigProvider: jest.Mocked> = { - get: jest.fn().mockReturnValue({ - priceApi: { - cacheTtlsMilliseconds: { - fiatExchangeRates: 3600000, - spotPrices: 3600000, - historicalPrices: 3600000, - }, - }, - activeNetworks: [], + const remoteFeatureFlagsProvider = { + getFeatureFlag: jest.fn().mockResolvedValue({ + stage: migrationOn + ? SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback + : SnapsAssetsMigrationStage.Off, }), }; - const snapAdapter = new SnapAssetsAdapter({ - logger: mockLogger, - assetsRepository: mockAssetsRepository as never, - state: mockState as never, - trongridApiClient: mockTrongridApiClient as never, - tronHttpClient: mockTronHttpClient as never, - priceApiClient: mockPriceApiClient as never, - tokenApiClient: mockTokenApiClient as never, - snapClient: mockSnapClient as never, - configProvider: mockConfigProvider as never, - }); - const coreAdapter = new CoreAssetsAdapter({ - getAccountAssetByID: - assetsProvider.getAccountAssetByID.bind(assetsProvider), - getAccountAssetsByIDs: - assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), - getAccountAssetsByScope: - assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - getAddressInfo: mockTrongridApiClient.getAccountInfoByAddress, - getAddressResources: mockTronHttpClient.getAccountResources, - getAddressStakingRewards: mockTronHttpClient.getReward, + const assetsService = new AssetsService({ + snapAdapter: snapAdapter as unknown as SnapAssetsAdapter, + coreAdapter: coreAdapter as unknown as CoreAssetsAdapter, + remoteFeatureFlagsProvider: + remoteFeatureFlagsProvider as unknown as RemoteFeatureFlagsProvider, }); - const assetsService = new AssetsService({ + return { + assetsService, snapAdapter, coreAdapter, remoteFeatureFlagsProvider, - }); - - return await testFunction({ - assetsService, - mockAssetsRepository, - mockState, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - 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 () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - 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, - ); - - 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'); - }, - ); - }); - - it('returns protocol resources when inactive account has empty resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(energyAsset).toBeDefined(); - }, - ); - }); - - 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, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(assets.length).toBeGreaterThan(0); - expect( - assets.some((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - }, - ); - }); - }); - - 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, - }); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalled(); - expect( - assets.some((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, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - trc20: [], - }), - ); - mockTronHttpClient.getAccountResources.mockRejectedValue( - new Error('Resources endpoint unavailable'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('0'); - }, - ); - }); - }); - - describe('bandwidth', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns remaining free bandwidth when no staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 200 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('400'); - }, - ); - }); - - it('returns combined remaining free + staked bandwidth', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('290'); - }, - ); - }); - - it('clamps to 0 when used exceeds maximum', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ - freeNetUsed: 600, - NetUsed: 50, - NetLimit: 16, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - }); - - describe('maximum bandwidth', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns only free bandwidth limit when no staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({}), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('600'); - }, - ); - }); - - it('returns free + staked bandwidth limit', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ NetLimit: 48 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('648'); - }, - ); - }); - }); - - describe('TRX ready for withdrawal', () => { - it('returns zero balance when account has no unfrozenV2 data', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('returns ready for withdrawal amount when unfrozenV2 has expired entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - - it('returns zero balance when unfrozenV2 has not expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('sums multiple expired unfrozen entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime1 = Date.now() - 1000; - const pastTime2 = Date.now() - 2000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime1 }, - { unfreeze_amount: 2000000, unfreeze_expire_time: pastTime2 }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('3000000'); - }, - ); - }); - - it('only includes expired entries when mixed with non-expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - { - unfreeze_amount: 5000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - }); - - describe('TRX in lock period', () => { - it('returns zero balance when account has no unfrozenV2 data', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('returns in lock period amount when unfrozenV2 has future entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - - it('returns zero balance when unfrozenV2 has already expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: pastTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('sums multiple non-expired unfrozen entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime1 = Date.now() + 1000000; - const futureTime2 = Date.now() + 2000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime1, - }, - { - unfreeze_amount: 2000000, - unfreeze_expire_time: futureTime2, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('3000000'); - }, - ); - }); - - it('only includes non-expired entries when mixed with expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - { - unfreeze_amount: 5000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('5000000'); - }, - ); - }); - - it('returns zero balance for inactive accounts', async () => { - await withAssetsService( - async ({ - assetsService, - 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 inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - }); - - describe('energy', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns full energy when none consumed', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('329'); - }, - ); - }); - - it('returns remaining energy after partial consumption', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('617'); - }, - ); - }); - - it('clamps to 0 when EnergyUsed exceeds EnergyLimit from leasing', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - }); - - describe('maximum energy', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + afterEach(() => { + jest.restoreAllMocks(); + }); - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); + it('exposes cache TTLs from the Snap adapter', () => { + const { assetsService, snapAdapter } = createAssetsService(false); - it('returns EnergyLimit from staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); + expect(assetsService.cacheTtlsMilliseconds).toBe( + snapAdapter.cacheTtlsMilliseconds, + ); + }); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + describe('static helpers', () => { + it('delegates isFiat to SnapAssetsAdapter', () => { + const isFiat = jest.spyOn(SnapAssetsAdapter, 'isFiat'); + const assetId = 'swift:0/iso4217:usd' as CaipAssetType; - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('329'); - }, - ); - }); + expect(AssetsService.isFiat(assetId)).toBe(true); + expect(isFiat).toHaveBeenCalledWith(assetId); }); - describe('staking rewards', () => { - it('returns 0 when account has no staking rewards', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockResolvedValue(0); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns staking rewards when account has unclaimed rewards', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockResolvedValue(5000000); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const stakingRewardsAsset = findAsset( - assets, - KnownCaip19Id.TrxStakingRewardsMainnet, - ); - expect(stakingRewardsAsset?.rawAmount).toBe('5000000'); - expect(stakingRewardsAsset?.uiAmount).toBe('5'); - expect(stakingRewardsAsset?.symbol).toBe('trx-staking-rewards'); - }, - ); - }); - - it('gracefully handles staking rewards API failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockRejectedValue( - new Error('API Error'), - ); + it('delegates hasChanged to SnapAssetsAdapter', () => { + const hasChanged = jest.spyOn(SnapAssetsAdapter, 'hasChanged'); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); + expect(AssetsService.hasChanged(mockAsset, [])).toBe(true); + expect(hasChanged).toHaveBeenCalledWith(mockAsset, []); }); }); - 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', - ); + describe('getAccountAssetsByIDs', () => { + it('returns an empty list without consulting adapters when no IDs are given', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(true); - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); + await expect( + assetsService.getAccountAssetsByIDs(mockAccount.id, []), + ).resolves.toStrictEqual([]); + expect(snapAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); + expect(coreAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); - }); - - 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); - await assetsService.saveMany(assets); + it('delegates to the Snap adapter when migration is off', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(false); + snapAdapter.getAccountAssetsByIDs.mockResolvedValue([mockAsset]); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, + await expect( + assetsService.getAccountAssetsByIDs(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]), + ).resolves.toStrictEqual([mockAsset]); + expect(snapAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + mockAccount.id, + [KnownCaip19Id.TrxMainnet], ); + expect(coreAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); - 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(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, - ); - }); + it('delegates to the Core adapter when migration is on', async () => { + const { + assetsService, + snapAdapter, + coreAdapter, + remoteFeatureFlagsProvider, + } = createAssetsService(true); + coreAdapter.getAccountAssetsByIDs.mockResolvedValue([mockAsset]); + + await expect( + assetsService.getAccountAssetsByIDs(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]), + ).resolves.toStrictEqual([mockAsset]); + expect(remoteFeatureFlagsProvider.getFeatureFlag).toHaveBeenCalledWith( + TRON_FLAG_KEY, + ); + expect(coreAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( + mockAccount.id, + [KnownCaip19Id.TrxMainnet], + ); + expect(snapAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); + }); - it('updates stale non-essential assets balance to 0 if 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 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]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - finalSavedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, - ); + describe('getAccountAssetByID', () => { + it('delegates to the Snap adapter when migration is off', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(false); + snapAdapter.getAccountAssetByID.mockResolvedValue(mockAsset); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [trc20AssetId]: { - unit: 'USDT', - amount: '0', - }, - }, - }, - }, - ); - }, + await expect( + assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).resolves.toBe(mockAsset); + expect(snapAdapter.getAccountAssetByID).toHaveBeenCalledWith( + mockAccount.id, + KnownCaip19Id.TrxMainnet, ); + expect(coreAdapter.getAccountAssetByID).not.toHaveBeenCalled(); }); - 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: '', - }, - ]; + it('delegates to the Core adapter when migration is on', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(true); + coreAdapter.getAccountAssetByID.mockResolvedValue(mockAsset); - 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: [], - }, - }, - }, - ); - }, + await expect( + assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).resolves.toBe(mockAsset); + expect(coreAdapter.getAccountAssetByID).toHaveBeenCalledWith( + mockAccount.id, + KnownCaip19Id.TrxMainnet, ); + expect(snapAdapter.getAccountAssetByID).not.toHaveBeenCalled(); }); + }); - 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: [], - }, - }, - }, - ); - }, + describe('fetchAssetsAndBalancesForAccount', () => { + it('delegates to the Snap adapter when migration is off', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(false); + snapAdapter.fetchAssetsAndBalancesForAccount.mockResolvedValue([ + mockAsset, + ]); + + await expect( + assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ), + ).resolves.toStrictEqual([mockAsset]); + expect(snapAdapter.fetchAssetsAndBalancesForAccount).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount, ); + expect( + coreAdapter.fetchAssetsAndBalancesForAccount, + ).not.toHaveBeenCalled(); }); - 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: [], - }, - }, - }, - ); - }, + it('delegates to the Core adapter when migration is on', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(true); + coreAdapter.fetchAssetsAndBalancesForAccount.mockResolvedValue([ + mockAsset, + ]); + + await expect( + assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ), + ).resolves.toStrictEqual([mockAsset]); + expect(coreAdapter.fetchAssetsAndBalancesForAccount).toHaveBeenCalledWith( + Network.Mainnet, + mockAccount, ); + expect( + snapAdapter.fetchAssetsAndBalancesForAccount, + ).not.toHaveBeenCalled(); }); + }); - 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).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - 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, - }); + describe('saveMany', () => { + it('delegates to the Snap adapter when migration is off', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(false); - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany([mockAsset]); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); + expect(snapAdapter.saveMany).toHaveBeenCalledWith([mockAsset]); + expect(coreAdapter.saveMany).not.toHaveBeenCalled(); }); - describe('updating assets going down', () => { - it('updates energy balance when it decreases but remains >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: '100000', - uiAmount: '100000', - 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: '35000', - uiAmount: '35000', - 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.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '35000', - }, - }, - }, - }, - ); - }, - ); - }); - - it('updates bandwidth balance when it decreases but remains >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: '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.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, - ); - }, - ); - }); - - 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).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps energy in the list when it drops 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: '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: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + it('delegates to the Core adapter when migration is on', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(true); - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany([mockAsset]); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps bandwidth in the list when it drops 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: '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, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - 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.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('handles both energy and bandwidth fluctuating in a transaction', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - 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', - 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.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '2', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '45000', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', - }, - }, - }, - }, - ); - }, - ); - }); + expect(coreAdapter.saveMany).toHaveBeenCalledWith([mockAsset]); + expect(snapAdapter.saveMany).not.toHaveBeenCalled(); }); }); - describe('getAssetsMetadata', () => { - it('resolves metadata for native, protocol, and token asset types', async () => { - await withAssetsService(async ({ assetsService, mockTokenApiClient }) => { - const trc20 = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; - const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; - - mockTokenApiClient.getTokensMetadata.mockResolvedValue({ - [trc20]: { - fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, - }, - [trc10]: { - fungible: { symbol: 'T', name: 'Token', decimals: 0 }, - }, - } as never); - - const assetTypes = [ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.TrxStakedForBandwidthMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - KnownCaip19Id.TrxInLockPeriodMainnet, - KnownCaip19Id.TrxStakingRewardsMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.MaximumEnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - KnownCaip19Id.MaximumBandwidthMainnet, - trc10, - trc20, - ]; - - const metadata = await assetsService.getAssetsMetadata(assetTypes); - - expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); - expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); - expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); - expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ - trc10, - trc20, - ]); - }); + describe('getAccountAssets', () => { + it('delegates to the Snap adapter when migration is off', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(false); + snapAdapter.getAccountAssets.mockResolvedValue([mockAsset]); + + await expect( + assetsService.getAccountAssets(mockAccount.id), + ).resolves.toStrictEqual([mockAsset]); + expect(snapAdapter.getAccountAssets).toHaveBeenCalledWith(mockAccount.id); + expect(coreAdapter.getAccountAssets).not.toHaveBeenCalled(); }); - }); - - describe('assets migration', () => { - const accountId = mockAccount.id; - const fungibleAssetId = KnownCaip19Id.TrxMainnet; - const activeMigrationStage = - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; - - it('routes getAccountAssetByID through AssetsController when migration is active', async () => { - await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: activeMigrationStage, - }, - }, - }), - jest.fn().mockResolvedValue( - buildControllerAsset(fungibleAssetId, '2000000', { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }), - ), - ), - ); - const asset = await assetsService.getAccountAssetByID( - accountId, - fungibleAssetId, - ); + it('delegates to the Core adapter when migration is on', async () => { + const { assetsService, snapAdapter, coreAdapter } = + createAssetsService(true); + coreAdapter.getAccountAssets.mockResolvedValue([mockAsset]); - expect(asset).toMatchObject({ - assetType: fungibleAssetId, - rawAmount: '2000000', - uiAmount: '2', - }); - }); + await expect( + assetsService.getAccountAssets(mockAccount.id), + ).resolves.toStrictEqual([mockAsset]); + expect(coreAdapter.getAccountAssets).toHaveBeenCalledWith(mockAccount.id); + expect(snapAdapter.getAccountAssets).not.toHaveBeenCalled(); }); + }); - it('routes getAccountAssetsByIDs through AssetsController when migration is active', async () => { - await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { - const trx = KnownCaip19Id.TrxMainnet; - const usdt = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: activeMigrationStage, - }, - }, - }), - jest.fn(), - jest.fn().mockImplementation(async () => { - return { - [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }), - [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { - symbol: 'USDT', - name: 'Tether', - decimals: 6, - }), - }; - }), - ), - ); - - const results = await assetsService.getAccountAssetsByIDs(accountId, [ - trx, - usdt, - ]); - - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByIDs', - accountId, - [trx, usdt], - ); - expect(results[0]?.rawAmount).toBe('1000000'); - expect(results[1]?.rawAmount).toBe('500000'); - }); + describe('methods that always use the Snap adapter', () => { + it('delegates getAssetsMetadata to the Snap adapter while migration is on', async () => { + const { assetsService, snapAdapter } = createAssetsService(true); + const metadata = { [KnownCaip19Id.TrxMainnet]: null }; + snapAdapter.getAssetsMetadata.mockResolvedValue(metadata); + + await expect( + assetsService.getAssetsMetadata([KnownCaip19Id.TrxMainnet]), + ).resolves.toBe(metadata); + expect(snapAdapter.getAssetsMetadata).toHaveBeenCalledWith([ + KnownCaip19Id.TrxMainnet, + ]); }); - it('routes getAccountAssets through AssetsController when migration is active', async () => { - await withAssetsService( - async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { - setMigrationStage(activeMigrationStage); - - mockCoreMessenger.call.mockImplementation( - createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: activeMigrationStage, - }, - }, - }), - jest.fn(), - jest.fn(), - jest.fn().mockResolvedValue({ - [fungibleAssetId as Caip19AssetId]: buildControllerAsset( - fungibleAssetId, - '2000000', - { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }, - ), - }), - ), - ); + it('delegates getAll to the Snap adapter while migration is on', async () => { + const { assetsService, snapAdapter } = createAssetsService(true); + snapAdapter.getAll.mockResolvedValue([mockAsset]); - const assets = await assetsService.getAccountAssets(accountId); - - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', - accountId, - Network.Mainnet, - ); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', - accountId, - Network.Nile, - ); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', - accountId, - Network.Shasta, - ); - expect( - assets.some( - (asset: AssetEntity) => asset.assetType === fungibleAssetId, - ), - ).toBe(true); - }, - ); + await expect(assetsService.getAll()).resolves.toStrictEqual([mockAsset]); + expect(snapAdapter.getAll).toHaveBeenCalledWith(); }); - it('fetches only snap-owned assets when migration is active', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - setMigrationStage, - }) => { - setMigrationStage(activeMigrationStage); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ - address: mockAccount.address, - balance: 5_000_000, - trc20: [ - { - TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000000', - }, - ], - assetV2: [], - frozenV2: [], - unfrozenV2: [], - } as unknown as TronAccount); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - EnergyLimit: 1000, - }); - mockTronHttpClient.getReward.mockResolvedValue(0); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); - expect(assets.length).toBeGreaterThan(0); - expect( - assets.every((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(false); + it('delegates getMultipleTokenConversions to the Snap adapter while migration is on', async () => { + const { assetsService, snapAdapter } = createAssetsService(true); + const conversions = [ + { + from: KnownCaip19Id.TrxMainnet, + to: 'swift:0/iso4217:usd' as CaipAssetType, }, + ]; + const result = { [KnownCaip19Id.TrxMainnet]: {} }; + snapAdapter.getMultipleTokenConversions.mockResolvedValue(result); + + await expect( + assetsService.getMultipleTokenConversions(conversions), + ).resolves.toBe(result); + expect(snapAdapter.getMultipleTokenConversions).toHaveBeenCalledWith( + conversions, ); }); - it('emits only snap-owned assets and does not persist when migration is active', async () => { - await withAssetsService( - async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { - setMigrationStage(activeMigrationStage); - - const specialAsset: AssetEntity = { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '600', - uiAmount: '600', - iconUrl: '', - }; - const fungibleAsset: AssetEntity = { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }; - - await assetsService.saveMany([specialAsset, fungibleAsset]); - - expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.BandwidthMainnet], - removed: [], - }, - }, - }, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '600', - }, - }, - }, - }, - ); + it('delegates getMultipleTokensMarketData to the Snap adapter while migration is on', async () => { + const { assetsService, snapAdapter } = createAssetsService(true); + const assets = [ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd' as CaipAssetType, }, + ]; + const result = { [KnownCaip19Id.TrxMainnet]: {} }; + snapAdapter.getMultipleTokensMarketData.mockResolvedValue(result); + + await expect( + assetsService.getMultipleTokensMarketData(assets), + ).resolves.toBe(result); + expect(snapAdapter.getMultipleTokensMarketData).toHaveBeenCalledWith( + assets, ); }); - }); - - describe('facade delegation', () => { - it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { - await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { - const asset: AssetEntity = { - iconUrl: '', - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1', - uiAmount: '1', - }; - mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ - asset, - ]); - mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( - MOCK_EXCHANGE_RATES, - ); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [KnownCaip19Id.TrxMainnet]: { - id: KnownCaip19Id.TrxMainnet, - price: 1, - }, - }), - ); + it('delegates getHistoricalPrice to the Snap adapter while migration is on', async () => { + const { assetsService, snapAdapter } = createAssetsService(true); + const result = { intervals: {}, updateTime: 1 }; + snapAdapter.getHistoricalPrice.mockResolvedValue(result); - 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([]); - expect( - await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]), - ).toBeDefined(); - }, + await expect( + assetsService.getHistoricalPrice( + KnownCaip19Id.TrxMainnet, + 'swift:0/iso4217:usd', + ), + ).resolves.toBe(result); + expect(snapAdapter.getHistoricalPrice).toHaveBeenCalledWith( + KnownCaip19Id.TrxMainnet, + 'swift:0/iso4217:usd', ); }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.test.ts new file mode 100644 index 00000000..225b9182 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -0,0 +1,2630 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; + +import type { PriceApiClient } from '../../../clients/price-api/PriceApiClient'; +import type { SpotPrices } from '../../../clients/price-api/types'; +import type { SnapClient } from '../../../clients/snap/SnapClient'; +import type { TokenApiClient } from '../../../clients/token-api/TokenApiClient'; +import type { + AccountResources, + TronHttpClient, +} from '../../../clients/tron-http'; +import { TrongridAccountNotFoundError } from '../../../clients/trongrid/errors'; +import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; +import type { TronAccount } from '../../../clients/trongrid/types'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { mockLogger } from '../../../utils/mockLogger'; +import type { ConfigProvider } from '../../config'; +import type { AssetsRepository } from '../AssetsRepository'; +import type { NativeCaipAssetType, TokenCaipAssetType } from '../types'; +import { SnapAssetsAdapter } from './SnapAssetsAdapter'; + +/** + * Subset of State methods. + */ +type MockState = { + getKey: jest.Mock; + setKey: jest.Mock; + setKeyWith: jest.Mock; +}; + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +(globalThis as { snap?: unknown }).snap = {}; + +/** + * 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', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], +}; + +const emptyAccountResources: AccountResources = { + freeNetUsed: 0, + freeNetLimit: 0, + NetLimit: 0, + TotalNetLimit: 0, + TotalNetWeight: 0, + tronPowerUsed: 0, + tronPowerLimit: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, +}; + +/** + * Creates a properly typed TronAccount for tests. + * Uses snake_case property names to match Tron API response format. + * + * @param overrides - Partial TronAccount with required address. + * @returns A complete TronAccount. + */ +const createMockTronAccount = ( + overrides: Partial & { address: string }, +): TronAccount => ({ + owner_permission: { keys: [], threshold: 1, permission_name: 'owner' }, + account_resource: { + energy_window_optimized: false, + energy_window_size: 0, + }, + active_permission: [], + create_time: 0, + latest_opration_time: 0, + frozenV2: [], + unfrozenV2: [], + balance: 0, + trc20: [], + latest_consume_free_time: 0, + votes: [], + latest_withdraw_time: 0, + net_window_size: 0, + net_window_optimized: false, + ...overrides, +}); + +// Convenience alias used by bandwidth/energy tests +const minimalTronAccount = createMockTronAccount({ + address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', +}); + +/** + * Builds a mock AccountResources object matching the shape returned by + * POST https://api.trongrid.io/wallet/getaccountresource. + * + * The Tron full node omits fields with zero values, so all + * account-level fields are optional. Network-level totals use + * sensible mainnet defaults. + * + * @see https://developers.tron.network/reference/getaccountresource + * @param overrides - Account-specific fields to set. + * @returns A mock AccountResources object. + */ +function getMockAccountResources( + overrides: Record = {}, +): Record { + return { + freeNetLimit: 600, + TotalNetLimit: 0, + TotalNetWeight: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, + ...overrides, + }; +} + +/** + * Finds an asset by its CAIP-19 asset type. + * + * @param assets - The list of assets to search. + * @param assetType - The CAIP-19 asset type to match. + * @returns The matching asset, or undefined. + */ +function findAsset( + assets: AssetEntity[], + assetType: KnownCaip19Id, +): AssetEntity | undefined { + return assets.find((a: AssetEntity) => a.assetType === assetType); +} + +type WithSnapAssetsAdapterCallback = (payload: { + adapter: SnapAssetsAdapter; + mockAssetsRepository: jest.Mocked< + Pick< + AssetsRepository, + | 'saveMany' + | 'getByAccountId' + | 'getByAccountIdAndAssetType' + | 'getByAccountIdAndAssetTypes' + > + >; + mockState: MockState; + mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + >; + mockTronHttpClient: jest.Mocked< + Pick + >; + mockPriceApiClient: jest.Mocked< + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > + >; + mockTokenApiClient: jest.Mocked>; + mockSnapClient: jest.Mocked>; +}) => Promise | ReturnValue; + +/** + * Wraps tests for SnapAssetsAdapter by creating a fresh adapter with all mock + * dependencies. The callback receives the adapter and all mocks for + * test configuration. + * + * @param testFunction - The test body receiving the adapter and mocks. + * @returns The return value of the callback. + */ +async function withSnapAssetsAdapter( + testFunction: WithSnapAssetsAdapterCallback, +): Promise { + const mockAssetsRepository: jest.Mocked< + Pick< + AssetsRepository, + | 'getByAccountId' + | 'getByAccountIdAndAssetType' + | 'getByAccountIdAndAssetTypes' + | 'saveMany' + > + > = { + saveMany: jest.fn().mockResolvedValue(undefined), + 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, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + > = { + getAccountInfoByAddress: jest.fn(), + getTrc20BalancesByAddress: jest.fn(), + }; + + const mockTronHttpClient: jest.Mocked< + Pick + > = { + getAccountResources: jest.fn(), + getReward: jest.fn().mockResolvedValue(0), + }; + + const mockPriceApiClient: jest.Mocked< + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > + > = { + getFiatExchangeRates: jest.fn(), + getHistoricalPrices: jest.fn(), + getMultipleSpotPrices: jest.fn().mockResolvedValue({}), + }; + + const mockTokenApiClient: jest.Mocked< + Pick + > = { + getTokensMetadata: jest.fn().mockResolvedValue({}), + }; + + const mockSnapClient: jest.Mocked> = { + trackError: jest.fn().mockResolvedValue(undefined), + }; + + const mockConfigProvider: jest.Mocked> = { + get: jest.fn().mockReturnValue({ + priceApi: { + cacheTtlsMilliseconds: { + fiatExchangeRates: 3600000, + spotPrices: 3600000, + historicalPrices: 3600000, + }, + }, + activeNetworks: [], + }), + }; + + const adapter = new SnapAssetsAdapter({ + logger: mockLogger, + assetsRepository: mockAssetsRepository as never, + state: mockState as never, + trongridApiClient: mockTrongridApiClient as never, + tronHttpClient: mockTronHttpClient as never, + priceApiClient: mockPriceApiClient as never, + tokenApiClient: mockTokenApiClient as never, + snapClient: mockSnapClient as never, + configProvider: mockConfigProvider as never, + }); + + return await testFunction({ + adapter, + mockAssetsRepository, + mockState, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + mockTokenApiClient, + mockSnapClient, + }); +} + +describe('SnapAssetsAdapter', () => { + describe('fetchAssetsAndBalancesForAccount', () => { + describe('inactive account fallback', () => { + it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + await withSnapAssetsAdapter( + async ({ + adapter, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new TrongridAccountNotFoundError(), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + 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 adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + const trc20Asset = assets.find( + (asset: AssetEntity) => asset.assetType === trc20AssetId, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('24249143'); + }, + ); + }); + + it('returns protocol resources when inactive account has empty resources', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new TrongridAccountNotFoundError(), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + const energyAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.EnergyMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(energyAsset).toBeDefined(); + }, + ); + }); + + it('returns protocol assets when inactive account info fails', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new TrongridAccountNotFoundError(), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect(assets.length).toBeGreaterThan(0); + expect( + assets.some((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + }, + ); + }); + }); + + describe('partial failure handling', () => { + it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new TrongridAccountNotFoundError(), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, + freeNetLimit: 600, + NetLimit: 0, + EnergyLimit: 0, + }); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalled(); + expect( + assets.some((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 withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1000000, + trc20: [], + }), + ); + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Resources endpoint unavailable'), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); + + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('0'); + }, + ); + }); + }); + + describe('bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns remaining free bandwidth when no staking', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 200 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('400'); + }, + ); + }); + + it('returns combined remaining free + staked bandwidth', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('290'); + }, + ); + }); + + it('clamps to 0 when used exceeds maximum', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ + freeNetUsed: 600, + NetUsed: 50, + NetLimit: 16, + }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + + describe('maximum bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns only free bandwidth limit when no staking', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({}), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('600'); + }, + ); + }); + + it('returns free + staked bandwidth limit', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ NetLimit: 48 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('648'); + }, + ); + }); + }); + + describe('TRX ready for withdrawal', () => { + it('returns zero balance when account has no unfrozenV2 data', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('returns ready for withdrawal amount when unfrozenV2 has expired entries', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const pastTime = Date.now() - 1000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + + it('returns zero balance when unfrozenV2 has not expired', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('sums multiple expired unfrozen entries', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const pastTime1 = Date.now() - 1000; + const pastTime2 = Date.now() - 2000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime1 }, + { unfreeze_amount: 2000000, unfreeze_expire_time: pastTime2 }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('3000000'); + }, + ); + }); + + it('only includes expired entries when mixed with non-expired', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const pastTime = Date.now() - 1000; + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + { + unfreeze_amount: 5000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const readyForWithdrawalAsset = findAsset( + assets, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ); + expect(readyForWithdrawalAsset).toBeDefined(); + expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + }); + + describe('TRX in lock period', () => { + it('returns zero balance when account has no unfrozenV2 data', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('returns in lock period amount when unfrozenV2 has future entries', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('1000000'); + }, + ); + }); + + it('returns zero balance when unfrozenV2 has already expired', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const pastTime = Date.now() - 1000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: pastTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + + it('sums multiple non-expired unfrozen entries', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const futureTime1 = Date.now() + 1000000; + const futureTime2 = Date.now() + 2000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { + unfreeze_amount: 1000000, + unfreeze_expire_time: futureTime1, + }, + { + unfreeze_amount: 2000000, + unfreeze_expire_time: futureTime2, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('3000000'); + }, + ); + }); + + it('only includes non-expired entries when mixed with expired', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + const pastTime = Date.now() - 1000; + const futureTime = Date.now() + 1000000; + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + unfrozenV2: [ + { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, + { + unfreeze_amount: 5000000, + unfreeze_expire_time: futureTime, + }, + ], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('5000000'); + }, + ); + }); + + it('returns zero balance for inactive accounts', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('account not found'), + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const inLockPeriodAsset = findAsset( + assets, + KnownCaip19Id.TrxInLockPeriodMainnet, + ); + expect(inLockPeriodAsset).toBeDefined(); + expect(inLockPeriodAsset?.rawAmount).toBe('0'); + }, + ); + }); + }); + + describe('energy', () => { + it('returns 0 when account has no resources', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns full energy when none consumed', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + + it('returns remaining energy after partial consumption', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('617'); + }, + ); + }); + + it('clamps to 0 when EnergyUsed exceeds EnergyLimit from leasing', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + + describe('maximum energy', () => { + it('returns 0 when account has no resources', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns EnergyLimit from staking', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + }); + + describe('staking rewards', () => { + it('returns 0 when account has no staking rewards', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockResolvedValue(0); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns staking rewards when account has unclaimed rewards', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockResolvedValue(5000000); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const stakingRewardsAsset = findAsset( + assets, + KnownCaip19Id.TrxStakingRewardsMainnet, + ); + expect(stakingRewardsAsset?.rawAmount).toBe('5000000'); + expect(stakingRewardsAsset?.uiAmount).toBe('5'); + expect(stakingRewardsAsset?.symbol).toBe('trx-staking-rewards'); + }, + ); + }); + + it('gracefully handles staking rewards API failure', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockTrongridApiClient, mockTronHttpClient }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + mockTronHttpClient.getReward.mockRejectedValue( + new Error('API Error'), + ); + + const assets = await adapter.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) + ?.rawAmount, + ).toBe('0'); + }, + ); + }); + }); + }); + + describe('getHistoricalPrice', () => { + it('tracks historical price errors', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockSnapClient, mockPriceApiClient }) => { + const error = new Error('Price error'); + + mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); + + await adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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); + + await adapter.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + 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 withSnapAssetsAdapter(async ({ adapter, 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 adapter.saveMany(assets); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.TrxMainnet], + removed: [trc20AssetId], + }, + }, + }, + ); + }); + }); + + it('updates stale non-essential assets balance to 0 if missed from the latest snapshot', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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 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]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + finalSavedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.TrxMainnet], + removed: [trc20AssetId], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [trc20AssetId]: { + unit: 'USDT', + amount: '0', + }, + }, + }, + }, + ); + }, + ); + }); + + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('handles multiple assets updating from 0 to >0 simultaneously', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 withSnapAssetsAdapter( + async ({ adapter, 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 adapter.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 () => { + await withSnapAssetsAdapter( + async ({ adapter, 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: '100000', + uiAmount: '100000', + 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: '35000', + uiAmount: '35000', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '35000', + }, + }, + }, + }, + ); + }, + ); + }); + + it('updates bandwidth balance when it decreases but remains >0', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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 adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '4700', + }, + }, + }, + }, + ); + }, + ); + }); + + it('updates TRC20 token balance when it decreases but remains >0', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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 adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps energy in the list when it drops to 0', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('keeps bandwidth in the list when it drops to 0', async () => { + await withSnapAssetsAdapter( + async ({ adapter, 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: '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, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }, + ); + }); + + it('handles both energy and bandwidth fluctuating in a transaction', async () => { + await withSnapAssetsAdapter( + async ({ adapter, mockState, mockAssetsRepository }) => { + 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', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await adapter.saveMany(updatedAssets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( + updatedAssets, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '2', + }, + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '45000', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '1235', + }, + }, + }, + }, + ); + }, + ); + }); + }); + }); + + describe('getAssetsMetadata', () => { + it('resolves metadata for native, protocol, and token asset types', async () => { + await withSnapAssetsAdapter(async ({ adapter, mockTokenApiClient }) => { + const trc20 = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; + const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; + + mockTokenApiClient.getTokensMetadata.mockResolvedValue({ + [trc20]: { + fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, + }, + [trc10]: { + fungible: { symbol: 'T', name: 'Token', decimals: 0 }, + }, + } as never); + + const assetTypes = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + trc10, + trc20, + ]; + + const metadata = await adapter.getAssetsMetadata(assetTypes); + + expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); + expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); + expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ + trc10, + trc20, + ]); + }); + }); + }); + + describe('isFiat', () => { + it('returns true only for fiat CAIP asset IDs', () => { + expect(SnapAssetsAdapter.isFiat('eip155:1/erc20:0x0')).toBe(false); + expect(SnapAssetsAdapter.isFiat('swift:0/iso4217:usd')).toBe(true); + }); + }); + + describe('hasChanged', () => { + it('returns true when the asset is missing or the amount differs', () => { + const asset: AssetEntity = { + iconUrl: '', + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1', + uiAmount: '1', + }; + + expect(SnapAssetsAdapter.hasChanged(asset, [])).toBe(true); + expect(SnapAssetsAdapter.hasChanged(asset, [asset])).toBe(false); + }); + }); +}); From 6f224658520b19e1a0acaf71b630ffd720ba46b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 10:37:16 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(tron-wallet-snap):=20fix=20AssetsServi?= =?UTF-8?q?ce=20fa=C3=A7ade=20test=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use expect(await ...) instead of .resolves, and drop the unused no-explicit-any suppression from the old service test file. Co-authored-by: Ulisses Ferreira --- eslint-suppressions.json | 5 -- .../src/services/assets/AssetsService.test.ts | 80 +++++++++---------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 9492bd3d..43ac1cf0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1407,11 +1407,6 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts": { "no-restricted-syntax": { "count": 1 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 02da28e2..fd18d133 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -153,9 +153,9 @@ describe('AssetsService', () => { const { assetsService, snapAdapter, coreAdapter } = createAssetsService(true); - await expect( - assetsService.getAccountAssetsByIDs(mockAccount.id, []), - ).resolves.toStrictEqual([]); + expect( + await assetsService.getAccountAssetsByIDs(mockAccount.id, []), + ).toStrictEqual([]); expect(snapAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); expect(coreAdapter.getAccountAssetsByIDs).not.toHaveBeenCalled(); }); @@ -165,11 +165,11 @@ describe('AssetsService', () => { createAssetsService(false); snapAdapter.getAccountAssetsByIDs.mockResolvedValue([mockAsset]); - await expect( - assetsService.getAccountAssetsByIDs(mockAccount.id, [ + expect( + await assetsService.getAccountAssetsByIDs(mockAccount.id, [ KnownCaip19Id.TrxMainnet, ]), - ).resolves.toStrictEqual([mockAsset]); + ).toStrictEqual([mockAsset]); expect(snapAdapter.getAccountAssetsByIDs).toHaveBeenCalledWith( mockAccount.id, [KnownCaip19Id.TrxMainnet], @@ -186,11 +186,11 @@ describe('AssetsService', () => { } = createAssetsService(true); coreAdapter.getAccountAssetsByIDs.mockResolvedValue([mockAsset]); - await expect( - assetsService.getAccountAssetsByIDs(mockAccount.id, [ + expect( + await assetsService.getAccountAssetsByIDs(mockAccount.id, [ KnownCaip19Id.TrxMainnet, ]), - ).resolves.toStrictEqual([mockAsset]); + ).toStrictEqual([mockAsset]); expect(remoteFeatureFlagsProvider.getFeatureFlag).toHaveBeenCalledWith( TRON_FLAG_KEY, ); @@ -208,12 +208,12 @@ describe('AssetsService', () => { createAssetsService(false); snapAdapter.getAccountAssetByID.mockResolvedValue(mockAsset); - await expect( - assetsService.getAccountAssetByID( + expect( + await assetsService.getAccountAssetByID( mockAccount.id, KnownCaip19Id.TrxMainnet, ), - ).resolves.toBe(mockAsset); + ).toBe(mockAsset); expect(snapAdapter.getAccountAssetByID).toHaveBeenCalledWith( mockAccount.id, KnownCaip19Id.TrxMainnet, @@ -226,12 +226,12 @@ describe('AssetsService', () => { createAssetsService(true); coreAdapter.getAccountAssetByID.mockResolvedValue(mockAsset); - await expect( - assetsService.getAccountAssetByID( + expect( + await assetsService.getAccountAssetByID( mockAccount.id, KnownCaip19Id.TrxMainnet, ), - ).resolves.toBe(mockAsset); + ).toBe(mockAsset); expect(coreAdapter.getAccountAssetByID).toHaveBeenCalledWith( mockAccount.id, KnownCaip19Id.TrxMainnet, @@ -248,12 +248,12 @@ describe('AssetsService', () => { mockAsset, ]); - await expect( - assetsService.fetchAssetsAndBalancesForAccount( + expect( + await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, ), - ).resolves.toStrictEqual([mockAsset]); + ).toStrictEqual([mockAsset]); expect(snapAdapter.fetchAssetsAndBalancesForAccount).toHaveBeenCalledWith( Network.Mainnet, mockAccount, @@ -270,12 +270,12 @@ describe('AssetsService', () => { mockAsset, ]); - await expect( - assetsService.fetchAssetsAndBalancesForAccount( + expect( + await assetsService.fetchAssetsAndBalancesForAccount( Network.Mainnet, mockAccount, ), - ).resolves.toStrictEqual([mockAsset]); + ).toStrictEqual([mockAsset]); expect(coreAdapter.fetchAssetsAndBalancesForAccount).toHaveBeenCalledWith( Network.Mainnet, mockAccount, @@ -314,9 +314,9 @@ describe('AssetsService', () => { createAssetsService(false); snapAdapter.getAccountAssets.mockResolvedValue([mockAsset]); - await expect( - assetsService.getAccountAssets(mockAccount.id), - ).resolves.toStrictEqual([mockAsset]); + expect( + await assetsService.getAccountAssets(mockAccount.id), + ).toStrictEqual([mockAsset]); expect(snapAdapter.getAccountAssets).toHaveBeenCalledWith(mockAccount.id); expect(coreAdapter.getAccountAssets).not.toHaveBeenCalled(); }); @@ -326,9 +326,9 @@ describe('AssetsService', () => { createAssetsService(true); coreAdapter.getAccountAssets.mockResolvedValue([mockAsset]); - await expect( - assetsService.getAccountAssets(mockAccount.id), - ).resolves.toStrictEqual([mockAsset]); + expect( + await assetsService.getAccountAssets(mockAccount.id), + ).toStrictEqual([mockAsset]); expect(coreAdapter.getAccountAssets).toHaveBeenCalledWith(mockAccount.id); expect(snapAdapter.getAccountAssets).not.toHaveBeenCalled(); }); @@ -340,9 +340,9 @@ describe('AssetsService', () => { const metadata = { [KnownCaip19Id.TrxMainnet]: null }; snapAdapter.getAssetsMetadata.mockResolvedValue(metadata); - await expect( - assetsService.getAssetsMetadata([KnownCaip19Id.TrxMainnet]), - ).resolves.toBe(metadata); + expect( + await assetsService.getAssetsMetadata([KnownCaip19Id.TrxMainnet]), + ).toBe(metadata); expect(snapAdapter.getAssetsMetadata).toHaveBeenCalledWith([ KnownCaip19Id.TrxMainnet, ]); @@ -352,7 +352,7 @@ describe('AssetsService', () => { const { assetsService, snapAdapter } = createAssetsService(true); snapAdapter.getAll.mockResolvedValue([mockAsset]); - await expect(assetsService.getAll()).resolves.toStrictEqual([mockAsset]); + expect(await assetsService.getAll()).toStrictEqual([mockAsset]); expect(snapAdapter.getAll).toHaveBeenCalledWith(); }); @@ -367,9 +367,9 @@ describe('AssetsService', () => { const result = { [KnownCaip19Id.TrxMainnet]: {} }; snapAdapter.getMultipleTokenConversions.mockResolvedValue(result); - await expect( - assetsService.getMultipleTokenConversions(conversions), - ).resolves.toBe(result); + expect(await assetsService.getMultipleTokenConversions(conversions)).toBe( + result, + ); expect(snapAdapter.getMultipleTokenConversions).toHaveBeenCalledWith( conversions, ); @@ -386,9 +386,9 @@ describe('AssetsService', () => { const result = { [KnownCaip19Id.TrxMainnet]: {} }; snapAdapter.getMultipleTokensMarketData.mockResolvedValue(result); - await expect( - assetsService.getMultipleTokensMarketData(assets), - ).resolves.toBe(result); + expect(await assetsService.getMultipleTokensMarketData(assets)).toBe( + result, + ); expect(snapAdapter.getMultipleTokensMarketData).toHaveBeenCalledWith( assets, ); @@ -399,12 +399,12 @@ describe('AssetsService', () => { const result = { intervals: {}, updateTime: 1 }; snapAdapter.getHistoricalPrice.mockResolvedValue(result); - await expect( - assetsService.getHistoricalPrice( + expect( + await assetsService.getHistoricalPrice( KnownCaip19Id.TrxMainnet, 'swift:0/iso4217:usd', ), - ).resolves.toBe(result); + ).toBe(result); expect(snapAdapter.getHistoricalPrice).toHaveBeenCalledWith( KnownCaip19Id.TrxMainnet, 'swift:0/iso4217:usd',