From 65d9813a37c30d67ae117705f4524e143f3fb20b Mon Sep 17 00:00:00 2001 From: Ulisses Date: Tue, 3 Feb 2026 14:19:42 +0000 Subject: [PATCH 1/6] feat(NEB-461): support TRC20 token balances for inactive accounts Add fallback TRC20 balance fetching for inactive Tron accounts using the `/v1/accounts/{address}/trc20/balance` endpoint when the main account info endpoint returns no data (inactive accounts that haven't paid the 1 TRX activation fee). - Add `getTrc20BalancesByAddress()` method to TrongridApiClient - Implement fallback logic in AssetsService for inactive accounts - Add helper methods for zero-balance native assets and TRC20 extraction - Add comprehensive unit tests for new functionality --- packages/snap/CHANGELOG.md | 1 + .../src/clients/tron-http/TronHttpClient.ts | 10 +- .../trongrid/TrongridApiClient.test.ts | 263 ++++++++++ .../src/clients/trongrid/TrongridApiClient.ts | 60 ++- packages/snap/src/clients/trongrid/structs.ts | 19 + packages/snap/src/clients/trongrid/types.ts | 6 + .../src/services/assets/AssetsService.test.ts | 284 ++++++++++- .../snap/src/services/assets/AssetsService.ts | 457 +++++++++++------- 8 files changed, 920 insertions(+), 180 deletions(-) create mode 100644 packages/snap/src/clients/trongrid/TrongridApiClient.test.ts diff --git a/packages/snap/CHANGELOG.md b/packages/snap/CHANGELOG.md index 91c63d89..56a2aba1 100644 --- a/packages/snap/CHANGELOG.md +++ b/packages/snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Support fetching TRC20 token balances for inactive accounts using fallback endpoint ([#190](https://github.com/MetaMask/snap-tron-wallet/pull/190)) - Add security scanning for tokens sends ([#205](https://github.com/MetaMask/snap-tron-wallet/pull/205)) ### Fixed diff --git a/packages/snap/src/clients/tron-http/TronHttpClient.ts b/packages/snap/src/clients/tron-http/TronHttpClient.ts index 6237b528..82e9eb6c 100644 --- a/packages/snap/src/clients/tron-http/TronHttpClient.ts +++ b/packages/snap/src/clients/tron-http/TronHttpClient.ts @@ -118,12 +118,14 @@ export class TronHttpClient { } /** - * Get account resources (Energy and Bandwidth) + * Get account resources (Energy and Bandwidth). + * For inactive accounts, returns an empty object `{}` with all resource values effectively being 0. * * @see https://developers.tron.network/reference/getaccountresource - * @param network - Network to query - * @param accountAddress - Account address in base58 format - * @returns Promise - Account resources + * @param network - Network to query. + * @param accountAddress - Account address in base58 format. + * @returns Promise - Account resources (energy, bandwidth, etc.). + * @throws Error - HTTP errors or configuration errors. */ async getAccountResources( network: Network, diff --git a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts new file mode 100644 index 00000000..f5ba14a2 --- /dev/null +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -0,0 +1,263 @@ +/* eslint-disable no-restricted-globals */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { TrongridApiClient } from './TrongridApiClient'; +import type { Trc20Balance } from './types'; +import type { ICache } from '../../caching/ICache'; +import { Network } from '../../constants'; +import { ConfigProvider } from '../../services/config'; +import type { Serializable } from '../../utils/serialization/types'; +import { TronHttpClient } from '../tron-http/TronHttpClient'; + +describe('TrongridApiClient', () => { + let client: TrongridApiClient; + let mockConfigProvider: ConfigProvider; + let mockTronHttpClient: TronHttpClient; + let mockCache: ICache; + + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigProvider = new ConfigProvider(); + const baseConfig = mockConfigProvider.get(); + jest.spyOn(mockConfigProvider, 'get').mockReturnValue({ + ...baseConfig, + trongridApi: { + baseUrls: { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: 'https://nile.trongrid.io', + [Network.Shasta]: 'https://api.shasta.trongrid.io', + }, + }, + tronHttpApi: { + baseUrls: { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: 'https://nile.trongrid.io', + [Network.Shasta]: 'https://api.shasta.trongrid.io', + }, + }, + }); + + mockTronHttpClient = new TronHttpClient({ + configProvider: mockConfigProvider, + }); + + mockCache = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + clear: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + size: jest.fn(), + peek: jest.fn(), + mget: jest.fn(), + mset: jest.fn(), + mdelete: jest.fn(), + }; + + client = new TrongridApiClient({ + configProvider: mockConfigProvider, + tronHttpClient: mockTronHttpClient, + cache: mockCache, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('getTrc20BalancesByAddress', () => { + const mockAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; + const normalizeBalances = (balances: Trc20Balance[]): Trc20Balance[] => + balances.map((balance) => ({ ...balance })); + + it('fetches and returns TRC20 balances for an address', async () => { + const mockTrc20Balances: Trc20Balance[] = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, + { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, + ]; + + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + meta: { at: 1770121997373, page_size: 4 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + expect(global.fetch).toHaveBeenCalledWith( + `https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('returns empty array when no TRC20 tokens are found', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: [], + success: true, + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array when data is undefined', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); + + it('throws error when network base URL is invalid', async () => { + // Create a client with invalid testnet base URLs + const limitedConfigProvider = new ConfigProvider(); + const limitedBaseConfig = limitedConfigProvider.get(); + jest.spyOn(limitedConfigProvider, 'get').mockReturnValue({ + ...limitedBaseConfig, + trongridApi: { + baseUrls: { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: '', + [Network.Shasta]: '', + }, + }, + tronHttpApi: { + baseUrls: { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: '', + [Network.Shasta]: '', + }, + }, + }); + + const limitedClient = new TrongridApiClient({ + configProvider: limitedConfigProvider, + tronHttpClient: mockTronHttpClient, + cache: mockCache, + }); + + await expect( + limitedClient.getTrc20BalancesByAddress(Network.Nile, mockAddress), + ).rejects.toThrow('Invalid URL format'); + }); + + it('throws error when HTTP request fails', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response('', { status: 500 })); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('HTTP error! status: 500'); + }); + + it('throws error when API returns success: false', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: [], + success: false, + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('API request failed'); + }); + + it('works with different networks', async () => { + const mockTrc20Balances: Trc20Balance[] = [{ TTestToken123: '1000000' }]; + + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Nile, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('nile.trongrid.io'), + expect.any(Object), + ); + }); + + it('validates TRC20 balance data structure', async () => { + // Valid structure: array of Record + const validBalances: Trc20Balance[] = [ + { TokenAddress1: '100' }, + { TokenAddress2: '200' }, + ]; + + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: validBalances, + success: true, + meta: { at: 1770121997373, page_size: 2 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(2); + const normalizedBalances = normalizeBalances(result); + expect(normalizedBalances[0]).toStrictEqual({ TokenAddress1: '100' }); + expect(normalizedBalances[1]).toStrictEqual({ TokenAddress2: '200' }); + }); + }); +}); diff --git a/packages/snap/src/clients/trongrid/TrongridApiClient.ts b/packages/snap/src/clients/trongrid/TrongridApiClient.ts index 13221cae..23968362 100644 --- a/packages/snap/src/clients/trongrid/TrongridApiClient.ts +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.ts @@ -2,12 +2,14 @@ import { assert } from '@metamask/superstruct'; import { ContractTransactionInfoStruct, + Trc20BalanceStruct, TransactionInfoStruct, TronAccountStruct, TrongridApiMetaStruct, } from './structs'; import type { ContractTransactionInfo, + Trc20Balance, TransactionInfo, TronAccount, TrongridApiResponse, @@ -79,12 +81,15 @@ export class TrongridApiClient { } /** - * Get account information by address for a specific network. The returned data will also have assets information. + * Get account information by address for a specific network. + * The returned data includes TRX balance, TRC10 assets and TRC20 token balances. * * @see https://developers.tron.network/reference/get-account-info-by-address * @param scope - The network to query (e.g., 'mainnet', 'shasta') * @param address - The TRON address to query - * @returns Promise - Account data in camelCase + * @returns Promise - Account data including balances. + * @throws Error - "Account not found or no data returned" for inactive accounts. + * @throws Error - HTTP errors or API failures. */ async getAccountInfoByAddress( scope: Network, @@ -234,6 +239,57 @@ export class TrongridApiClient { return rawData.data; } + /** + * Get TRC20 token balances for an account address. + * This endpoint works for inactive accounts that haven't been activated yet. + * + * @see https://developers.tron.network/reference/get-trc20-token-holder-balances + * @param scope - The network to query (e.g., 'mainnet', 'shasta') + * @param address - The TRON address to query + * @returns Promise - Array of TRC20 balances (contract address -> balance) + */ + async getTrc20BalancesByAddress( + scope: Network, + address: string, + ): Promise { + const client = this.#clients.get(scope); + if (!client) { + throw new Error(`No client configured for network: ${scope}`); + } + + const { baseUrl, headers } = client; + const url = buildUrl({ + baseUrl, + path: '/v1/accounts/{address}/trc20/balance', + pathParams: { address }, + }); + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawData: TrongridApiResponse = await response.json(); + + // Validate API response structure + if (typeof rawData.success !== 'boolean' || !rawData.success) { + throw new Error('API request failed'); + } + assert(rawData.meta, TrongridApiMetaStruct); + + if (!rawData.data) { + return []; + } + + // Validate each TRC20 balance entry + for (const balance of rawData.data) { + assert(balance, Trc20BalanceStruct); + } + + return rawData.data; + } + /** * Get chain parameters for a specific network. * Results are cached until the next maintenance period (every ~6 hours). diff --git a/packages/snap/src/clients/trongrid/structs.ts b/packages/snap/src/clients/trongrid/structs.ts index 96c8c115..e35ada50 100644 --- a/packages/snap/src/clients/trongrid/structs.ts +++ b/packages/snap/src/clients/trongrid/structs.ts @@ -221,3 +221,22 @@ export const TrongridContractTransactionInfoResponseStruct = type({ success: boolean(), meta: TrongridApiMetaStruct, }); + +// -------------------------------------------------------------------------- +// TRC20 Balance Response Structs (for inactive account fallback) +// -------------------------------------------------------------------------- + +/** + * Struct for validating individual TRC20 balance entries. + * Each entry is a record mapping contract address to balance string. + */ +export const Trc20BalanceStruct = record(string(), string()); + +/** + * Struct for validating the TRC20 balance API response. + */ +export const TrongridTrc20BalanceResponseStruct = type({ + data: array(Trc20BalanceStruct), + success: boolean(), + meta: TrongridApiMetaStruct, +}); diff --git a/packages/snap/src/clients/trongrid/types.ts b/packages/snap/src/clients/trongrid/types.ts index 16ebd841..aba79f98 100644 --- a/packages/snap/src/clients/trongrid/types.ts +++ b/packages/snap/src/clients/trongrid/types.ts @@ -192,3 +192,9 @@ export type TokenInfo = { decimals: number; name: string; }; + +/** + * TRC20 balance entry from the /v1/accounts/{address}/trc20/balance endpoint. + * Each entry is a record mapping the contract address to its balance. + */ +export type Trc20Balance = Record; diff --git a/packages/snap/src/services/assets/AssetsService.test.ts b/packages/snap/src/services/assets/AssetsService.test.ts index 85397872..dfd1ad39 100644 --- a/packages/snap/src/services/assets/AssetsService.test.ts +++ b/packages/snap/src/services/assets/AssetsService.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable no-restricted-globals */ import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; @@ -37,9 +35,11 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ })); // Mock global snap object +// eslint-disable-next-line no-restricted-globals (global as any).snap = {}; // Import AssetsService after mocking context +// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals const { AssetsService } = require('./AssetsService'); /* eslint-disable @typescript-eslint/naming-convention */ @@ -113,13 +113,19 @@ describe('AssetsService', () => { Pick, 'getKey' | 'setKey'> >; let mockTrongridApiClient: jest.Mocked< - Pick + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > >; let mockTronHttpClient: jest.Mocked< Pick >; let mockPriceApiClient: jest.Mocked< - Pick + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > >; let mockTokenApiClient: jest.Mocked< Pick @@ -151,15 +157,18 @@ describe('AssetsService', () => { mockTrongridApiClient = { getAccountInfoByAddress: jest.fn(), + getTrc20BalancesByAddress: jest.fn(), }; mockTronHttpClient = { getAccountResources: jest.fn(), }; mockPriceApiClient = { - getMultipleSpotPrices: jest.fn(), + getFiatExchangeRates: jest.fn(), + getHistoricalPrices: jest.fn(), + getMultipleSpotPrices: jest.fn().mockResolvedValue({}), }; mockTokenApiClient = { - getTokensMetadata: jest.fn(), + getTokensMetadata: jest.fn().mockResolvedValue({}), }; assetsService = new AssetsService({ @@ -173,6 +182,269 @@ describe('AssetsService', () => { }); }); + describe('fetchAssetsAndBalancesForAccount', () => { + describe('inactive account fallback', () => { + it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + // Arrange: Account info fails (inactive account doesn't exist on-chain) + // Note: getAccountResources returns {} for inactive accounts, not an error + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + + // TRC20 fallback endpoint returns some balances + const trc20Balances = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20Balances, + ); + + // Mock price API to return prices for the TRC20 tokens + const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ + [trc20AssetId]: { + id: trc20AssetId, + price: 1.0, + }, + } as never); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: Should have called the fallback endpoint + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + // Assert: Should return TRX with zero balance and TRC20 tokens + const trxAsset = assets.find( + (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const trc20Asset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === expectedTrc20AssetType, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('24249143'); + }); + + it('returns zero TRX and resources when fallback also returns empty', async () => { + // Arrange: Account info fails (inactive account) + // Note: getAccountResources returns {} for inactive accounts, not an error + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + + // TRC20 fallback endpoint returns empty (no tokens) + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue([]); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: Should have called the fallback endpoint + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + // Assert: Should return TRX with zero balance and zero resources + const trxAsset = assets.find( + (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + // Assert: Bandwidth and energy should also be present with zero values + 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('gracefully handles fallback endpoint failure', async () => { + // Arrange: Account info fails (inactive account) + // Note: getAccountResources returns {} for inactive accounts, not an error + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + + // TRC20 fallback endpoint also fails + mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( + new Error('Network error'), + ); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: Should still return TRX with zero balance + const trxAsset = assets.find( + (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + }); + + it('filters out TRC20 tokens without price data from inactive account', async () => { + // Arrange: Account info fails (inactive account) + // Note: getAccountResources returns {} for inactive accounts, not an error + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + + // TRC20 fallback returns tokens including a spam token + const trc20BalancesWithSpam = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price + { TSpamToken123456789: '1000000000' }, // Spam token - no price + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20BalancesWithSpam as never, + ); + + // Mock price API to only return price for USDT + const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ + [usdtAssetId]: { + id: usdtAssetId, + price: 1.0, + }, + // No price for spam token + } as never); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: USDT should be included + const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const usdtAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === usdtAssetType, + ); + expect(usdtAsset).toBeDefined(); + + // Assert: Spam token should be filtered out + const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; + const spamAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === spamAssetType, + ); + expect(spamAsset).toBeUndefined(); + }); + }); + + describe('partial failure handling', () => { + it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + // Arrange: Account info fails (inactive account), resources succeed with empty object + // This matches real API behavior: getAccountResources returns {} for inactive accounts + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + freeNetLimit: 600, + NetLimit: 0, + EnergyLimit: 0, + } as never); + + // TRC20 fallback endpoint returns some balances + const trc20Balances = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20Balances, + ); + + // Mock price API for the TRC20 token + const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + } as never); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: Should have used the fallback endpoint + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + + // Assert: Should return zero TRX (fallback behavior) + const trxAsset = assets.find( + (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + + // Assert: Should include TRC20 from fallback + const trc20Asset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === trc20AssetId, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('100000'); + }); + + it('continues with zero resources when only resources request fails', async () => { + // Arrange: Account info succeeds, resources fail + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ + address: mockAccount.address, + balance: 1000000, + trc20: [], + } as never); + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Resources endpoint unavailable'), + ); + + // Act + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + // Assert: Should return TRX balance from account info + const trxAsset = assets.find( + (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('1000000'); + + // Assert: Bandwidth and energy should be 0 (default) + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('0'); + }); + }); + }); + describe('saveMany', () => { it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { // Arrange: Create assets with zero amounts for energy and bandwidth diff --git a/packages/snap/src/services/assets/AssetsService.ts b/packages/snap/src/services/assets/AssetsService.ts index 33d0b623..c1ab3a9d 100644 --- a/packages/snap/src/services/assets/AssetsService.ts +++ b/packages/snap/src/services/assets/AssetsService.ts @@ -40,7 +40,7 @@ import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { TronAccount } from '../../clients/trongrid/types'; +import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; import type { KnownCaip19Id, Network } from '../../constants'; import { BANDWIDTH_METADATA, @@ -60,6 +60,27 @@ import { toUiAmount } from '../../utils/conversion'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; +/** + * Normalized account data structure that provides a consistent shape for both + * active and inactive accounts. This allows extraction functions to work + * without needing to know the account's activation state. + */ +type NormalizedAccountData = { + /** Native TRX balance in sun (0 for inactive accounts). */ + nativeBalance: number; + /** TRC10 token balances as `{ key: tokenId, value: balance }[]` (empty for inactive accounts). */ + trc10Balances: TronAccount['assetV2']; + /** TRC20 token balances from either account info or fallback endpoint. */ + trc20Balances: Trc20Balance[]; + /** Staking data including frozen balances and delegated resources. */ + stakedData: { + frozenV2: TronAccount['frozenV2']; + accountResource: TronAccount['account_resource'] | undefined; + }; + /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ + resources: AccountResources | Record; +}; + export class AssetsService { readonly #logger: ILogger; @@ -138,6 +159,26 @@ export class AssetsService { ); } + /** + * Fetches all assets and balances for an account. + * + * Data Sources: + * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) + * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) + * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) + * + * Logic Flow: + * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) + * 2. Normalize data into consistent shape via `#buildAccountData` + * 3. Extract all assets via `#extractAssets` + * 4. Fetch metadata and prices in parallel + * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` + * 6. Filter spam tokens via `#filterTokensWithoutPriceData` + * + * @param scope - The network to query. + * @param account - The keyring account. + * @returns Promise - Array of assets with balances. + */ async fetchAssetsAndBalancesForAccount( scope: Network, account: KeyringAccount, @@ -147,83 +188,189 @@ export class AssetsService { scope, }); + // --- DATA FETCHING --- const [tronAccountInfoRequest, tronAccountResourcesRequest] = await Promise.allSettled([ this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), this.#tronHttpClient.getAccountResources(scope, account.address), ]); - if ( - tronAccountInfoRequest.status === 'rejected' || - tronAccountResourcesRequest.status === 'rejected' - ) { - const errorMessage = `Failed to fetch account info or account resources for ${account.address} on network ${scope}`; - this.#logger.error(errorMessage); - throw new Error(errorMessage); + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + if (isInactiveAccount) { + this.#logger.info( + 'Account info request failed, treating as inactive account', + { account, scope }, + ); } - const nativeAsset = this.#extractNativeAsset({ - account, - scope, - tronAccountInfo: tronAccountInfoRequest.value, - }); - const stakedNativeAssets = this.#extractStakedNativeAssets({ - account, - scope, - tronAccountInfo: tronAccountInfoRequest.value, - }); - const bandwidthAssets = this.#extractBandwidth({ - account, - scope, - tronAccountResources: tronAccountResourcesRequest.value, - }); - const energyAssets = this.#extractEnergy({ - account, - scope, - tronAccountResources: tronAccountResourcesRequest.value, - }); - const trc10Assets = this.#extractTrc10Assets({ - account, - scope, - tronAccountInfo: tronAccountInfoRequest.value, - }); - const trc20Assets = this.#extractTrc20Assets({ - account, - scope, - tronAccountInfo: tronAccountInfoRequest.value, + const trc20BalancesFallback = isInactiveAccount + ? await this.#trongridApiClient + .getTrc20BalancesByAddress(scope, account.address) + .catch((error) => { + this.#logger.warn( + 'Failed to fetch TRC20 balances for inactive account', + { error, account, scope }, + ); + return []; + }) + : []; + + // --- NORMALIZE DATA --- + const accountData = this.#buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, }); - const assetTypes = [ - nativeAsset.assetType, - ...stakedNativeAssets.map((assets) => assets.assetType), - ...bandwidthAssets.map((assets) => assets.assetType), - ...energyAssets.map((assets) => assets.assetType), - ...trc10Assets.map((assets) => assets.assetType), - ...trc20Assets.map((assets) => assets.assetType), - ]; - // Only native, TRC10, and TRC20 have Price API-compliant CAIP IDs - // (staked, energy, bandwidth have non-compliant IDs that would fail) - const priceableAssetTypes = [ - nativeAsset.assetType, - ...trc10Assets.map((assets) => assets.assetType), - ...trc20Assets.map((assets) => assets.assetType), - ]; + // --- EXTRACT ASSETS --- + const rawAssets = this.#extractAssets(account, scope, accountData); + + // --- FETCH METADATA & PRICES --- + const assetTypes = rawAssets.map((asset) => asset.assetType); + const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); const [assetsMetadata, spotPrices] = await Promise.all([ this.getAssetsMetadata(assetTypes), this.#priceApiClient .getMultipleSpotPrices(priceableAssetTypes, 'usd') - .catch(() => ({})), // If prices fail, return empty - filtering will handle it + .catch(() => ({})), ]); - const assets = [ - nativeAsset, - ...stakedNativeAssets, - ...bandwidthAssets, - ...energyAssets, - ...trc10Assets, - ...trc20Assets, - ].map((asset) => { + // --- ENRICH & FILTER --- + const enrichedAssets = this.#enrichAssetsWithMetadata( + rawAssets, + assetsMetadata, + ); + return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); + } + + /** + * Filters out spam tokens (those without price data). + * Essential assets are always kept. Tokens need price data to be included. + * + * @param assets - The assets to filter. + * @param spotPrices - Pre-fetched USD prices for assets. + * @returns The filtered assets. + */ + #filterTokensWithoutPriceData( + assets: AssetEntity[], + spotPrices: SpotPrices | Record, + ): AssetEntity[] { + const filtered = assets.filter((asset) => { + // Essential assets (TRX, staked, energy, bandwidth) are always kept + if (ESSENTIAL_ASSETS.includes(asset.assetType)) { + return true; + } + // Tokens: keep only if they have price data + const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; + return typeof spotPrice?.price === 'number'; + }); + + return filtered; + } + + /** + * Normalizes raw API responses into a consistent shape for both active and inactive accounts. + * This allows extraction functions to work without needing to know the account's activation state. + * + * @param params - The raw API responses to normalize. + * @param params.tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. + * @param params.tronAccountResourcesRequest - The settled promise result from getAccountResources. + * @param params.trc20BalancesFallback - TRC20 balances from fallback endpoint (empty for active accounts). + * @returns NormalizedAccountData - Consistent data shape for extraction. + */ + #buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, + }: { + tronAccountInfoRequest: PromiseSettledResult; + tronAccountResourcesRequest: PromiseSettledResult; + trc20BalancesFallback: Trc20Balance[]; + }): NormalizedAccountData { + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + const resources = + tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; + + if (isInactiveAccount) { + return { + nativeBalance: 0, + trc10Balances: [], + trc20Balances: trc20BalancesFallback, + stakedData: { frozenV2: [], accountResource: undefined }, + resources, + }; + } + + const tronAccountInfo = tronAccountInfoRequest.value; + return { + nativeBalance: tronAccountInfo.balance ?? 0, + trc10Balances: tronAccountInfo.assetV2 ?? [], + trc20Balances: tronAccountInfo.trc20 ?? [], + stakedData: { + frozenV2: tronAccountInfo.frozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }, + resources, + }; + } + + /** + * Extracts all assets from normalized account data. + * Coordinates calls to individual extraction functions. + * + * @param account - The keyring account. + * @param scope - The network. + * @param data - Normalized account data. + * @returns AssetEntity[] - Array of all extracted assets. + */ + #extractAssets( + account: KeyringAccount, + scope: Network, + data: NormalizedAccountData, + ): AssetEntity[] { + return [ + this.#extractNativeAsset(account, scope, data.nativeBalance), + ...this.#extractStakedNativeAssets(account, scope, data.stakedData), + ...this.#extractTrc10Assets(account, scope, data.trc10Balances), + ...this.#extractTrc20Assets(account, scope, data.trc20Balances), + ...this.#extractBandwidth(account, scope, data.resources), + ...this.#extractEnergy(account, scope, data.resources), + ]; + } + + /** + * Returns the asset types that can be priced (native, TRC10, TRC20). + * Staked, energy, and bandwidth assets have non-compliant CAIP IDs that would fail the Price API. + * + * @param assets - Array of assets to filter. + * @returns CaipAssetType[] - Array of priceable asset types. + */ + #getPriceableAssetTypes(assets: AssetEntity[]): CaipAssetType[] { + return assets + .filter( + (asset) => + asset.assetType.includes('/slip44:') || + asset.assetType.includes('/trc10:') || + asset.assetType.includes('/trc20:'), + ) + .map((asset) => asset.assetType); + } + + /** + * Enriches assets with metadata (symbol, decimals, iconUrl) and calculates uiAmount. + * + * @param assets - Raw assets to enrich. + * @param assetsMetadata - Metadata lookup by asset type. + * @returns AssetEntity[] - Enriched assets. + */ + #enrichAssetsWithMetadata( + assets: AssetEntity[], + assetsMetadata: Record, + ): AssetEntity[] { + return assets.map((asset) => { const metadata = assetsMetadata[ asset.assetType ] as FungibleAssetMetadata | null; @@ -245,7 +392,6 @@ export class AssetsService { } else { symbol = metadata?.symbol ?? symbol; } - // Include iconUrl from metadata if available iconUrl = metadata.iconUrl ?? iconUrl; } @@ -259,54 +405,22 @@ export class AssetsService { iconUrl, }; }); - - // Filter out tokens without price data (spam/obscure tokens) - const filteredAssets = this.#filterTokensWithoutPriceData( - assets, - spotPrices, - ); - - return filteredAssets; } /** - * Filters out spam tokens (those without price data). - * Essential assets are always kept. Tokens need price data to be included. + * Extracts the native TRX asset from the balance. * - * @param assets - The assets to filter. - * @param spotPrices - Pre-fetched USD prices for assets. - * @returns The filtered assets. + * @param account - The keyring account. + * @param scope - The network. + * @param balance - The native balance in sun. + * @returns AssetEntity - The native TRX asset. */ - #filterTokensWithoutPriceData( - assets: AssetEntity[], - spotPrices: SpotPrices | Record, - ): AssetEntity[] { - const filtered = assets.filter((asset) => { - // Essential assets (TRX, staked, energy, bandwidth) are always kept - if (ESSENTIAL_ASSETS.includes(asset.assetType)) { - return true; - } - // Tokens: keep only if they have price data - const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; - return typeof spotPrice?.price === 'number'; - }); - - return filtered; - } - - #extractNativeAsset({ - account, - scope, - tronAccountInfo, - }: { - account: KeyringAccount; - scope: Network; - tronAccountInfo: TronAccount; - }): AssetEntity { - // Balance may be missing for very new/inactive accounts, default to 0 - const balance = tronAccountInfo.balance ?? 0; - - const asset: AssetEntity = { + #extractNativeAsset( + account: KeyringAccount, + scope: Network, + balance: number, + ): AssetEntity { + return { assetType: Networks[scope].nativeToken.id, keyringAccountId: account.id, network: scope, @@ -319,25 +433,27 @@ export class AssetsService { ).toString(), iconUrl: Networks[scope].nativeToken.iconUrl, }; - - return asset; } - #extractStakedNativeAssets({ - account, - scope, - tronAccountInfo, - }: { - account: KeyringAccount; - scope: Network; - tronAccountInfo: TronAccount; - }): AssetEntity[] { + /** + * Extracts staked TRX assets (for bandwidth and energy). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including frozen balances and delegated resources. + * @returns AssetEntity[] - Array of staked assets (may be empty if no staking). + */ + #extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity[] { const assets: AssetEntity[] = []; let stakedBandwidthAmount = 0; let stakedEnergyAmount = 0; - tronAccountInfo.frozenV2?.forEach((frozen) => { + stakedData.frozenV2?.forEach((frozen) => { const amount = frozen.amount ?? 0; if (frozen.type === 'ENERGY') { @@ -349,11 +465,9 @@ export class AssetsService { }); const delegatedBandwidth = - tronAccountInfo?.account_resource - ?.delegated_frozenV2_balance_for_bandwidth ?? 0; + stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; const delegatedEnergy = - tronAccountInfo?.account_resource - ?.delegated_frozenV2_balance_for_energy ?? 0; + stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; stakedBandwidthAmount += delegatedBandwidth; stakedEnergyAmount += delegatedEnergy; @@ -396,7 +510,7 @@ export class AssetsService { } /** - * Extracts used bandwidth and maximum bandwidth assets from the account resources. + * Extracts current and maximum bandwidth from the account resources. * * @param options - Options object. * @param options.account - The account to extract bandwidth for. @@ -447,6 +561,14 @@ export class AssetsService { ]; } + /** + * Extracts current and maximum energy from the account resources. + * + * @param account - The keyring account. + * @param scope - The network. + * @param resources - Account resources (energy, bandwidth). + * @returns AssetEntity[] - Array containing energy and maximum energy assets. + */ #extractEnergy({ account, scope, @@ -457,7 +579,6 @@ export class AssetsService { tronAccountResources: AccountResources | Record; }): AssetEntity[] { const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; - const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; /** @@ -489,21 +610,22 @@ export class AssetsService { ]; } - #extractTrc10Assets({ - account, - scope, - tronAccountInfo, - }: { - account: KeyringAccount; - scope: Network; - tronAccountInfo: TronAccount; - }): AssetEntity[] { - const { assetV2 } = tronAccountInfo; - - const trc10Assets = - assetV2?.flatMap((tokenObject) => { + /** + * Extracts TRC10 assets from the balances array. + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. + * @returns AssetEntity[] - Array of TRC10 asset entities. + */ + #extractTrc10Assets( + account: KeyringAccount, + scope: Network, + trc10Balances: TronAccount['assetV2'], + ): AssetEntity[] { + return ( + trc10Balances?.flatMap((tokenObject) => { // assetV2 has structure: { "key": "token_id", "value": "balance" } - // Each object in the array has "key" and "value" properties return { assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, keyringAccountId: account.id, @@ -514,39 +636,38 @@ export class AssetsService { uiAmount: '0', iconUrl: '', // Will be enriched with metadata later }; - }) ?? []; - - return trc10Assets; + }) ?? [] + ); } - #extractTrc20Assets({ - account, - scope, - tronAccountInfo, - }: { - account: KeyringAccount; - scope: Network; - tronAccountInfo: TronAccount; - }): AssetEntity[] { - const { trc20 } = tronAccountInfo; - - const trc20Assets = - trc20?.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }) ?? []; - - return trc20Assets; + /** + * Extracts TRC20 assets from a balances array. + * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). + * @returns AssetEntity[] - Array of TRC20 asset entities. + */ + #extractTrc20Assets( + account: KeyringAccount, + scope: Network, + trc20Balances: Trc20Balance[], + ): AssetEntity[] { + return trc20Balances.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later + }; + }); + }); } async getAssetsMetadata( From 248706c0911da46e4550ec0dec2aaa27f5db5f17 Mon Sep 17 00:00:00 2001 From: Ulisses Date: Wed, 4 Feb 2026 17:44:51 +0000 Subject: [PATCH 2/6] chore: refine tests and assets flow Use InMemoryCache in TrongridApiClient tests and drop redundant section comments in AssetsService. --- .../trongrid/TrongridApiClient.test.ts | 48 ++++--- .../src/services/assets/AssetsService.test.ts | 117 ++++++++++++++---- .../snap/src/services/assets/AssetsService.ts | 5 - packages/snap/src/utils/formatAmount.test.ts | 2 +- 4 files changed, 121 insertions(+), 51 deletions(-) diff --git a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts index f5ba14a2..8002519a 100644 --- a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -1,10 +1,10 @@ -/* eslint-disable no-restricted-globals */ -/* eslint-disable @typescript-eslint/naming-convention */ import { TrongridApiClient } from './TrongridApiClient'; import type { Trc20Balance } from './types'; import type { ICache } from '../../caching/ICache'; +import { InMemoryCache } from '../../caching/InMemoryCache'; import { Network } from '../../constants'; import { ConfigProvider } from '../../services/config'; +import { mockLogger } from '../../utils/mockLogger'; import type { Serializable } from '../../utils/serialization/types'; import { TronHttpClient } from '../tron-http/TronHttpClient'; @@ -14,6 +14,7 @@ describe('TrongridApiClient', () => { let mockTronHttpClient: TronHttpClient; let mockCache: ICache; + // eslint-disable-next-line no-restricted-globals const originalFetch = global.fetch; beforeEach(() => { @@ -43,19 +44,7 @@ describe('TrongridApiClient', () => { configProvider: mockConfigProvider, }); - mockCache = { - get: jest.fn(), - set: jest.fn(), - delete: jest.fn(), - clear: jest.fn(), - has: jest.fn(), - keys: jest.fn(), - size: jest.fn(), - peek: jest.fn(), - mget: jest.fn(), - mset: jest.fn(), - mdelete: jest.fn(), - }; + mockCache = new InMemoryCache(mockLogger); client = new TrongridApiClient({ configProvider: mockConfigProvider, @@ -65,6 +54,7 @@ describe('TrongridApiClient', () => { }); afterEach(() => { + // eslint-disable-next-line no-restricted-globals global.fetch = originalFetch; }); @@ -79,11 +69,14 @@ describe('TrongridApiClient', () => { { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, ]; + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ data: mockTrc20Balances, success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 4 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -96,6 +89,7 @@ describe('TrongridApiClient', () => { ); expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals expect(global.fetch).toHaveBeenCalledWith( `https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`, expect.objectContaining({ @@ -107,11 +101,14 @@ describe('TrongridApiClient', () => { }); it('returns empty array when no TRC20 tokens are found', async () => { + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ data: [], success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 0 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -127,10 +124,13 @@ describe('TrongridApiClient', () => { }); it('returns empty array when data is undefined', async () => { + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 0 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -179,9 +179,11 @@ describe('TrongridApiClient', () => { }); it('throws error when HTTP request fails', async () => { - jest - .spyOn(global, 'fetch') - .mockResolvedValueOnce(new Response('', { status: 500 })); + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response('', { status: 500 }), + ); await expect( client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), @@ -189,11 +191,14 @@ describe('TrongridApiClient', () => { }); it('throws error when API returns success: false', async () => { + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ data: [], success: false, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 0 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -208,11 +213,14 @@ describe('TrongridApiClient', () => { it('works with different networks', async () => { const mockTrc20Balances: Trc20Balance[] = [{ TTestToken123: '1000000' }]; + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ data: mockTrc20Balances, success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 1 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -225,6 +233,7 @@ describe('TrongridApiClient', () => { ); expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining('nile.trongrid.io'), expect.any(Object), @@ -238,11 +247,14 @@ describe('TrongridApiClient', () => { { TokenAddress2: '200' }, ]; + // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals new Response( JSON.stringify({ data: validBalances, success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention meta: { at: 1770121997373, page_size: 2 }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, diff --git a/packages/snap/src/services/assets/AssetsService.test.ts b/packages/snap/src/services/assets/AssetsService.test.ts index dfd1ad39..255c3510 100644 --- a/packages/snap/src/services/assets/AssetsService.test.ts +++ b/packages/snap/src/services/assets/AssetsService.test.ts @@ -4,9 +4,12 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetsRepository } from './AssetsRepository'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { SpotPrices } from '../../clients/price-api/types'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { AccountResources } from '../../clients/tron-http/types'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; import { KnownCaip19Id, Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import { mockLogger } from '../../utils/mockLogger'; @@ -140,6 +143,57 @@ describe('AssetsService', () => { scopes: ['tron:728126428'], }; + // Reusable mock for empty AccountResources (inactive account scenario) + const emptyAccountResources: AccountResources = { + freeNetUsed: 0, + freeNetLimit: 0, + NetLimit: 0, + TotalNetLimit: 0, + TotalNetWeight: 0, + tronPowerUsed: 0, + tronPowerLimit: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, + }; + + // Helper to create properly typed SpotPrices for tests + const createSpotPrices = ( + entries: Record, + ): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); + + // Helper to create properly typed TronAccount for tests + // Uses snake_case property names to match Tron API response format + /* eslint-disable @typescript-eslint/naming-convention */ + 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, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + beforeEach(() => { jest.clearAllMocks(); @@ -190,7 +244,9 @@ describe('AssetsService', () => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('Account not found or no data returned'), ); - mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); // TRC20 fallback endpoint returns some balances const trc20Balances = [ @@ -202,12 +258,11 @@ describe('AssetsService', () => { // Mock price API to return prices for the TRC20 tokens const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ - [trc20AssetId]: { - id: trc20AssetId, - price: 1.0, - }, - } as never); + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + }), + ); // Act const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -242,7 +297,9 @@ describe('AssetsService', () => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('Account not found or no data returned'), ); - mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); // TRC20 fallback endpoint returns empty (no tokens) mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue([]); @@ -284,7 +341,9 @@ describe('AssetsService', () => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('Account not found or no data returned'), ); - mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); // TRC20 fallback endpoint also fails mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( @@ -311,26 +370,25 @@ describe('AssetsService', () => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('Account not found or no data returned'), ); - mockTronHttpClient.getAccountResources.mockResolvedValue({} as never); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); // TRC20 fallback returns tokens including a spam token - const trc20BalancesWithSpam = [ + const trc20BalancesWithSpam: Trc20Balance[] = [ { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price { TSpamToken123456789: '1000000000' }, // Spam token - no price ]; mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam as never, + trc20BalancesWithSpam, ); // Mock price API to only return price for USDT const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ - [usdtAssetId]: { - id: usdtAssetId, - price: 1.0, - }, + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( // No price for spam token - } as never); + createSpotPrices({ [usdtAssetId]: { id: usdtAssetId, price: 1.0 } }), + ); // Act const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -364,10 +422,11 @@ describe('AssetsService', () => { new Error('Account not found or no data returned'), ); mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, freeNetLimit: 600, NetLimit: 0, EnergyLimit: 0, - } as never); + }); // TRC20 fallback endpoint returns some balances const trc20Balances = [ @@ -379,9 +438,11 @@ describe('AssetsService', () => { // Mock price API for the TRC20 token const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - } as never); + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + }), + ); // Act const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -412,11 +473,13 @@ describe('AssetsService', () => { it('continues with zero resources when only resources request fails', async () => { // Arrange: Account info succeeds, resources fail - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ - address: mockAccount.address, - balance: 1000000, - trc20: [], - } as never); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1000000, + trc20: [], + }), + ); mockTronHttpClient.getAccountResources.mockRejectedValue( new Error('Resources endpoint unavailable'), ); diff --git a/packages/snap/src/services/assets/AssetsService.ts b/packages/snap/src/services/assets/AssetsService.ts index c1ab3a9d..63f2ff6a 100644 --- a/packages/snap/src/services/assets/AssetsService.ts +++ b/packages/snap/src/services/assets/AssetsService.ts @@ -188,7 +188,6 @@ export class AssetsService { scope, }); - // --- DATA FETCHING --- const [tronAccountInfoRequest, tronAccountResourcesRequest] = await Promise.allSettled([ this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), @@ -215,17 +214,14 @@ export class AssetsService { }) : []; - // --- NORMALIZE DATA --- const accountData = this.#buildAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, trc20BalancesFallback, }); - // --- EXTRACT ASSETS --- const rawAssets = this.#extractAssets(account, scope, accountData); - // --- FETCH METADATA & PRICES --- const assetTypes = rawAssets.map((asset) => asset.assetType); const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); @@ -236,7 +232,6 @@ export class AssetsService { .catch(() => ({})), ]); - // --- ENRICH & FILTER --- const enrichedAssets = this.#enrichAssetsWithMetadata( rawAssets, assetsMetadata, diff --git a/packages/snap/src/utils/formatAmount.test.ts b/packages/snap/src/utils/formatAmount.test.ts index 6a14f94a..7e40100f 100644 --- a/packages/snap/src/utils/formatAmount.test.ts +++ b/packages/snap/src/utils/formatAmount.test.ts @@ -73,7 +73,7 @@ describe('formatAmount', () => { }); }); - describe('extreme values (original bug scenarios)', () => { + describe('values out of `Number` 64-bit size', () => { it('handles extremely large token amounts without scientific notation', () => { // Values that caused parseFloat to return scientific notation const largeValue = '999999999999999999999999999999'; From f1d9d18040caae74faedceb8f41f331194f4daef Mon Sep 17 00:00:00 2001 From: Ulisses Date: Wed, 11 Feb 2026 14:59:18 +0000 Subject: [PATCH 3/6] test(TrongridApiClient): extract `beforeEach` code to an auxiliary function --- .../trongrid/TrongridApiClient.test.ts | 456 ++++++++++-------- 1 file changed, 243 insertions(+), 213 deletions(-) diff --git a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts index 8002519a..31889b07 100644 --- a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -8,268 +8,298 @@ import { mockLogger } from '../../utils/mockLogger'; import type { Serializable } from '../../utils/serialization/types'; import { TronHttpClient } from '../tron-http/TronHttpClient'; -describe('TrongridApiClient', () => { - let client: TrongridApiClient; - let mockConfigProvider: ConfigProvider; - let mockTronHttpClient: TronHttpClient; - let mockCache: ICache; - - // eslint-disable-next-line no-restricted-globals - const originalFetch = global.fetch; - - beforeEach(() => { - jest.clearAllMocks(); - - mockConfigProvider = new ConfigProvider(); - const baseConfig = mockConfigProvider.get(); - jest.spyOn(mockConfigProvider, 'get').mockReturnValue({ - ...baseConfig, - trongridApi: { - baseUrls: { - [Network.Mainnet]: 'https://api.trongrid.io', - [Network.Nile]: 'https://nile.trongrid.io', - [Network.Shasta]: 'https://api.shasta.trongrid.io', - }, - }, - tronHttpApi: { - baseUrls: { - [Network.Mainnet]: 'https://api.trongrid.io', - [Network.Nile]: 'https://nile.trongrid.io', - [Network.Shasta]: 'https://api.shasta.trongrid.io', - }, - }, - }); +/** + * Builds a TrongridApiClient with default dependencies. + * Each call creates fresh instances to keep tests isolated. + * + * @param overrides - Optional overrides for the config provider base URLs. + * @param overrides.trongridBaseUrls - Custom TronGrid API base URLs by network. + * @param overrides.tronHttpBaseUrls - Custom Tron HTTP API base URLs by network. + * @returns The client and its dependencies. + */ +function buildTrongridApiClient( + overrides: { + trongridBaseUrls?: Record; + tronHttpBaseUrls?: Record; + } = {}, +): { + client: TrongridApiClient; + configProvider: ConfigProvider; + tronHttpClient: TronHttpClient; + cache: ICache; +} { + const defaultBaseUrls = { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: 'https://nile.trongrid.io', + [Network.Shasta]: 'https://api.shasta.trongrid.io', + }; + + const configProvider = new ConfigProvider(); + const baseConfig = configProvider.get(); + jest.spyOn(configProvider, 'get').mockReturnValue({ + ...baseConfig, + trongridApi: { + baseUrls: overrides.trongridBaseUrls ?? defaultBaseUrls, + }, + tronHttpApi: { + baseUrls: overrides.tronHttpBaseUrls ?? defaultBaseUrls, + }, + }); - mockTronHttpClient = new TronHttpClient({ - configProvider: mockConfigProvider, - }); + const tronHttpClient = new TronHttpClient({ + configProvider, + }); - mockCache = new InMemoryCache(mockLogger); + const cache = new InMemoryCache(mockLogger); - client = new TrongridApiClient({ - configProvider: mockConfigProvider, - tronHttpClient: mockTronHttpClient, - cache: mockCache, - }); + const client = new TrongridApiClient({ + configProvider, + tronHttpClient, + cache, }); - afterEach(() => { + return { client, configProvider, tronHttpClient, cache }; +} + +/** + * Wraps a test function that needs to mock `global.fetch`, + * ensuring the original fetch is restored after the test completes. + * + * @param testFn - The async test body to execute. + */ +async function withFetch(testFn: () => Promise): Promise { + // eslint-disable-next-line no-restricted-globals + const originalFetch = global.fetch; + try { + await testFn(); + } finally { // eslint-disable-next-line no-restricted-globals global.fetch = originalFetch; - }); + } +} +describe('TrongridApiClient', () => { describe('getTrc20BalancesByAddress', () => { const mockAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx'; const normalizeBalances = (balances: Trc20Balance[]): Trc20Balance[] => balances.map((balance) => ({ ...balance })); it('fetches and returns TRC20 balances for an address', async () => { - const mockTrc20Balances: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, - ]; + await withFetch(async () => { + const { client } = buildTrongridApiClient(); + const mockTrc20Balances: Trc20Balance[] = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, + { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, + ]; - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - data: mockTrc20Balances, - success: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 4 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const result = await client.getTrc20BalancesByAddress( - Network.Mainnet, - mockAddress, - ); - - expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); - // eslint-disable-next-line no-restricted-globals - expect(global.fetch).toHaveBeenCalledWith( - `https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`, - expect.objectContaining({ - headers: expect.objectContaining({ - 'Content-Type': 'application/json', + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 4 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + `https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), }), - }), - ); + ); + }); }); it('returns empty array when no TRC20 tokens are found', async () => { - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( - // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - data: [], - success: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 0 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const result = await client.getTrc20BalancesByAddress( - Network.Mainnet, - mockAddress, - ); + await withFetch(async () => { + const { client } = buildTrongridApiClient(); - expect(result).toStrictEqual([]); + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [], + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); }); it('returns empty array when data is undefined', async () => { - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( - // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - success: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 0 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const result = await client.getTrc20BalancesByAddress( - Network.Mainnet, - mockAddress, - ); + await withFetch(async () => { + const { client } = buildTrongridApiClient(); - expect(result).toStrictEqual([]); + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toStrictEqual([]); + }); }); it('throws error when network base URL is invalid', async () => { - // Create a client with invalid testnet base URLs - const limitedConfigProvider = new ConfigProvider(); - const limitedBaseConfig = limitedConfigProvider.get(); - jest.spyOn(limitedConfigProvider, 'get').mockReturnValue({ - ...limitedBaseConfig, - trongridApi: { - baseUrls: { - [Network.Mainnet]: 'https://api.trongrid.io', - [Network.Nile]: '', - [Network.Shasta]: '', - }, - }, - tronHttpApi: { - baseUrls: { - [Network.Mainnet]: 'https://api.trongrid.io', - [Network.Nile]: '', - [Network.Shasta]: '', - }, - }, - }); - - const limitedClient = new TrongridApiClient({ - configProvider: limitedConfigProvider, - tronHttpClient: mockTronHttpClient, - cache: mockCache, + const invalidBaseUrls = { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: '', + [Network.Shasta]: '', + }; + const { client } = buildTrongridApiClient({ + trongridBaseUrls: invalidBaseUrls, + tronHttpBaseUrls: invalidBaseUrls, }); await expect( - limitedClient.getTrc20BalancesByAddress(Network.Nile, mockAddress), + client.getTrc20BalancesByAddress(Network.Nile, mockAddress), ).rejects.toThrow('Invalid URL format'); }); it('throws error when HTTP request fails', async () => { - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( - // eslint-disable-next-line no-restricted-globals - new Response('', { status: 500 }), - ); + await withFetch(async () => { + const { client } = buildTrongridApiClient(); - await expect( - client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), - ).rejects.toThrow('HTTP error! status: 500'); + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response('', { status: 500 }), + ); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('HTTP error! status: 500'); + }); }); it('throws error when API returns success: false', async () => { - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( - // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - data: [], - success: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 0 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); + await withFetch(async () => { + const { client } = buildTrongridApiClient(); - await expect( - client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), - ).rejects.toThrow('API request failed'); + // eslint-disable-next-line no-restricted-globals + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: [], + success: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 0 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress), + ).rejects.toThrow('API request failed'); + }); }); it('works with different networks', async () => { - const mockTrc20Balances: Trc20Balance[] = [{ TTestToken123: '1000000' }]; + await withFetch(async () => { + const { client } = buildTrongridApiClient(); + const mockTrc20Balances: Trc20Balance[] = [ + { TTestToken123: '1000000' }, + ]; - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - data: mockTrc20Balances, - success: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 1 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const result = await client.getTrc20BalancesByAddress( - Network.Nile, - mockAddress, - ); - - expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); - // eslint-disable-next-line no-restricted-globals - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('nile.trongrid.io'), - expect.any(Object), - ); + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: mockTrc20Balances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Nile, + mockAddress, + ); + + expect(normalizeBalances(result)).toStrictEqual(mockTrc20Balances); + // eslint-disable-next-line no-restricted-globals + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('nile.trongrid.io'), + expect.any(Object), + ); + }); }); it('validates TRC20 balance data structure', async () => { - // Valid structure: array of Record - const validBalances: Trc20Balance[] = [ - { TokenAddress1: '100' }, - { TokenAddress2: '200' }, - ]; - - // eslint-disable-next-line no-restricted-globals - jest.spyOn(global, 'fetch').mockResolvedValueOnce( + await withFetch(async () => { + const { client } = buildTrongridApiClient(); + const validBalances: Trc20Balance[] = [ + { TokenAddress1: '100' }, + { TokenAddress2: '200' }, + ]; + // eslint-disable-next-line no-restricted-globals - new Response( - JSON.stringify({ - data: validBalances, - success: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - meta: { at: 1770121997373, page_size: 2 }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const result = await client.getTrc20BalancesByAddress( - Network.Mainnet, - mockAddress, - ); - - expect(result).toHaveLength(2); - const normalizedBalances = normalizeBalances(result); - expect(normalizedBalances[0]).toStrictEqual({ TokenAddress1: '100' }); - expect(normalizedBalances[1]).toStrictEqual({ TokenAddress2: '200' }); + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify({ + data: validBalances, + success: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + meta: { at: 1770121997373, page_size: 2 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await client.getTrc20BalancesByAddress( + Network.Mainnet, + mockAddress, + ); + + expect(result).toHaveLength(2); + const normalizedBalances = normalizeBalances(result); + expect(normalizedBalances[0]).toStrictEqual({ TokenAddress1: '100' }); + expect(normalizedBalances[1]).toStrictEqual({ TokenAddress2: '200' }); + }); }); }); }); From e4ec71325e7a30bb954c46a78eb7fc4f9aa73479 Mon Sep 17 00:00:00 2001 From: Ulisses Date: Wed, 11 Feb 2026 15:07:32 +0000 Subject: [PATCH 4/6] test(AssetsService): extract `beforeEach` code to an auxiliary function --- packages/snap/jest.config.js | 8 +- .../src/services/assets/AssetsService.test.ts | 861 ++++++++++-------- .../snap/src/services/assets/AssetsService.ts | 15 +- 3 files changed, 496 insertions(+), 388 deletions(-) diff --git a/packages/snap/jest.config.js b/packages/snap/jest.config.js index 35a95aea..8e441eba 100644 --- a/packages/snap/jest.config.js +++ b/packages/snap/jest.config.js @@ -24,10 +24,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 49.68, - functions: 52.79, - lines: 65.54, - statements: 65.62, + branches: 50.55, + functions: 54.14, + lines: 66.12, + statements: 66.21, }, }, diff --git a/packages/snap/src/services/assets/AssetsService.test.ts b/packages/snap/src/services/assets/AssetsService.test.ts index 255c3510..d3e22c78 100644 --- a/packages/snap/src/services/assets/AssetsService.test.ts +++ b/packages/snap/src/services/assets/AssetsService.test.ts @@ -6,8 +6,8 @@ import type { AssetsRepository } from './AssetsRepository'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; import type { SpotPrices } from '../../clients/price-api/types'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; +import type { AccountResources } from '../../clients/tron-http/structs'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; -import type { AccountResources } from '../../clients/tron-http/types'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; import { KnownCaip19Id, Network } from '../../constants'; @@ -45,28 +45,81 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals const { AssetsService } = require('./AssetsService'); -/* eslint-disable @typescript-eslint/naming-convention */ -const minimalTronAccount = { +const mockAccount: KeyringAccount = { + id: 'test-account-id', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', - balance: 0, - frozenV2: [], + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: ['tron:728126428'], +}; + +// Reusable mock for empty AccountResources (inactive account scenario) +const emptyAccountResources: AccountResources = { + freeNetUsed: 0, + freeNetLimit: 0, + NetLimit: 0, + TotalNetLimit: 0, + TotalNetWeight: 0, + tronPowerUsed: 0, + tronPowerLimit: 0, + TotalEnergyLimit: 0, + TotalEnergyWeight: 0, +}; + +/** + * Creates properly typed SpotPrices for tests. + * + * @param entries - Map of asset ID to price info. + * @returns SpotPrices object. + */ +const createSpotPrices = ( + entries: Record, +): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); + +/** + * Creates a properly typed TronAccount for tests. + * Uses snake_case property names to match Tron API response format. + * + * @param overrides - Partial TronAccount with required address. + * @returns A complete TronAccount. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +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, - owner_permission: { keys: [], threshold: 0, permission_name: 'owner' }, - active_permission: [], -}; + ...overrides, +}); /* eslint-enable @typescript-eslint/naming-convention */ +// 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. @@ -101,9 +154,15 @@ function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { return assets.find((a: AssetEntity) => a.assetType === assetType); } -describe('AssetsService', () => { - let assetsService: any; - let mockAssetsRepository: jest.Mocked< +/** + * Builds an AssetsService with fresh mock dependencies. + * Each call creates new instances to keep tests isolated. + * + * @returns The service and all mock dependencies for test configuration. + */ +function buildAssetsService(): { + assetsService: InstanceType; + mockAssetsRepository: jest.Mocked< Pick< AssetsRepository, | 'saveMany' @@ -112,133 +171,111 @@ describe('AssetsService', () => { | 'getByAccountIdAndAssetTypes' > >; - let mockState: jest.Mocked< + mockState: jest.Mocked< Pick, 'getKey' | 'setKey'> >; - let mockTrongridApiClient: jest.Mocked< + mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' > >; - let mockTronHttpClient: jest.Mocked< - Pick - >; - let mockPriceApiClient: jest.Mocked< + mockTronHttpClient: jest.Mocked>; + mockPriceApiClient: jest.Mocked< Pick< PriceApiClient, 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' > >; - let mockTokenApiClient: jest.Mocked< - Pick - >; + mockTokenApiClient: jest.Mocked>; +} { + 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 mockAccount: KeyringAccount = { - id: 'test-account-id', - address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', - type: 'eip155:eoa', - options: {}, - methods: [], - scopes: ['tron:728126428'], + const mockState: jest.Mocked< + Pick, 'getKey' | 'setKey'> + > = { + getKey: jest.fn().mockResolvedValue({}), + setKey: jest.fn().mockResolvedValue(undefined), }; - // Reusable mock for empty AccountResources (inactive account scenario) - const emptyAccountResources: AccountResources = { - freeNetUsed: 0, - freeNetLimit: 0, - NetLimit: 0, - TotalNetLimit: 0, - TotalNetWeight: 0, - tronPowerUsed: 0, - tronPowerLimit: 0, - TotalEnergyLimit: 0, - TotalEnergyWeight: 0, + const mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + > = { + getAccountInfoByAddress: jest.fn(), + getTrc20BalancesByAddress: jest.fn(), }; - // Helper to create properly typed SpotPrices for tests - const createSpotPrices = ( - entries: Record, - ): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - - // Helper to create properly typed TronAccount for tests - // Uses snake_case property names to match Tron API response format - /* eslint-disable @typescript-eslint/naming-convention */ - 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, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - beforeEach(() => { - jest.clearAllMocks(); - - mockAssetsRepository = { - saveMany: jest.fn().mockResolvedValue(undefined), - getByAccountId: jest.fn().mockResolvedValue([]), - getByAccountIdAndAssetType: jest.fn().mockResolvedValue(null), - getByAccountIdAndAssetTypes: jest.fn().mockResolvedValue([]), - }; - - mockState = { - getKey: jest.fn().mockResolvedValue({}), - setKey: jest.fn().mockResolvedValue(undefined), - }; - - mockTrongridApiClient = { - getAccountInfoByAddress: jest.fn(), - getTrc20BalancesByAddress: jest.fn(), - }; - mockTronHttpClient = { - getAccountResources: jest.fn(), - }; - mockPriceApiClient = { - getFiatExchangeRates: jest.fn(), - getHistoricalPrices: jest.fn(), - getMultipleSpotPrices: jest.fn().mockResolvedValue({}), - }; - mockTokenApiClient = { - getTokensMetadata: jest.fn().mockResolvedValue({}), - }; - - assetsService = new AssetsService({ - logger: mockLogger, - assetsRepository: mockAssetsRepository, - state: mockState, - trongridApiClient: mockTrongridApiClient, - tronHttpClient: mockTronHttpClient, - priceApiClient: mockPriceApiClient, - tokenApiClient: mockTokenApiClient, - }); + const mockTronHttpClient: jest.Mocked< + Pick + > = { + getAccountResources: jest.fn(), + }; + + 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 assetsService = new AssetsService({ + logger: mockLogger, + assetsRepository: mockAssetsRepository, + state: mockState, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + priceApiClient: mockPriceApiClient, + tokenApiClient: mockTokenApiClient, }); + return { + assetsService, + mockAssetsRepository, + mockState, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + mockTokenApiClient, + }; +} + +describe('AssetsService', () => { describe('fetchAssetsAndBalancesForAccount', () => { describe('inactive account fallback', () => { it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + const { + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + } = buildAssetsService(); + // Arrange: Account info fails (inactive account doesn't exist on-chain) // Note: getAccountResources returns {} for inactive accounts, not an error mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( @@ -292,6 +329,9 @@ describe('AssetsService', () => { }); it('returns zero TRX and resources when fallback also returns empty', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + // Arrange: Account info fails (inactive account) // Note: getAccountResources returns {} for inactive accounts, not an error mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( @@ -336,6 +376,9 @@ describe('AssetsService', () => { }); it('gracefully handles fallback endpoint failure', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + // Arrange: Account info fails (inactive account) // Note: getAccountResources returns {} for inactive accounts, not an error mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( @@ -365,6 +408,13 @@ describe('AssetsService', () => { }); it('filters out TRC20 tokens without price data from inactive account', async () => { + const { + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + } = buildAssetsService(); + // Arrange: Account info fails (inactive account) // Note: getAccountResources returns {} for inactive accounts, not an error mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( @@ -416,6 +466,13 @@ describe('AssetsService', () => { describe('partial failure handling', () => { it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + const { + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + } = buildAssetsService(); + // Arrange: Account info fails (inactive account), resources succeed with empty object // This matches real API behavior: getAccountResources returns {} for inactive accounts mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( @@ -472,6 +529,9 @@ describe('AssetsService', () => { }); it('continues with zero resources when only resources request fails', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + // Arrange: Account info succeeds, resources fail mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( createMockTronAccount({ @@ -506,10 +566,289 @@ describe('AssetsService', () => { expect(bandwidthAsset?.rawAmount).toBe('0'); }); }); + + describe('bandwidth', () => { + it('returns 0 when account has no resources', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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('energy', () => { + it('returns 0 when account has no resources', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + 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 () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('0'); + }); + + it('returns EnergyLimit from staking', async () => { + const { assetsService, mockTrongridApiClient, mockTronHttpClient } = + buildAssetsService(); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('329'); + }); + }); }); describe('saveMany', () => { it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Create assets with zero amounts for energy and bandwidth const assets: AssetEntity[] = [ { @@ -568,6 +907,8 @@ describe('AssetsService', () => { }); it('removes non-essential assets with zero amounts', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Create a regular TRC20 token with zero amount const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const assets: AssetEntity[] = [ @@ -617,6 +958,8 @@ describe('AssetsService', () => { }); it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Create maximum energy and bandwidth assets with zero amounts const assets: AssetEntity[] = [ { @@ -677,6 +1020,8 @@ describe('AssetsService', () => { }); it('keeps staked assets even with zero amounts', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Create staked assets with zero amounts const assets: AssetEntity[] = [ { @@ -738,6 +1083,8 @@ describe('AssetsService', () => { describe('updating assets from 0 to >0', () => { it('adds energy to the asset list when it updates from 0 to >0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with zero energy const savedAssets: AssetEntity[] = [ { @@ -813,6 +1160,8 @@ describe('AssetsService', () => { }); it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with zero bandwidth const savedAssets: AssetEntity[] = [ { @@ -888,6 +1237,7 @@ describe('AssetsService', () => { }); it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { + const { assetsService, mockState } = buildAssetsService(); const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; // Arrange: Previously saved assets with zero USDT @@ -965,6 +1315,7 @@ describe('AssetsService', () => { }); it('handles multiple assets updating from 0 to >0 simultaneously', async () => { + const { assetsService, mockState } = buildAssetsService(); const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; // Arrange: Previously saved assets with all zeros @@ -1084,6 +1435,8 @@ describe('AssetsService', () => { }); it('handles staked assets updating from 0 to >0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with zero staked amounts const savedAssets: AssetEntity[] = [ { @@ -1161,6 +1514,8 @@ describe('AssetsService', () => { describe('updating assets going down', () => { it('updates energy balance when it decreases but remains >0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with high energy const savedAssets: AssetEntity[] = [ { @@ -1256,6 +1611,8 @@ describe('AssetsService', () => { }); it('updates bandwidth balance when it decreases but remains >0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with high bandwidth const savedAssets: AssetEntity[] = [ { @@ -1351,6 +1708,8 @@ describe('AssetsService', () => { }); it('keeps energy in the list when it drops to 0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with some energy const savedAssets: AssetEntity[] = [ { @@ -1426,6 +1785,8 @@ describe('AssetsService', () => { }); it('keeps bandwidth in the list when it drops to 0', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets with some bandwidth const savedAssets: AssetEntity[] = [ { @@ -1501,6 +1862,8 @@ describe('AssetsService', () => { }); it('handles both energy and bandwidth fluctuating in a transaction', async () => { + const { assetsService, mockState } = buildAssetsService(); + // Arrange: Previously saved assets const savedAssets: AssetEntity[] = [ { @@ -1621,270 +1984,4 @@ describe('AssetsService', () => { }); }); }); - - describe('fetchAssetsAndBalancesForAccount', () => { - describe('bandwidth', () => { - it('returns 0 when account has no resources', async () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ NetLimit: 48 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet)?.rawAmount, - ).toBe('648'); - }); - }); - - describe('energy', () => { - it('returns 0 when account has no resources', async () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - 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 () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('0'); - }); - - it('returns EnergyLimit from staking', async () => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTokenApiClient.getTokensMetadata.mockResolvedValue({}); - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue({}); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('329'); - }); - }); - }); }); diff --git a/packages/snap/src/services/assets/AssetsService.ts b/packages/snap/src/services/assets/AssetsService.ts index 63f2ff6a..ee299b93 100644 --- a/packages/snap/src/services/assets/AssetsService.ts +++ b/packages/snap/src/services/assets/AssetsService.ts @@ -331,8 +331,16 @@ export class AssetsService { ...this.#extractStakedNativeAssets(account, scope, data.stakedData), ...this.#extractTrc10Assets(account, scope, data.trc10Balances), ...this.#extractTrc20Assets(account, scope, data.trc20Balances), - ...this.#extractBandwidth(account, scope, data.resources), - ...this.#extractEnergy(account, scope, data.resources), + ...this.#extractBandwidth({ + account, + scope, + tronAccountResources: data.resources, + }), + ...this.#extractEnergy({ + account, + scope, + tronAccountResources: data.resources, + }), ]; } @@ -559,9 +567,12 @@ export class AssetsService { /** * Extracts current and maximum energy from the account resources. * + * @param account.account * @param account - The keyring account. * @param scope - The network. * @param resources - Account resources (energy, bandwidth). + * @param account.scope + * @param account.tronAccountResources * @returns AssetEntity[] - Array containing energy and maximum energy assets. */ #extractEnergy({ From 7ebc2700ac0f78b11dacb6715d8085098724d8f0 Mon Sep 17 00:00:00 2001 From: Ulisses Date: Wed, 11 Feb 2026 15:44:13 +0000 Subject: [PATCH 5/6] test(TrongridApiClient): extract `beforeEach` code to an auxiliary function --- .../trongrid/TrongridApiClient.test.ts | 114 +++++++++--------- 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts index 31889b07..1e8b721a 100644 --- a/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -8,26 +8,36 @@ import { mockLogger } from '../../utils/mockLogger'; import type { Serializable } from '../../utils/serialization/types'; import { TronHttpClient } from '../tron-http/TronHttpClient'; -/** - * Builds a TrongridApiClient with default dependencies. - * Each call creates fresh instances to keep tests isolated. - * - * @param overrides - Optional overrides for the config provider base URLs. - * @param overrides.trongridBaseUrls - Custom TronGrid API base URLs by network. - * @param overrides.tronHttpBaseUrls - Custom Tron HTTP API base URLs by network. - * @returns The client and its dependencies. - */ -function buildTrongridApiClient( - overrides: { - trongridBaseUrls?: Record; - tronHttpBaseUrls?: Record; - } = {}, -): { +type WithTrongridApiClientCallback = (payload: { client: TrongridApiClient; configProvider: ConfigProvider; tronHttpClient: TronHttpClient; cache: ICache; -} { +}) => Promise | ReturnValue; + +type WithTrongridApiClientOptions = { + options: { + trongridBaseUrls?: Record; + tronHttpBaseUrls?: Record; + }; +}; + +/** + * Wraps tests for TrongridApiClient by creating a fresh client with all + * dependencies and restoring `global.fetch` afterward. + * + * @param args - Either a callback, or an options bag + callback. Options allow + * overriding base URLs. The callback receives the client and its dependencies. + * @returns The return value of the callback. + */ +async function withTrongridApiClient( + ...args: + | [WithTrongridApiClientCallback] + | [WithTrongridApiClientOptions, WithTrongridApiClientCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const defaultBaseUrls = { [Network.Mainnet]: 'https://api.trongrid.io', [Network.Nile]: 'https://nile.trongrid.io', @@ -39,39 +49,30 @@ function buildTrongridApiClient( jest.spyOn(configProvider, 'get').mockReturnValue({ ...baseConfig, trongridApi: { - baseUrls: overrides.trongridBaseUrls ?? defaultBaseUrls, + baseUrls: options.trongridBaseUrls ?? defaultBaseUrls, }, tronHttpApi: { - baseUrls: overrides.tronHttpBaseUrls ?? defaultBaseUrls, + baseUrls: options.tronHttpBaseUrls ?? defaultBaseUrls, }, }); - const tronHttpClient = new TronHttpClient({ - configProvider, - }); - + const tronHttpClient = new TronHttpClient({ configProvider }); const cache = new InMemoryCache(mockLogger); - const client = new TrongridApiClient({ configProvider, tronHttpClient, cache, }); - return { client, configProvider, tronHttpClient, cache }; -} - -/** - * Wraps a test function that needs to mock `global.fetch`, - * ensuring the original fetch is restored after the test completes. - * - * @param testFn - The async test body to execute. - */ -async function withFetch(testFn: () => Promise): Promise { // eslint-disable-next-line no-restricted-globals const originalFetch = global.fetch; try { - await testFn(); + return await testFunction({ + client, + configProvider, + tronHttpClient, + cache, + }); } finally { // eslint-disable-next-line no-restricted-globals global.fetch = originalFetch; @@ -85,8 +86,7 @@ describe('TrongridApiClient', () => { balances.map((balance) => ({ ...balance })); it('fetches and returns TRC20 balances for an address', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); + await withTrongridApiClient(async ({ client }) => { const mockTrc20Balances: Trc20Balance[] = [ { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, { TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' }, @@ -125,9 +125,7 @@ describe('TrongridApiClient', () => { }); it('returns empty array when no TRC20 tokens are found', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); - + await withTrongridApiClient(async ({ client }) => { // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals @@ -152,9 +150,7 @@ describe('TrongridApiClient', () => { }); it('returns empty array when data is undefined', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); - + await withTrongridApiClient(async ({ client }) => { // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals @@ -183,20 +179,24 @@ describe('TrongridApiClient', () => { [Network.Nile]: '', [Network.Shasta]: '', }; - const { client } = buildTrongridApiClient({ - trongridBaseUrls: invalidBaseUrls, - tronHttpBaseUrls: invalidBaseUrls, - }); - await expect( - client.getTrc20BalancesByAddress(Network.Nile, mockAddress), - ).rejects.toThrow('Invalid URL format'); + await withTrongridApiClient( + { + options: { + trongridBaseUrls: invalidBaseUrls, + tronHttpBaseUrls: invalidBaseUrls, + }, + }, + async ({ client }) => { + await expect( + client.getTrc20BalancesByAddress(Network.Nile, mockAddress), + ).rejects.toThrow('Invalid URL format'); + }, + ); }); it('throws error when HTTP request fails', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); - + await withTrongridApiClient(async ({ client }) => { // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals @@ -210,9 +210,7 @@ describe('TrongridApiClient', () => { }); it('throws error when API returns success: false', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); - + await withTrongridApiClient(async ({ client }) => { // eslint-disable-next-line no-restricted-globals jest.spyOn(global, 'fetch').mockResolvedValueOnce( // eslint-disable-next-line no-restricted-globals @@ -234,8 +232,7 @@ describe('TrongridApiClient', () => { }); it('works with different networks', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); + await withTrongridApiClient(async ({ client }) => { const mockTrc20Balances: Trc20Balance[] = [ { TTestToken123: '1000000' }, ]; @@ -269,8 +266,7 @@ describe('TrongridApiClient', () => { }); it('validates TRC20 balance data structure', async () => { - await withFetch(async () => { - const { client } = buildTrongridApiClient(); + await withTrongridApiClient(async ({ client }) => { const validBalances: Trc20Balance[] = [ { TokenAddress1: '100' }, { TokenAddress2: '200' }, From 98c7dd65d0b15113e771da89c800fa823e060ec5 Mon Sep 17 00:00:00 2001 From: Ulisses Date: Wed, 11 Feb 2026 15:45:46 +0000 Subject: [PATCH 6/6] test(AssetsService): extract `beforeEach` code to an auxiliary function --- packages/snap/jest.config.js | 8 +- .../src/services/assets/AssetsService.test.ts | 2891 ++++++++--------- .../snap/src/services/assets/AssetsService.ts | 10 +- 3 files changed, 1451 insertions(+), 1458 deletions(-) diff --git a/packages/snap/jest.config.js b/packages/snap/jest.config.js index 8e441eba..00fd79a6 100644 --- a/packages/snap/jest.config.js +++ b/packages/snap/jest.config.js @@ -24,10 +24,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 50.55, - functions: 54.14, - lines: 66.12, - statements: 66.21, + branches: 52.24, + functions: 54.75, + lines: 67.06, + statements: 67.13, }, }, diff --git a/packages/snap/src/services/assets/AssetsService.test.ts b/packages/snap/src/services/assets/AssetsService.test.ts index d3e22c78..562caa0a 100644 --- a/packages/snap/src/services/assets/AssetsService.test.ts +++ b/packages/snap/src/services/assets/AssetsService.test.ts @@ -15,7 +15,6 @@ import type { AssetEntity } from '../../entities/assets'; import { mockLogger } from '../../utils/mockLogger'; import type { State, UnencryptedStateValue } from '../state/State'; -// Mock context module to avoid circular dependency jest.mock('../../context', () => ({ configProvider: { get() { @@ -37,11 +36,9 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); -// Mock global snap object // eslint-disable-next-line no-restricted-globals (global as any).snap = {}; -// Import AssetsService after mocking context // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-globals const { AssetsService } = require('./AssetsService'); @@ -54,7 +51,6 @@ const mockAccount: KeyringAccount = { scopes: ['tron:728126428'], }; -// Reusable mock for empty AccountResources (inactive account scenario) const emptyAccountResources: AccountResources = { freeNetUsed: 0, freeNetLimit: 0, @@ -154,13 +150,7 @@ function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { return assets.find((a: AssetEntity) => a.assetType === assetType); } -/** - * Builds an AssetsService with fresh mock dependencies. - * Each call creates new instances to keep tests isolated. - * - * @returns The service and all mock dependencies for test configuration. - */ -function buildAssetsService(): { +type WithAssetsServiceCallback = (payload: { assetsService: InstanceType; mockAssetsRepository: jest.Mocked< Pick< @@ -188,7 +178,19 @@ function buildAssetsService(): { > >; mockTokenApiClient: jest.Mocked>; -} { +}) => 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, @@ -254,7 +256,7 @@ function buildAssetsService(): { tokenApiClient: mockTokenApiClient, }); - return { + return await testFunction({ assetsService, mockAssetsRepository, mockState, @@ -262,831 +264,651 @@ function buildAssetsService(): { mockTronHttpClient, mockPriceApiClient, mockTokenApiClient, - }; + }); } describe('AssetsService', () => { describe('fetchAssetsAndBalancesForAccount', () => { describe('inactive account fallback', () => { it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { - const { - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - } = buildAssetsService(); - - // Arrange: Account info fails (inactive account doesn't exist on-chain) - // Note: getAccountResources returns {} for inactive accounts, not an error - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - // TRC20 fallback endpoint returns some balances - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - // Mock price API to return prices for the TRC20 tokens - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: Should have called the fallback endpoint - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - // Assert: Should return TRX with zero balance and TRC20 tokens - const trxAsset = assets.find( - (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const trc20Asset = assets.find( - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - (asset: AssetEntity) => asset.assetType === expectedTrc20AssetType, + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + 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 expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const trc20Asset = assets.find( + (asset: AssetEntity) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + asset.assetType === expectedTrc20AssetType, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('24249143'); + }, ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); }); it('returns zero TRX and resources when fallback also returns empty', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - // Arrange: Account info fails (inactive account) - // Note: getAccountResources returns {} for inactive accounts, not an error - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - // TRC20 fallback endpoint returns empty (no tokens) - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue([]); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: Should have called the fallback endpoint - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - // Assert: Should return TRX with zero balance and zero resources - const trxAsset = assets.find( - (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - // Assert: Bandwidth and energy should also be present with zero values - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + [], + ); + + 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 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(); + }, ); - expect(bandwidthAsset).toBeDefined(); - expect(energyAsset).toBeDefined(); }); it('gracefully handles fallback endpoint failure', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - // Arrange: Account info fails (inactive account) - // Note: getAccountResources returns {} for inactive accounts, not an error - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - // TRC20 fallback endpoint also fails - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - new Error('Network error'), - ); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: Should still return TRX with zero balance - const trxAsset = assets.find( - (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( + new Error('Network error'), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('0'); + }, ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); }); it('filters out TRC20 tokens without price data from inactive account', async () => { - const { - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - } = buildAssetsService(); - - // Arrange: Account info fails (inactive account) - // Note: getAccountResources returns {} for inactive accounts, not an error - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - // TRC20 fallback returns tokens including a spam token - const trc20BalancesWithSpam: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price - { TSpamToken123456789: '1000000000' }, // Spam token - no price - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam, - ); - - // Mock price API to only return price for USDT - const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - // No price for spam token - createSpotPrices({ [usdtAssetId]: { id: usdtAssetId, price: 1.0 } }), - ); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: USDT should be included - const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const usdtAsset = assets.find( - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - (asset: AssetEntity) => asset.assetType === usdtAssetType, - ); - expect(usdtAsset).toBeDefined(); - - // Assert: Spam token should be filtered out - const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; - const spamAsset = assets.find( - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - (asset: AssetEntity) => asset.assetType === spamAssetType, + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + const trc20BalancesWithSpam: Trc20Balance[] = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price + { TSpamToken123456789: '1000000000' }, // Spam token - no price + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20BalancesWithSpam, + ); + + const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + const usdtAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === usdtAssetType, + ); + expect(usdtAsset).toBeDefined(); + + const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; + const spamAsset = assets.find( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === spamAssetType, + ); + expect(spamAsset).toBeUndefined(); + }, ); - expect(spamAsset).toBeUndefined(); }); }); describe('partial failure handling', () => { it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { - const { - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - } = buildAssetsService(); - - // Arrange: Account info fails (inactive account), resources succeed with empty object - // This matches real API behavior: getAccountResources returns {} for inactive accounts - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - NetLimit: 0, - EnergyLimit: 0, - }); - - // TRC20 fallback endpoint returns some balances - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - // Mock price API for the TRC20 token - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: Should have used the fallback endpoint - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - // Assert: Should return zero TRX (fallback behavior) - const trxAsset = assets.find( - (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - // Assert: Should include TRC20 from fallback - const trc20Asset = assets.find( - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - (asset: AssetEntity) => asset.assetType === trc20AssetId, + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( + new Error('Account not found or no data returned'), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, + freeNetLimit: 600, + NetLimit: 0, + EnergyLimit: 0, + }); + + const trc20Balances = [ + { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, + ]; + mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( + trc20Balances, + ); + + const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + 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( + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + (asset: AssetEntity) => asset.assetType === trc20AssetId, + ); + expect(trc20Asset).toBeDefined(); + expect(trc20Asset?.rawAmount).toBe('100000'); + }, ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('100000'); }); it('continues with zero resources when only resources request fails', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - // Arrange: Account info succeeds, resources fail - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - trc20: [], - }), - ); - mockTronHttpClient.getAccountResources.mockRejectedValue( - new Error('Resources endpoint unavailable'), - ); - - // Act - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - // Assert: Should return TRX balance from account info - const trxAsset = assets.find( - (asset: AssetEntity) => asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('1000000'); - - // Assert: Bandwidth and energy should be 0 (default) - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, + 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, + ); + + const trxAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ); + expect(trxAsset).toBeDefined(); + expect(trxAsset?.rawAmount).toBe('1000000'); + + const bandwidthAsset = assets.find( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.BandwidthMainnet, + ); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('0'); + }, ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('0'); }); }); describe('bandwidth', () => { it('returns 0 when account has no resources', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); }); it('returns remaining free bandwidth when no staking', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 200 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('400'); }); it('returns combined remaining free + staked bandwidth', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('290'); }); it('clamps to 0 when used exceeds maximum', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ - freeNetUsed: 600, - NetUsed: 50, - NetLimit: 16, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); }); }); describe('maximum bandwidth', () => { it('returns 0 when account has no resources', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet)?.rawAmount, - ).toBe('0'); }); it('returns only free bandwidth limit when no staking', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({}), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet)?.rawAmount, - ).toBe('600'); }); it('returns free + staked bandwidth limit', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ NetLimit: 48 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet)?.rawAmount, - ).toBe('648'); }); }); describe('energy', () => { it('returns 0 when account has no resources', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount).toBe( - '0', + 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 () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - 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', + 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 () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - 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', + 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 () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - 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', + 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 () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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.MaximumEnergyMainnet)?.rawAmount, + ).toBe('0'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('0'); }); it('returns EnergyLimit from staking', async () => { - const { assetsService, mockTrongridApiClient, mockTronHttpClient } = - buildAssetsService(); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + 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.MaximumEnergyMainnet)?.rawAmount, + ).toBe('329'); + }, ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('329'); }); }); }); describe('saveMany', () => { it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Create assets with zero amounts for energy and bandwidth - 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', // Zero energy - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', // Zero bandwidth - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({}); - - await assetsService.saveMany(assets); - - // Assert: Energy and bandwidth should be in the "added" list, not "removed" - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }); - - it('removes non-essential assets with zero amounts', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Create a regular TRC20 token with zero amount - 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', // Zero balance - uiAmount: '0', - iconUrl: '', - }, - ]; - - // Mock the getAll method to return the same assets (simulating they were already saved) - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: assets, - }); - - // Act: Save the assets - await assetsService.saveMany(assets); - - // Assert: TRC20 with zero balance should be in the "removed" list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], // TRX is always present - removed: [trc20AssetId], // Zero balance TRC20 should be removed - }, - }, - }, - ); - }); - - it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Create maximum energy and bandwidth assets with zero amounts - 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: '', - }, - ]; - - // Mock the getAll method to return empty - mockState.getKey.mockResolvedValue({}); - - // Act: Save the assets - await assetsService.saveMany(assets); - - // Assert: Maximum energy and bandwidth should be in the "added" list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.MaximumEnergyMainnet, - KnownCaip19Id.MaximumBandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }); - - it('keeps staked assets even with zero amounts', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Create staked assets with zero amounts - 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: '', - }, - ]; - - // Mock the getAll method to return empty - mockState.getKey.mockResolvedValue({}); - - // Act: Save the assets - await assetsService.saveMany(assets); - - // Assert: Staked assets should be in the "added" list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.TrxStakedForBandwidthMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }); - - describe('updating assets from 0 to >0', () => { - it('adds energy to the asset list when it updates from 0 to >0', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with zero energy - const savedAssets: AssetEntity[] = [ + await withAssetsService(async ({ assetsService, mockState }) => { + const assets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, @@ -1103,45 +925,26 @@ describe('AssetsService', () => { network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '0', // Previously zero + rawAmount: '0', uiAmount: '0', iconUrl: '', }, - ]; - - // New assets with non-zero energy - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, { - assetType: KnownCaip19Id.EnergyMainnet, + assetType: KnownCaip19Id.BandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'ENERGY', + symbol: 'BANDWIDTH', decimals: 0, - rawAmount: '50000', // Now has energy! - uiAmount: '50000', + rawAmount: '0', + uiAmount: '0', iconUrl: '', }, ]; - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + mockState.getKey.mockResolvedValue({}); - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany(assets); - // Assert: Energy should be in the "added" list since it went from 0 to >0 expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1151,6 +954,7 @@ describe('AssetsService', () => { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, ]), removed: [], }, @@ -1158,12 +962,12 @@ describe('AssetsService', () => { }, ); }); + }); - it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with zero bandwidth - const savedAssets: AssetEntity[] = [ + it('removes 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, @@ -1175,19 +979,41 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: KnownCaip19Id.BandwidthMainnet, + assetType: trc20AssetId, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', // Previously zero + symbol: 'USDT', + decimals: 6, + rawAmount: '0', uiAmount: '0', iconUrl: '', }, ]; - // New assets with non-zero bandwidth - const updatedAssets: AssetEntity[] = [ + 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('keeps maximum energy and bandwidth assets even with zero amounts', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const assets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, @@ -1199,104 +1025,31 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: KnownCaip19Id.BandwidthMainnet, + assetType: KnownCaip19Id.MaximumEnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'BANDWIDTH', + symbol: 'MAX-ENERGY', decimals: 0, - rawAmount: '1500', // Now has bandwidth! - uiAmount: '1500', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Bandwidth should be in the "added" list since it went from 0 to >0 - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }); - - it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { - const { assetsService, mockState } = buildAssetsService(); - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - // Arrange: Previously saved assets with zero USDT - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', + rawAmount: '0', + uiAmount: '0', iconUrl: '', }, { - assetType: trc20AssetId, + assetType: KnownCaip19Id.MaximumBandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', // Previously zero + symbol: 'MAX-BANDWIDTH', + decimals: 0, + rawAmount: '0', uiAmount: '0', iconUrl: '', }, ]; - // New assets with non-zero USDT - 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', // Now has USDT! - uiAmount: '100', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + mockState.getKey.mockResolvedValue({}); - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany(assets); - // Assert: USDT should be in the "added" list since it went from 0 to >0 expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1305,7 +1058,8 @@ describe('AssetsService', () => { [mockAccount.id]: { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, - trc20AssetId, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, ]), removed: [], }, @@ -1313,13 +1067,11 @@ describe('AssetsService', () => { }, ); }); + }); - it('handles multiple assets updating from 0 to >0 simultaneously', async () => { - const { assetsService, mockState } = buildAssetsService(); - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - // Arrange: Previously saved assets with all zeros - const savedAssets: AssetEntity[] = [ + it('keeps staked assets even with zero amounts', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const assets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, @@ -1331,30 +1083,20 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, + assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, + symbol: 'sTRX-BANDWIDTH', + decimals: 6, rawAmount: '0', uiAmount: '0', iconUrl: '', }, { - assetType: trc20AssetId, + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'USDT', + symbol: 'sTRX-ENERGY', decimals: 6, rawAmount: '0', uiAmount: '0', @@ -1362,59 +1104,10 @@ describe('AssetsService', () => { }, ]; - // New assets with all non-zero amounts - 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: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + mockState.getKey.mockResolvedValue({}); - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); + await assetsService.saveMany(assets); - // Assert: All assets should be in the "added" list expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1423,9 +1116,8 @@ describe('AssetsService', () => { [mockAccount.id]: { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - trc20AssetId, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, ]), removed: [], }, @@ -1433,554 +1125,857 @@ describe('AssetsService', () => { }, ); }); + }); - it('handles staked assets updating from 0 to >0', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with zero staked amounts - 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: '', - }, - ]; + 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 }) => { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + ]), + removed: [], + }, + }, + }, + ); + }); + }); - // New assets after staking (TRX reduced, staked asset increased) - 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', // User staked 3 TRX - uiAmount: '3', - iconUrl: '', - }, - ]; + it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + }); + }); - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, + it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); }); + }); - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); + it('handles multiple assets updating from 0 to >0 simultaneously', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + trc20AssetId, + ]), + removed: [], + }, + }, + }, + ); + }); + }); - // Assert: Staked asset should be in the "added" list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], + it('handles staked assets updating from 0 to >0', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '5000000', + uiAmount: '5', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: '0', + uiAmount: '0', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'sTRX-ENERGY', + decimals: 6, + rawAmount: '3000000', + uiAmount: '3', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + ]), + removed: [], + }, }, }, - }, - ); + ); + }); }); }); describe('updating assets going down', () => { it('updates energy balance when it decreases but remains >0', async () => { - const { assetsService, mockState } = buildAssetsService(); + await withAssetsService(async ({ assetsService, mockState }) => { + 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(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', + }, + }, + }, + }, + ); + }); + }); - // Arrange: Previously saved assets with high energy - 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', // High energy - uiAmount: '100000', - iconUrl: '', - }, - ]; - - // New assets with reduced energy (after transaction consumption) - 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', // Energy consumed by transaction - uiAmount: '35000', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Energy should still be in the "added" list (not removed) - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, + it('updates bandwidth balance when it decreases but remains >0', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', }, - }, - ); - - // Assert: Balance update event should be emitted with new amount - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '35000', - }, - }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '5000', + uiAmount: '5000', + iconUrl: '', }, - }, - ); - }); - - it('updates bandwidth balance when it decreases but remains >0', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with high bandwidth - 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', // High bandwidth - uiAmount: '5000', - iconUrl: '', - }, - ]; - - // New assets with reduced bandwidth (after transaction consumption) - 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', // Bandwidth consumed by transaction - uiAmount: '4700', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Bandwidth should still be in the "added" list (not removed) - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', }, - }, - ); - - // Assert: Balance update event should be emitted with new amount - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', + { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '4700', + }, }, }, }, - }, - ); + ); + }); }); it('keeps energy in the list when it drops to 0', async () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with some energy - 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: '', - }, - ]; - - // New assets with zero energy (fully consumed) - 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', // All energy consumed - uiAmount: '0', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Energy should still be in the "added" list (not removed) because it's an essential asset - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], + await withAssetsService(async ({ assetsService, mockState }) => { + 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 assetsService.saveMany(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 () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets with some bandwidth - 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: '', - }, - ]; - - // New assets with zero bandwidth (fully consumed) - 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', // All bandwidth consumed - uiAmount: '0', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Bandwidth should still be in the "added" list (not removed) because it's an essential asset - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], + await withAssetsService(async ({ assetsService, mockState }) => { + 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(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 () => { - const { assetsService, mockState } = buildAssetsService(); - - // Arrange: Previously saved assets - 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: '', - }, - ]; - - // New assets after a TRC20 transaction (both consumed) - 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', // Consumed by smart contract call - uiAmount: '45000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1235', // Consumed by transaction size - uiAmount: '1235', - iconUrl: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: Both should remain in the asset list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, + await withAssetsService(async ({ assetsService, mockState }) => { + const savedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: '', }, - }, - ); - - // Assert: Balance update event should reflect new amounts - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '2', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '45000', + { + 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(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', + }, + }, + ); + + 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', + }, }, }, }, - }, - ); + ); + }); }); }); }); diff --git a/packages/snap/src/services/assets/AssetsService.ts b/packages/snap/src/services/assets/AssetsService.ts index ee299b93..412d4cd0 100644 --- a/packages/snap/src/services/assets/AssetsService.ts +++ b/packages/snap/src/services/assets/AssetsService.ts @@ -567,12 +567,10 @@ export class AssetsService { /** * Extracts current and maximum energy from the account resources. * - * @param account.account - * @param account - The keyring account. - * @param scope - The network. - * @param resources - Account resources (energy, bandwidth). - * @param account.scope - * @param account.tronAccountResources + * @param options - Options object. + * @param options.account - The keyring account. + * @param options.scope - The network. + * @param options.tronAccountResources - Account resources (energy, bandwidth). * @returns AssetEntity[] - Array containing energy and maximum energy assets. */ #extractEnergy({