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/jest.config.js b/packages/snap/jest.config.js index 35a95aea..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: 49.68, - functions: 52.79, - lines: 65.54, - statements: 65.62, + branches: 52.24, + functions: 54.75, + lines: 67.06, + statements: 67.13, }, }, 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..1e8b721a --- /dev/null +++ b/packages/snap/src/clients/trongrid/TrongridApiClient.test.ts @@ -0,0 +1,301 @@ +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'; + +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', + [Network.Shasta]: 'https://api.shasta.trongrid.io', + }; + + const configProvider = new ConfigProvider(); + const baseConfig = configProvider.get(); + jest.spyOn(configProvider, 'get').mockReturnValue({ + ...baseConfig, + trongridApi: { + baseUrls: options.trongridBaseUrls ?? defaultBaseUrls, + }, + tronHttpApi: { + baseUrls: options.tronHttpBaseUrls ?? defaultBaseUrls, + }, + }); + + const tronHttpClient = new TronHttpClient({ configProvider }); + const cache = new InMemoryCache(mockLogger); + const client = new TrongridApiClient({ + configProvider, + tronHttpClient, + cache, + }); + + // eslint-disable-next-line no-restricted-globals + const originalFetch = global.fetch; + try { + return await testFunction({ + client, + configProvider, + tronHttpClient, + cache, + }); + } 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 () => { + await withTrongridApiClient(async ({ client }) => { + 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', + }), + }), + ); + }); + }); + + it('returns empty array when no TRC20 tokens are found', async () => { + await withTrongridApiClient(async ({ client }) => { + // 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 () => { + await withTrongridApiClient(async ({ client }) => { + // 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 () => { + const invalidBaseUrls = { + [Network.Mainnet]: 'https://api.trongrid.io', + [Network.Nile]: '', + [Network.Shasta]: '', + }; + + 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 withTrongridApiClient(async ({ client }) => { + // 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 () => { + await withTrongridApiClient(async ({ client }) => { + // 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 () => { + await withTrongridApiClient(async ({ client }) => { + 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), + ); + }); + }); + + it('validates TRC20 balance data structure', async () => { + await withTrongridApiClient(async ({ client }) => { + const validBalances: Trc20Balance[] = [ + { TokenAddress1: '100' }, + { 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' } }, + ), + ); + + 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..562caa0a 100644 --- a/packages/snap/src/services/assets/AssetsService.test.ts +++ b/packages/snap/src/services/assets/AssetsService.test.ts @@ -1,20 +1,20 @@ -/* 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'; 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 { 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'; import type { State, UnencryptedStateValue } from '../state/State'; -// Mock context module to avoid circular dependency jest.mock('../../context', () => ({ configProvider: { get() { @@ -36,34 +36,86 @@ 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'); -/* 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'], +}; + +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. @@ -98,9 +150,9 @@ function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { return assets.find((a: AssetEntity) => a.assetType === assetType); } -describe('AssetsService', () => { - let assetsService: any; - let mockAssetsRepository: jest.Mocked< +type WithAssetsServiceCallback = (payload: { + assetsService: InstanceType; + mockAssetsRepository: jest.Mocked< Pick< AssetsRepository, | 'saveMany' @@ -109,302 +161,754 @@ describe('AssetsService', () => { | 'getByAccountIdAndAssetTypes' > >; - let mockState: jest.Mocked< + mockState: jest.Mocked< Pick, 'getKey' | 'setKey'> >; - let mockTrongridApiClient: jest.Mocked< - Pick - >; - let mockTronHttpClient: jest.Mocked< - Pick - >; - let mockPriceApiClient: jest.Mocked< - Pick + mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > >; - let mockTokenApiClient: jest.Mocked< - Pick + mockTronHttpClient: jest.Mocked>; + mockPriceApiClient: jest.Mocked< + Pick< + PriceApiClient, + 'getFiatExchangeRates' | 'getHistoricalPrices' | 'getMultipleSpotPrices' + > >; + 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, + | '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), }; - 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(), - }; - mockTronHttpClient = { - getAccountResources: jest.fn(), - }; - mockPriceApiClient = { - getMultipleSpotPrices: jest.fn(), - }; - mockTokenApiClient = { - getTokensMetadata: jest.fn(), - }; - - assetsService = new AssetsService({ - logger: mockLogger, - assetsRepository: mockAssetsRepository, - state: mockState, - trongridApiClient: mockTrongridApiClient, - tronHttpClient: mockTronHttpClient, - priceApiClient: mockPriceApiClient, - tokenApiClient: mockTokenApiClient, - }); + const mockTrongridApiClient: jest.Mocked< + Pick< + TrongridApiClient, + 'getAccountInfoByAddress' | 'getTrc20BalancesByAddress' + > + > = { + getAccountInfoByAddress: jest.fn(), + getTrc20BalancesByAddress: jest.fn(), + }; + + 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, }); - 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 - 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: [], - }, + return await testFunction({ + 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 () => { + 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'); }, - }, - ); + ); + }); + + it('returns zero TRX and resources when fallback also returns empty', async () => { + 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(); + }, + ); + }); + + it('gracefully handles fallback endpoint failure', async () => { + 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'); + }, + ); + }); + + it('filters out TRC20 tokens without price data from inactive account', async () => { + 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(); + }, + ); + }); }); - it('removes non-essential assets with zero amounts', async () => { - // 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: '', - }, - ]; + describe('partial failure handling', () => { + it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + 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'); + }, + ); + }); + + it('continues with zero resources when only resources request fails', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1000000, + trc20: [], + }), + ); + mockTronHttpClient.getAccountResources.mockRejectedValue( + new Error('Resources endpoint unavailable'), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + 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'); + }, + ); + }); + }); - // Mock the getAll method to return the same assets (simulating they were already saved) - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: assets, + describe('bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); }); - // 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('returns remaining free bandwidth when no staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 200 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('400'); }, - }, - ); + ); + }); + + it('returns combined remaining free + staked bandwidth', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('290'); + }, + ); + }); + + it('clamps to 0 when used exceeds maximum', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ + freeNetUsed: 600, + NetUsed: 50, + NetLimit: 16, + }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); }); - it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { - // 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: [], - }, + describe('maximum bandwidth', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('0'); }, - }, - ); + ); + }); + + it('returns only free bandwidth limit when no staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({}), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('600'); + }, + ); + }); + + it('returns free + staked bandwidth limit', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ NetLimit: 48 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) + ?.rawAmount, + ).toBe('648'); + }, + ); + }); }); - it('keeps staked assets even with zero amounts', async () => { - // 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('energy', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); }, - }, - ); + ); + }); + + it('returns full energy when none consumed', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + + it('returns remaining energy after partial consumption', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('617'); + }, + ); + }); + + it('clamps to 0 when EnergyUsed exceeds EnergyLimit from leasing', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); }); - describe('updating assets from 0 to >0', () => { - it('adds energy to the asset list when it updates from 0 to >0', async () => { - // Arrange: Previously saved assets with zero energy - const savedAssets: AssetEntity[] = [ + describe('maximum energy', () => { + it('returns 0 when account has no resources', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('0'); + }, + ); + }); + + it('returns EnergyLimit from staking', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, + ).toBe('329'); + }, + ); + }); + }); + }); + + describe('saveMany', () => { + it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { + await withAssetsService(async ({ assetsService, mockState }) => { + const assets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, keyringAccountId: mockAccount.id, @@ -421,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, @@ -469,6 +954,7 @@ describe('AssetsService', () => { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, ]), removed: [], }, @@ -476,10 +962,12 @@ describe('AssetsService', () => { }, ); }); + }); - it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { - // 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, @@ -491,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, @@ -515,26 +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: '0', + uiAmount: '0', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.MaximumBandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'MAX-BANDWIDTH', decimals: 0, - rawAmount: '1500', // Now has bandwidth! - uiAmount: '1500', + 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: Bandwidth should be in the "added" list since it went from 0 to >0 expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -543,7 +1058,8 @@ describe('AssetsService', () => { [mockAccount.id]: { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, ]), removed: [], }, @@ -551,12 +1067,11 @@ describe('AssetsService', () => { }, ); }); + }); - it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - // Arrange: Previously saved assets with zero USDT - 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, @@ -568,681 +1083,31 @@ describe('AssetsService', () => { iconUrl: '', }, { - assetType: trc20AssetId, + assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'USDT', + symbol: 'sTRX-BANDWIDTH', decimals: 6, - rawAmount: '0', // Previously zero + 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, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: USDT 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, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }); - - it('handles multiple assets updating from 0 to >0 simultaneously', async () => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - // Arrange: Previously saved assets with all zeros - 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: '', - }, - ]; - - // 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, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // Assert: All assets should be in the "added" list - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }); - - it('handles staked assets updating from 0 to >0', async () => { - // 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: '', - }, - ]; - - // 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: '', - }, - ]; - - // Mock state to return previously saved assets - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - // Act: Save updated assets - await assetsService.saveMany(updatedAssets); - - // 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: [], - }, - }, - }, - ); - }); - }); - - describe('updating assets going down', () => { - it('updates energy balance when it decreases but remains >0', async () => { - // 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: [], - }, - }, - }, - ); - - // 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', - }, - }, - }, - }, - ); - }); - - it('updates bandwidth balance when it decreases but remains >0', async () => { - // 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: [], - }, - }, - }, - ); - - // 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.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, - ); - }); - - it('keeps energy in the list when it drops to 0', async () => { - // 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: [], - }, - }, - }, - ); - }); - - it('keeps bandwidth in the list when it drops to 0', async () => { - // 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: [], - }, - }, - }, - ); - }); - - it('handles both energy and bandwidth fluctuating in a transaction', async () => { - // 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, + assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', + symbol: 'sTRX-ENERGY', 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', + 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: Both should remain in the asset list expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1251,304 +1116,866 @@ describe('AssetsService', () => { [mockAccount.id]: { added: expect.arrayContaining([ KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, ]), removed: [], }, }, }, ); + }); + }); - // 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', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', + 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: [], }, }, }, - }, - ); - }); - }); - }); - - 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'); + 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: [], + }, + }, + }, + ); + }); }); - }); - - 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('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: [], + }, + }, + }, + ); + }); }); - 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('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: [], + }, + }, + }, + ); + }); }); - 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'); + 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('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', - ); + describe('updating assets going down', () => { + it('updates energy 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: '', + }, + { + 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', + }, + }, + }, + }, + ); + }); }); - 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('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: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '5000', + uiAmount: '5000', + iconUrl: '', + }, + ]; + + const updatedAssets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '4700', + uiAmount: '4700', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(updatedAssets); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, + }, + }, + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: { + unit: 'TRX', + amount: '1', + }, + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '4700', + }, + }, + }, + }, + ); + }); }); - it('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', - ); + it('keeps energy in the list when it drops 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: '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: [], + }, + }, + }, + ); + }); }); - }); - - 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('keeps bandwidth in the list when it drops 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: '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('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'); + it('handles both energy and bandwidth fluctuating in a transaction', async () => { + 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: '', + }, + { + 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: [], + }, + }, + }, + ); + + 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 33d0b623..412d4cd0 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, @@ -153,77 +194,186 @@ export class AssetsService { 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 []; + }) + : []; + + 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), - ]; + const rawAssets = this.#extractAssets(account, scope, accountData); + + const assetTypes = rawAssets.map((asset) => asset.assetType); + const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); const [assetsMetadata, spotPrices] = await Promise.all([ this.getAssetsMetadata(assetTypes), this.#priceApiClient .getMultipleSpotPrices(priceableAssetTypes, 'usd') - .catch(() => ({})), // If prices fail, return empty - filtering will handle it + .catch(() => ({})), ]); - const assets = [ - nativeAsset, - ...stakedNativeAssets, - ...bandwidthAssets, - ...energyAssets, - ...trc10Assets, - ...trc20Assets, - ].map((asset) => { + 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, + tronAccountResources: data.resources, + }), + ...this.#extractEnergy({ + account, + scope, + tronAccountResources: 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 +395,6 @@ export class AssetsService { } else { symbol = metadata?.symbol ?? symbol; } - // Include iconUrl from metadata if available iconUrl = metadata.iconUrl ?? iconUrl; } @@ -259,54 +408,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 +436,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 +468,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 +513,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 +564,15 @@ export class AssetsService { ]; } + /** + * Extracts current and maximum energy from the account resources. + * + * @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({ account, scope, @@ -457,7 +583,6 @@ export class AssetsService { tronAccountResources: AccountResources | Record; }): AssetEntity[] { const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; - const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; /** @@ -489,21 +614,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 +640,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( 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';