From 0e27e35638c881481e278edefc82e466517f9711 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 15:44:04 +0100 Subject: [PATCH 1/7] refactor(tron-wallet-snap): extract SnapAssetsAdapter behind AssetsService Move existing assets logic into SnapAssetsAdapter and keep AssetsService as a thin facade that always delegates to the Snap adapter. Behavior unchanged; prepares for a later Core adapter / feature-flag routing PR. --- .../src/services/assets/AssetsService.ts | 1773 +--------------- .../assets/adapters/SnapAssetsAdapter.ts | 1845 +++++++++++++++++ 2 files changed, 1882 insertions(+), 1736 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index a33f16e5..5cc5475d 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,123 +1,33 @@ -import { KeyringEvent } from '@metamask/keyring-api'; -import type { - AccountAssetListUpdatedEvent, - AccountBalancesUpdatedEvent, - KeyringAccount, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, - FungibleAssetMetadata, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; +import type { KeyringAccount } from '@metamask/keyring-api'; import type { CaipAssetType } from '@metamask/utils'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; -import { pick } from 'lodash'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; -import type { - FiatTicker, - SpotPrice, - SpotPrices, -} from '../../clients/price-api/types'; -import { - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - VsCurrencyParamStruct, -} from '../../clients/price-api/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; -import type { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { - RawTronUnfrozenV2, - Trc20Balance, - TronAccount, -} from '../../clients/trongrid/types'; -import type { KnownCaip19Id, Network } from '../../constants'; -import { - BANDWIDTH_METADATA, - ENERGY_METADATA, - ESSENTIAL_ASSETS, - MAX_BANDWIDTH_METADATA, - MAX_ENERGY_METADATA, - Networks, - TokenMetadata, - TRX_IN_LOCK_PERIOD_METADATA, - TRX_METADATA, - TRX_READY_FOR_WITHDRAWAL_METADATA, - TRX_STAKED_FOR_BANDWIDTH_METADATA, - TRX_STAKED_FOR_ENERGY_METADATA, - TRX_STAKING_REWARDS_METADATA, -} from '../../constants'; -import { configProvider } from '../../context'; +import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; -import { toUiAmount } from '../../utils/conversion'; -import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; import type { AssetsRepository } from './AssetsRepository'; -import type { - InLockPeriodCaipAssetType, - NativeCaipAssetType, - NftCaipAssetType, - ReadyForWithdrawalCaipAssetType, - ResourceCaipAssetType, - StakedCaipAssetType, - StakingRewardsCaipAssetType, - TokenCaipAssetType, -} from './types'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** - * 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. + * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter + * (legacy snap-owned reads/writes). Core adapter routing can be introduced later + * without changing callers. */ -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']; - unfrozenV2: TronAccount['unfrozenV2']; - accountResource: TronAccount['account_resource'] | undefined; - }; - /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ - resources: AccountResources | Record; - /** Unclaimed staking rewards in sun (0 if no rewards). */ - stakingRewards: number; -}; - export class AssetsService { - readonly #logger: ILogger; - - readonly #assetsRepository: AssetsRepository; - - readonly #state: State; - - readonly #trongridApiClient: TrongridApiClient; + readonly #snapAdapter: SnapAssetsAdapter; - readonly #tronHttpClient: TronHttpClient; - - readonly #priceApiClient: PriceApiClient; - - readonly #tokenApiClient: TokenApiClient; - - readonly #snapClient: SnapClient; - - readonly cacheTtlsMilliseconds: { - fiatExchangeRates: number; - spotPrices: number; - historicalPrices: number; - }; + readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; constructor({ logger, @@ -138,1561 +48,78 @@ export class AssetsService { tokenApiClient: TokenApiClient; snapClient: SnapClient; }) { - this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#assetsRepository = assetsRepository; - this.#state = state; - this.#trongridApiClient = trongridApiClient; - this.#tronHttpClient = tronHttpClient; - this.#priceApiClient = priceApiClient; - this.#tokenApiClient = tokenApiClient; - this.#snapClient = snapClient; - - const { cacheTtlsMilliseconds } = configProvider.get().priceApi; - this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + this.#snapAdapter = new SnapAssetsAdapter({ + logger, + assetsRepository, + state, + trongridApiClient, + tronHttpClient, + priceApiClient, + tokenApiClient, + snapClient, + }); + this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } static isFiat(caipAssetId: CaipAssetType): boolean { - return caipAssetId.includes('swift:0/iso4217:'); + return SnapAssetsAdapter.isFiat(caipAssetId); + } + + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } async getAccountAssets(accountId: string): Promise { - return this.#assetsRepository.getByAccountId(accountId); + return this.#snapAdapter.getAccountAssets(accountId); } async getAccountAssetsByIDs( accountId: string, assetTypes: string[], ): Promise<(AssetEntity | null)[]> { - return this.#assetsRepository.getByAccountIdAndAssetTypes( - accountId, - assetTypes, - ); + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetTypes); } async getAccountAssetByID( accountId: string, assetType: string, ): Promise { - return this.#assetsRepository.getByAccountIdAndAssetType( - accountId, - assetType, - ); + return this.#snapAdapter.getAccountAssetByID(accountId, assetType); } - /** - * 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, ): Promise { - this.#logger.info('Fetching assets and balances by account', { - account, - scope, - }); - - const [ - tronAccountInfoRequest, - tronAccountResourcesRequest, - stakingRewardsRequest, - ] = await Promise.allSettled([ - this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), - this.#tronHttpClient.getAccountResources(scope, account.address), - this.#tronHttpClient.getReward(scope, account.address), - ]); - - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - if (isInactiveAccount) { - this.#logger.info( - 'Account info request failed, treating as inactive account', - { account, scope }, - ); - } - - const trc20BalancesFallback = isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; - - const accountData = this.#buildAccountData({ - tronAccountInfoRequest, - tronAccountResourcesRequest, - trc20BalancesFallback, - stakingRewardsRequest, - }); - - 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(async (error) => { - await this.#snapClient.trackError(error as Error); - return {}; - }), - ]); - - const enrichedAssets = this.#enrichAssetsWithMetadata( - rawAssets, - assetsMetadata, - ); - return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); - } - - /** - * Filters out spam tokens (those without price data). - * Essential assets are always kept. Tokens need price data to be included. - * - * @param assets - The assets to filter. - * @param spotPrices - Pre-fetched USD prices for assets. - * @returns The filtered assets. - */ - #filterTokensWithoutPriceData( - assets: AssetEntity[], - spotPrices: SpotPrices | Record, - ): AssetEntity[] { - const filtered = assets.filter((asset) => { - // Essential assets (TRX, staked, energy, bandwidth) are always kept - if (ESSENTIAL_ASSETS.includes(asset.assetType)) { - return true; - } - // Tokens: keep only if they have price data - const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; - return typeof spotPrice?.price === 'number'; - }); - - return filtered; - } - - /** - * 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). - * @param params.stakingRewardsRequest - The settled promise result from getReward. - * @returns NormalizedAccountData - Consistent data shape for extraction. - */ - #buildAccountData({ - tronAccountInfoRequest, - tronAccountResourcesRequest, - trc20BalancesFallback, - stakingRewardsRequest, - }: { - tronAccountInfoRequest: PromiseSettledResult; - tronAccountResourcesRequest: PromiseSettledResult; - trc20BalancesFallback: Trc20Balance[]; - stakingRewardsRequest: PromiseSettledResult; - }): NormalizedAccountData { - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - const resources = - tronAccountResourcesRequest.status === 'fulfilled' - ? tronAccountResourcesRequest.value - : {}; - const stakingRewards = - stakingRewardsRequest.status === 'fulfilled' - ? Math.max(0, stakingRewardsRequest.value) - : 0; - - if (isInactiveAccount) { - return { - nativeBalance: 0, - trc10Balances: [], - trc20Balances: trc20BalancesFallback, - stakedData: { - frozenV2: [], - unfrozenV2: [], - accountResource: undefined, - }, - resources, - stakingRewards, - }; - } - - const tronAccountInfo = tronAccountInfoRequest.value; - return { - nativeBalance: tronAccountInfo.balance ?? 0, - trc10Balances: tronAccountInfo.assetV2 ?? [], - trc20Balances: tronAccountInfo.trc20 ?? [], - stakedData: { - frozenV2: tronAccountInfo.frozenV2 ?? [], - unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], - accountResource: tronAccountInfo.account_resource, - }, - resources, - stakingRewards, - }; - } - - /** - * 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.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), - this.#extractInLockPeriodAsset(account, scope, data.stakedData), - this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), - ...this.#extractTrc10Assets(account, scope, data.trc10Balances), - ...this.#extractTrc20Assets(account, scope, data.trc20Balances), - ...this.#extractBandwidth({ - account, - scope, - 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; - - const { - symbol: initialSymbol, - decimals: initialDecimals = 0, - iconUrl: initialIconUrl, - } = asset; - let symbol = initialSymbol; - let decimals = initialDecimals; - let iconUrl = initialIconUrl; - - if (metadata?.fungible) { - const unit = metadata.units?.[0]; - if (unit) { - symbol = unit.symbol ?? metadata.symbol ?? symbol; - decimals = unit.decimals ?? decimals; - } else { - symbol = metadata?.symbol ?? symbol; - } - iconUrl = metadata.iconUrl ?? iconUrl; - } - - const uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); - - return { - ...asset, - symbol, - decimals, - uiAmount, - iconUrl, - }; - }); - } - - /** - * Extracts the native TRX asset from the balance. - * - * @param account - The keyring account. - * @param scope - The network. - * @param balance - The native balance in sun. - * @returns AssetEntity - The native TRX asset. - */ - #extractNativeAsset( - account: KeyringAccount, - scope: Network, - balance: number, - ): AssetEntity { - return { - assetType: Networks[scope].nativeToken.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].nativeToken.symbol, - decimals: Networks[scope].nativeToken.decimals, - rawAmount: balance.toString(), - uiAmount: toUiAmount( - balance, - Networks[scope].nativeToken.decimals, - ).toString(), - iconUrl: Networks[scope].nativeToken.iconUrl, - }; - } - - /** - * Extracts staked TRX assets (for bandwidth and energy). - * - * @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 (always 2: bandwidth and energy, amounts may be 0). - */ - #extractStakedNativeAssets( - account: KeyringAccount, - scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity[] { - const assets: AssetEntity[] = []; - - let stakedBandwidthAmount = 0; - let stakedEnergyAmount = 0; - - stakedData.frozenV2?.forEach((frozen) => { - const amount = frozen.amount ?? 0; - - if (frozen.type === 'ENERGY') { - stakedEnergyAmount += amount; - } else if (!frozen.type) { - // Item without type is for bandwidth - stakedBandwidthAmount += amount; - } - }); - - const delegatedBandwidth = - stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; - const delegatedEnergy = - stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; - - stakedBandwidthAmount += delegatedBandwidth; - stakedEnergyAmount += delegatedEnergy; - - assets.push({ - assetType: Networks[scope].stakedForBandwidth.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakedForBandwidth.symbol, - decimals: Networks[scope].stakedForBandwidth.decimals, - rawAmount: stakedBandwidthAmount.toString(), - uiAmount: toUiAmount( - stakedBandwidthAmount, - Networks[scope].stakedForBandwidth.decimals, - ).toString(), - iconUrl: Networks[scope].stakedForBandwidth.iconUrl, - }); - - assets.push({ - assetType: Networks[scope].stakedForEnergy.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakedForEnergy.symbol, - decimals: Networks[scope].stakedForEnergy.decimals, - rawAmount: stakedEnergyAmount.toString(), - uiAmount: toUiAmount( - stakedEnergyAmount, - Networks[scope].stakedForEnergy.decimals, - ).toString(), - iconUrl: Networks[scope].stakedForEnergy.iconUrl, - }); - - return assets; - } - - /** - * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakedData - Staking data including unfrozen balances. - * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). - */ - #extractReadyForWithdrawalAsset( - account: KeyringAccount, - scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity { - const currentTimestamp = Date.now(); - let readyForWithdrawalAmount = 0; - - stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { - const expireTime = unfrozen.unfreeze_expire_time ?? 0; - const amount = unfrozen.unfreeze_amount ?? 0; - - if (expireTime <= currentTimestamp && amount > 0) { - readyForWithdrawalAmount += amount; - } - }); - - const { id, symbol, decimals, iconUrl } = - Networks[scope].readyForWithdrawal; - - return { - assetType: id, - keyringAccountId: account.id, - network: scope, - symbol, - decimals, - rawAmount: readyForWithdrawalAmount.toString(), - uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), - iconUrl, - }; - } - - /** - * Extracts staking rewards asset (unclaimed voting rewards). - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakingRewards - Unclaimed staking rewards in sun. - * @returns AssetEntity - The staking rewards asset. - */ - #extractStakingRewardsAsset( - account: KeyringAccount, - scope: Network, - stakingRewards: number, - ): AssetEntity { - return { - assetType: Networks[scope].stakingRewards.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakingRewards.symbol, - decimals: Networks[scope].stakingRewards.decimals, - rawAmount: stakingRewards.toString(), - uiAmount: toUiAmount( - stakingRewards, - Networks[scope].stakingRewards.decimals, - ).toString(), - iconUrl: Networks[scope].stakingRewards.iconUrl, - }; - } - - /** - * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). - * This represents TRX that the user has initiated unstaking for but must wait - * the 14-day lock period before they can withdraw. - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakedData - Staking data including unfrozen balances. - * @returns AssetEntity - The in-lock-period asset (amount may be 0). - */ - #extractInLockPeriodAsset( - account: KeyringAccount, - scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity { - const currentTimestamp = Date.now(); - let inLockPeriodAmount = 0; - - stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { - const expireTime = unfrozen.unfreeze_expire_time ?? 0; - const amount = unfrozen.unfreeze_amount ?? 0; - - if (expireTime > currentTimestamp && amount > 0) { - inLockPeriodAmount += amount; - } - }); - - const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; - - return { - assetType: id, - keyringAccountId: account.id, - network: scope, - symbol, - decimals, - rawAmount: inLockPeriodAmount.toString(), - uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), - iconUrl, - }; - } - - /** - * Extracts current and maximum bandwidth from the account resources. - * - * @param options - Options object. - * @param options.account - The account to extract bandwidth for. - * @param options.scope - The network to extract bandwidth for. - * @param options.tronAccountResources - The account resources to extract bandwidth for. - * @returns The bandwidth assets. - */ - #extractBandwidth({ - account, - scope, - tronAccountResources, - }: { - account: KeyringAccount; - scope: Network; - tronAccountResources: AccountResources | Record; - }): AssetEntity[] { - const freeBandwidth = tronAccountResources?.freeNetLimit ?? 0; - const stakingBandwidth = tronAccountResources?.NetLimit ?? 0; - const maximumBandwidth = freeBandwidth + stakingBandwidth; - - const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; - const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; - const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; - - const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); - - return [ - { - assetType: Networks[scope].bandwidth.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].bandwidth.symbol, - decimals: Networks[scope].bandwidth.decimals, - rawAmount: availableBandwidth.toString(), - uiAmount: availableBandwidth.toString(), - iconUrl: Networks[scope].bandwidth.iconUrl, - }, - { - assetType: Networks[scope].maximumBandwidth.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].maximumBandwidth.symbol, - decimals: Networks[scope].maximumBandwidth.decimals, - rawAmount: maximumBandwidth.toString(), - uiAmount: maximumBandwidth.toString(), - iconUrl: Networks[scope].maximumBandwidth.iconUrl, - }, - ]; - } - - /** - * 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, - tronAccountResources, - }: { - account: KeyringAccount; - scope: Network; - tronAccountResources: AccountResources | Record; - }): AssetEntity[] { - const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; - const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; - - /** - * We might have used more Energy than the maximum allocated - */ - const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); - - return [ - { - assetType: Networks[scope].energy.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].energy.symbol, - decimals: Networks[scope].energy.decimals, - rawAmount: availableEnergy.toString(), - uiAmount: availableEnergy.toString(), - iconUrl: Networks[scope].energy.iconUrl, - }, - { - assetType: Networks[scope].maximumEnergy.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].maximumEnergy.symbol, - decimals: Networks[scope].maximumEnergy.decimals, - rawAmount: maximumEnergy.toString(), - uiAmount: maximumEnergy.toString(), - iconUrl: Networks[scope].maximumEnergy.iconUrl, - }, - ]; - } - - /** - * Extracts TRC10 assets from the balances array. - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. - * @returns AssetEntity[] - Array of TRC10 asset entities. - */ - #extractTrc10Assets( - account: KeyringAccount, - scope: Network, - trc10Balances: TronAccount['assetV2'], - ): AssetEntity[] { - return ( - trc10Balances?.flatMap((tokenObject) => { - // assetV2 has structure: { "key": "token_id", "value": "balance" } - return { - assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: tokenObject.value?.toString() ?? '0', - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }) ?? [] - ); - } - - /** - * Extracts TRC20 assets from a balances array. - * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). - * @returns AssetEntity[] - Array of TRC20 asset entities. - */ - #extractTrc20Assets( - account: KeyringAccount, - scope: Network, - trc20Balances: Trc20Balance[], - ): AssetEntity[] { - return trc20Balances.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }); + return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { - this.#logger.info('Fetching metadata for assets', assetTypes); - - const { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - } = this.#splitAssetsByType(assetTypes); - - const nativeTokensMetadata = - this.#getNativeTokensMetadata(nativeAssetTypes); - const stakedTokensMetadata = this.#getStakedTokensMetadata( - stakedNativeAssetTypes, - ); - const readyForWithdrawalTokensMetadata = - this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); - const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( - inLockPeriodAssetTypes, - ); - const stakingRewardsMetadata = this.#getStakingRewardsMetadata( - stakingRewardsAssetTypes, - ); - const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); - const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( - maximunEnergyAssetTypes, - ); - const bandwidthTokensMetadata = - this.#getBandwidthMetadata(bandwidthAssetTypes); - const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( - maximunBandwidthAssetTypes, - ); - const tokensMetadata = await this.#getTokensMetadata([ - ...tokenTrc10AssetTypes, - ...tokenTrc20AssetTypes, - ]); - - const result = { - ...nativeTokensMetadata, - ...stakedTokensMetadata, - ...readyForWithdrawalTokensMetadata, - ...inLockPeriodTokensMetadata, - ...stakingRewardsMetadata, - ...energyTokensMetadata, - ...maximunEnergyTokensMetadata, - ...bandwidthTokensMetadata, - ...maximunBandwidthTokensMetadata, - ...tokensMetadata, - }; - - this.#logger.info('Resolved assets metadata', { assetTypes, result }); - - return result; - } - - #splitAssetsByType(assetTypes: CaipAssetType[]): { - nativeAssetTypes: NativeCaipAssetType[]; - stakedNativeAssetTypes: StakedCaipAssetType[]; - readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; - inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; - stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; - energyAssetTypes: ResourceCaipAssetType[]; - maximunEnergyAssetTypes: ResourceCaipAssetType[]; - bandwidthAssetTypes: ResourceCaipAssetType[]; - maximunBandwidthAssetTypes: ResourceCaipAssetType[]; - tokenTrc10AssetTypes: TokenCaipAssetType[]; - tokenTrc20AssetTypes: TokenCaipAssetType[]; - nftAssetTypes: NftCaipAssetType[]; - } { - const nativeAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195'), - ) as NativeCaipAssetType[]; - const stakedNativeAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/slip44:195-staked-for-'), - ) as StakedCaipAssetType[]; - const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-ready-for-withdrawal'), - ) as ReadyForWithdrawalCaipAssetType[]; - const inLockPeriodAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-in-lock-period'), - ) as InLockPeriodCaipAssetType[]; - const stakingRewardsAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-staking-rewards'), - ) as StakingRewardsCaipAssetType[]; - const energyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:energy'), - ) as ResourceCaipAssetType[]; - const maximunEnergyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-energy'), - ) as ResourceCaipAssetType[]; - const bandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:bandwidth'), - ) as ResourceCaipAssetType[]; - const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-bandwidth'), - ) as ResourceCaipAssetType[]; - const tokenTrc10AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc10:'), - ) as TokenCaipAssetType[]; - const tokenTrc20AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc20:'), - ) as TokenCaipAssetType[]; - const nftAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc721:'), - ) as NftCaipAssetType[]; - - return { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - nftAssetTypes, - }; + return this.#snapAdapter.getAssetsMetadata(assetTypes); } - #getNativeTokensMetadata( - assetTypes: NativeCaipAssetType[], - ): Record { - const nativeTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - nativeTokensMetadata[assetType] = { - fungible: TRX_METADATA.fungible, - name: TRX_METADATA.name, - symbol: TRX_METADATA.symbol, - iconUrl: TRX_METADATA.iconUrl, - units: [ - { - decimals: TRX_METADATA.decimals, - symbol: TRX_METADATA.symbol, - name: TRX_METADATA.name, - }, - ], - }; - } - - return nativeTokensMetadata; - } - - #getStakedTokensMetadata( - assetTypes: StakedCaipAssetType[], - ): Record { - // Can either be Staked for Bandwidth or Staked for Energy - const stakedTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); - - if (isForBandwdidth) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - const isForEnergy = assetType.endsWith('staked-for-energy'); - - if (isForEnergy) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - }, - ], - }; - } - } - - return stakedTokensMetadata; - } - - #getReadyForWithdrawalTokensMetadata( - assetTypes: ReadyForWithdrawalCaipAssetType[], - ): Record { - const readyForWithdrawalTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - readyForWithdrawalTokensMetadata[assetType] = { - fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, - units: [ - { - decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - }, - ], - }; - } - - return readyForWithdrawalTokensMetadata; - } - - #getStakingRewardsMetadata( - assetTypes: StakingRewardsCaipAssetType[], - ): Record { - const stakingRewardsMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - stakingRewardsMetadata[assetType] = { - fungible: TRX_STAKING_REWARDS_METADATA.fungible, - name: TRX_STAKING_REWARDS_METADATA.name, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKING_REWARDS_METADATA.decimals, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - name: TRX_STAKING_REWARDS_METADATA.name, - }, - ], - }; - } - - return stakingRewardsMetadata; - } - - #getInLockPeriodMetadata( - assetTypes: InLockPeriodCaipAssetType[], - ): Record { - const inLockPeriodTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - inLockPeriodTokensMetadata[assetType] = { - fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, - units: [ - { - decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - }, - ], - }; - } - - return inLockPeriodTokensMetadata; - } - - #getBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const bandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - bandwidthTokensMetadata[assetType] = { - fungible: BANDWIDTH_METADATA.fungible, - name: BANDWIDTH_METADATA.name, - symbol: BANDWIDTH_METADATA.symbol, - iconUrl: BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: BANDWIDTH_METADATA.decimals, - symbol: BANDWIDTH_METADATA.symbol, - name: BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return bandwidthTokensMetadata; - } - - #getMaximunBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunBandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunBandwidthTokensMetadata[assetType] = { - fungible: MAX_BANDWIDTH_METADATA.fungible, - name: MAX_BANDWIDTH_METADATA.name, - symbol: MAX_BANDWIDTH_METADATA.symbol, - iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: MAX_BANDWIDTH_METADATA.decimals, - symbol: MAX_BANDWIDTH_METADATA.symbol, - name: MAX_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return maximunBandwidthTokensMetadata; - } - - #getEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const energyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - energyTokensMetadata[assetType] = { - fungible: ENERGY_METADATA.fungible, - name: ENERGY_METADATA.name, - symbol: ENERGY_METADATA.symbol, - iconUrl: ENERGY_METADATA.iconUrl, - units: [ - { - decimals: ENERGY_METADATA.decimals, - symbol: ENERGY_METADATA.symbol, - name: ENERGY_METADATA.name, - }, - ], - }; - } - - return energyTokensMetadata; - } - - #getMaximunEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunEnergyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunEnergyTokensMetadata[assetType] = { - fungible: MAX_ENERGY_METADATA.fungible, - name: MAX_ENERGY_METADATA.name, - symbol: MAX_ENERGY_METADATA.symbol, - iconUrl: MAX_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: MAX_ENERGY_METADATA.decimals, - symbol: MAX_ENERGY_METADATA.symbol, - name: MAX_ENERGY_METADATA.name, - }, - ], - }; - } - - return maximunEnergyTokensMetadata; - } - - async #getTokensMetadata( - assetTypes: TokenCaipAssetType[], - ): Promise> { - return this.#tokenApiClient.getTokensMetadata(assetTypes); - } - - /** - * Checks if the asset has changed compared to passed assets lookup. - * - * @param asset - The asset to check. - * @param assetsLookup - The lookup table to check against. - * @returns True if the asset has changed, false otherwise. - */ - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - if (!savedAsset) { - return true; - } - - return savedAsset.rawAmount !== asset.rawAmount; - } - - /** - * Persist the latest fetched assets and emit the corresponding keyring events. - * - * The input is treated as the latest snapshot for the account/network pairs - * included in this sync. The method compares that snapshot with the - * previously saved state to detect disappeared assets, emits asset-list - * updates, and emits balance updates including synthetic zero balances for - * assets that vanished from the latest response. - * - * @param assets - The latest asset snapshot returned by the refresh flow. - */ async saveMany(assets: AssetEntity[]): Promise { - this.#logger.info('Saving assets', assets); - - const hasZeroAmount = (asset: AssetEntity): boolean => - asset.rawAmount === '0' || asset.uiAmount === '0'; - - const savedAssets = await this.getAll(); - const isEssentialAsset = (asset: AssetEntity): boolean => - ESSENTIAL_ASSETS.includes(asset.assetType); - - // Track only the account/network pairs refreshed in this run. - // That prevents us from treating assets from untouched networks as disappeared. - const syncedNetworksByAccount = assets.reduce>>( - (acc, asset) => { - acc[asset.keyringAccountId] ??= new Set(); - acc[asset.keyringAccountId]?.add(asset.network); - return acc; - }, - {}, - ); - - const incomingAssetKeys = new Set( - assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), - ); - - // A saved asset is considered disappeared only if its network was part of - // this sync, it is not essential, and it is missing from the latest - // snapshot for that account. - const disappearedAssets = savedAssets.filter((savedAsset) => { - const syncedNetworks = - syncedNetworksByAccount[savedAsset.keyringAccountId]; - - if ( - !syncedNetworks?.has(savedAsset.network) || - isEssentialAsset(savedAsset) - ) { - return false; - } - - return !incomingAssetKeys.has( - `${savedAsset.keyringAccountId}:${savedAsset.assetType}`, - ); - }); - - // A token should be removed from the visible asset list only when the latest - // snapshot says its balance is zero. Essential assets stay visible even at - // zero because they are part of the permanent Tron account model. - const shouldBeInRemovedList = (asset: AssetEntity): boolean => - hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list - - // Assets are added to the visible list when they are non-zero and either: - // - we are doing a full non-incremental broadcast, or - // - they are brand new, or - // - they existed before with zero balance and now became non-zero. - const shouldBeInAddedList = (asset: AssetEntity): boolean => - !shouldBeInRemovedList(asset); - - // Build the asset-list payload in two stages: - // 1. seed the removed list with assets that vanished from the latest - // snapshot entirely - // 2. fold in the current assets to report additions and explicit zero-balance - // removals in the same event - const assetListUpdatedPayload = disappearedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); - - for (const asset of assets) { - // Merge the current snapshot into the pre-seeded payload so each account - // ends up with one consolidated added/removed diff. - assetListUpdatedPayload[asset.keyringAccountId] = { - added: [ - ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), - ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), - ], - removed: [ - ...(assetListUpdatedPayload[asset.keyringAccountId]?.removed ?? []), - ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), - ], - }; - } - - // If no assets were added or removed, don't emit the event. - const isEmptyAccountAssetListUpdatedPayload = Object.values( - assetListUpdatedPayload, - ) - .map((item) => item.added.length + item.removed.length) - .every((item) => item === 0); - - if (!isEmptyAccountAssetListUpdatedPayload) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - } - - // Emit synthetic zero-balance entries for disappeared assets so clients can - // clear cached balances even when the backend omits zero-balance tokens - // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); - - const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; - // Save assets using repository - await this.#assetsRepository.saveMany(assetsToSave); - - // Broadcast the current snapshot plus synthetic zero-balance removals so the - // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = assetsToSave.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - ...(acc[asset.keyringAccountId] ?? {}), - [asset.assetType]: { - unit: asset.symbol, - amount: asset.uiAmount, - }, - }, - }), - {}, - ); - - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. - const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes - .some((count) => count > 0); - - // Only emit the event if some balance was changed. - if (isSomeBalanceChanged) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } + return this.#snapAdapter.saveMany(assets); } async getAll(): Promise { - const assetsByAccount = - (await this.#state.getKey('assets')) ?? - {}; - - return Object.values(assetsByAccount).flat(); - } - - /** - * Creates an asset entity with zero balance from a known CAIP-19 asset ID. - * Uses pre-calculated metadata from TokenMetadata. - * - * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). - * @param keyringAccountId - The keyring account ID. - * @returns The asset entity with zero balance. - */ - #createZeroBalanceAsset( - assetId: KnownCaip19Id, - keyringAccountId: string, - ): AssetEntity { - const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; - const { chainId } = parseCaipAssetType(assetId); - - return { - assetType: metadata.id, - keyringAccountId, - network: chainId as Network, - symbol: metadata.symbol, - decimals: metadata.decimals, - rawAmount: '0', - uiAmount: '0', - } as AssetEntity; + return this.#snapAdapter.getAll(); } async getByKeyringAccountId( - keyringAccountId: string, + accountId: string, ): Promise { - const savedAssets = - await this.#assetsRepository.getByAccountId(keyringAccountId); - - /** - * Ensure the special assets are always present whether they have been synced or not. - * These are assets that should be visible to the user even with zero balance. - */ - const missingEssentialAssets: AssetEntity[] = []; - - for (const essentialAssetId of ESSENTIAL_ASSETS) { - const savedAsset = savedAssets.find( - (asset) => (asset.assetType as string) === essentialAssetId, - ); - - if (!savedAsset) { - const zeroBalanceAsset = this.#createZeroBalanceAsset( - essentialAssetId as KnownCaip19Id, - keyringAccountId, - ); - missingEssentialAssets.push(zeroBalanceAsset); - } - } - - return [...savedAssets, ...missingEssentialAssets]; - } - - /** - * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. - * - * @param caipAssetType - The CAIP-19 asset type. - * @returns The fiat ticker. - */ - #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { - if (!AssetsService.isFiat(caipAssetType)) { - throw new Error('Passed caipAssetType is not a fiat asset'); - } - - const fiatTicker = - parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); - - return fiatTicker as FiatTicker; - } - - /** - * Fetches fiat exchange rates and crypto prices for the given assets. - * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. - * - * @param allAssets - Array of all CAIP asset types (both fiat and crypto). - * @returns Promise resolving to fiat exchange rates and crypto prices. - */ - async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ - fiatExchangeRates: Record; - cryptoPrices: Record; - }> { - const cryptoAssets = allAssets.filter( - (asset) => !AssetsService.isFiat(asset), - ); - - const [fiatExchangeRates, cryptoPrices] = await Promise.all([ - this.#priceApiClient.getFiatExchangeRates(), - this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), - ]); - - return { fiatExchangeRates, cryptoPrices }; + return this.#snapAdapter.getByKeyringAccountId(accountId); } - /** - * Get the token conversions for a list of asset pairs. - * It caches the results for 1 hour. - * - * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. This is not entirely accurate but it's the - * best we can do with the current API. - * - * @param conversions - The asset pairs to get the conversions for. - * @returns The token conversions. - */ - async getMultipleTokenConversions( + async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< Record> > { - if (conversions.length === 0) { - return {}; - } - - /** - * `from` and `to` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = conversions.flatMap((conversion) => [ - conversion.from, - conversion.to, - ]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - /** - * Now that we have the data, convert the `from`s to `to`s. - * - * We need to handle the following cases: - * 1. `from` and `to` are both fiat - * 2. `from` and `to` are both crypto - * 3. `from` is fiat and `to` is crypto - * 4. `from` is crypto and `to` is fiat - * - * We also need to keep in mind that although `cryptoPrices` are indexed - * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. - * To convert fiat currency symbols to CAIP 19 IDs, we can use the - * `this.#fiatSymbolToCaip19Id` method. - */ - - const result: Record< - CaipAssetType, - Record - > = {}; - - conversions.forEach((conversion) => { - const { from, to } = conversion; - - result[from] ??= {}; - - let fromUsdRate: BigNumber; - let toUsdRate: BigNumber; - - if (AssetsService.isFiat(from)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(from)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); - } - - if (AssetsService.isFiat(to)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(to)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); - } - - if (fromUsdRate.isZero() || toUsdRate.isZero()) { - result[from][to] = null; - return; - } - - const rate = fromUsdRate.dividedBy(toUsdRate).toString(); - - const now = Date.now(); - - result[from][to] = { - rate, - conversionTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - }); - - return result; - } - - /** - * Computes the market data object in the target currency. - * - * @param spotPrice - The spot price of the asset in source currency. - * @param rate - The rate to convert the market data to from source currency to target currency. - * @returns The market data in the target currency. - */ - #computeMarketData( - spotPrice: SpotPrice, - rate: BigNumber, - ): FungibleAssetMarketData { - const marketDataInUsd = pick(spotPrice, [ - 'marketCap', - 'totalVolume', - 'circulatingSupply', - 'allTimeHigh', - 'allTimeLow', - 'pricePercentChange1h', - 'pricePercentChange1d', - 'pricePercentChange7d', - 'pricePercentChange14d', - 'pricePercentChange30d', - 'pricePercentChange200d', - 'pricePercentChange1y', - ]); - - const toCurrency = (value: number | null | undefined): string => { - return value === null || value === undefined - ? '' - : new BigNumber(value).dividedBy(rate).toString(); - }; - - const includeIfDefined = ( - key: string, - value: number | null | undefined, - ): Record => { - return value === null || value === undefined ? {} : { [key]: value }; - }; - - // Variations in percent don't need to be converted, they are independent of the currency - const pricePercentChange = { - ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), - ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), - ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), - ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), - ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), - ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), - ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), - }; - - const marketDataInToCurrency = { - fungible: true, - marketCap: toCurrency(marketDataInUsd.marketCap), - totalVolume: toCurrency(marketDataInUsd.totalVolume), - circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert - allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), - allTimeLow: toCurrency(marketDataInUsd.allTimeLow), - // Add pricePercentChange field only if it has values - ...(Object.keys(pricePercentChange).length > 0 - ? { pricePercentChange } - : {}), - } as FungibleAssetMarketData; - - return marketDataInToCurrency; + return this.#snapAdapter.getMultipleTokenConversions(conversions); } async getMultipleTokensMarketData( @@ -1703,78 +130,9 @@ export class AssetsService { ): Promise< Record> > { - if (assets.length === 0) { - return {}; - } - - /** - * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - const result: Record< - CaipAssetType, - Record - > = {}; - - assets.forEach((asset) => { - const { asset: assetType, unit } = asset; - - // Skip if we don't have price data for the asset - if (!cryptoPrices[assetType]) { - return; - } - - let unitUsdRate: BigNumber; - - if (AssetsService.isFiat(unit)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; - - if (!fiatExchangeRate) { - return; - } - - unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); - } - - if (unitUsdRate.isZero()) { - return; - } - - // Initialize the nested structure for the asset if it doesn't exist - result[assetType] ??= {}; - - // Store the market data with the unit as the key - result[assetType][unit] = this.#computeMarketData( - cryptoPrices[assetType], - unitUsdRate, - ); - }); - - return result; + return this.#snapAdapter.getMultipleTokensMarketData(assets); } - /** - * Get historical prices for a token pair by calling the Price API. - * Similar to the Solana snap implementation. - * - * @param from - The asset to get historical prices for. - * @param to - The currency to convert prices to. - * @returns Historical price data with intervals. - */ async getHistoricalPrice( from: CaipAssetType, to: CaipAssetType, @@ -1783,63 +141,6 @@ export class AssetsService { updateTime: number; expirationTime?: number; }> { - assert(from, CaipAssetTypeStruct); - assert(to, CaipAssetTypeStruct); - - const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); - assert(toTicker, VsCurrencyParamStruct); - - const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; - - // For each time period, call the Price API to fetch the historical prices - const promises = timePeriodsToFetch.map(async (timePeriod) => - this.#priceApiClient - .getHistoricalPrices({ - assetType: from, - timePeriod, - vsCurrency: toTicker, - }) - // Wrap the response in an object with the time period and the response for easier reducing - .then((response) => ({ - timePeriod, - response, - })) - // Gracefully handle individual errors to avoid breaking the entire operation - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, - error, - ); - return { - timePeriod, - response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - }; - }), - ); - - const wrappedHistoricalPrices = await Promise.all(promises); - - const intervals = wrappedHistoricalPrices.reduce( - (acc, { timePeriod, response }) => { - const iso8601Interval = `P${timePeriod.toUpperCase()}`; - acc[iso8601Interval] = response.prices.map((price) => [ - price[0], - price[1].toString(), - ]); - return acc; - }, - {}, - ); - - const now = Date.now(); - - const result = { - intervals, - updateTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - - return result; + return this.#snapAdapter.getHistoricalPrice(from, to); } } diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts new file mode 100644 index 00000000..9e14ead0 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -0,0 +1,1845 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, + KeyringAccount, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { + AssetConversion, + AssetMetadata, + FungibleAssetMarketData, + FungibleAssetMetadata, + HistoricalPriceIntervals, +} from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { pick } from 'lodash'; + +import type { PriceApiClient } from '../../../clients/price-api/PriceApiClient'; +import type { + FiatTicker, + SpotPrice, + SpotPrices, +} from '../../clients/price-api/types'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; +import type { SnapClient } from '../../../clients/snap/SnapClient'; +import type { TokenApiClient } from '../../../clients/token-api/TokenApiClient'; +import type { AccountResources } from '../../../clients/tron-http'; +import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; +import type { + RawTronUnfrozenV2, + Trc20Balance, + TronAccount, +} from '../../clients/trongrid/types'; +import type { KnownCaip19Id, Network } from '../../../constants'; +import { + BANDWIDTH_METADATA, + ENERGY_METADATA, + ESSENTIAL_ASSETS, + MAX_BANDWIDTH_METADATA, + MAX_ENERGY_METADATA, + Networks, + TokenMetadata, + TRX_IN_LOCK_PERIOD_METADATA, + TRX_METADATA, + TRX_READY_FOR_WITHDRAWAL_METADATA, + TRX_STAKED_FOR_BANDWIDTH_METADATA, + TRX_STAKED_FOR_ENERGY_METADATA, + TRX_STAKING_REWARDS_METADATA, +} from '../../constants'; +import { configProvider } from '../../../context'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import type { State, UnencryptedStateValue } from '../../state/State'; +import type { AssetsRepository } from '../AssetsRepository'; +import type { + InLockPeriodCaipAssetType, + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + ResourceCaipAssetType, + StakedCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from './types'; + +/** + * Normalized account data structure that provides a consistent shape for both + * active and inactive accounts. This allows extraction functions to work + * without needing to know the account's activation state. + */ +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']; + unfrozenV2: TronAccount['unfrozenV2']; + accountResource: TronAccount['account_resource'] | undefined; + }; + /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ + resources: AccountResources | Record; + /** Unclaimed staking rewards in sun (0 if no rewards). */ + stakingRewards: number; +}; + +export class SnapAssetsAdapter { + readonly #logger: ILogger; + + readonly #assetsRepository: AssetsRepository; + + readonly #state: State; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + readonly #priceApiClient: PriceApiClient; + + readonly #tokenApiClient: TokenApiClient; + + readonly #snapClient: SnapClient; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + + constructor({ + logger, + assetsRepository, + state, + trongridApiClient, + tronHttpClient, + priceApiClient, + tokenApiClient, + snapClient, + }: { + logger: ILogger; + assetsRepository: AssetsRepository; + state: State; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + priceApiClient: PriceApiClient; + tokenApiClient: TokenApiClient; + snapClient: SnapClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); + this.#assetsRepository = assetsRepository; + this.#state = state; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + this.#priceApiClient = priceApiClient; + this.#tokenApiClient = tokenApiClient; + this.#snapClient = snapClient; + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + } + + static isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); + } + + async getAccountAssets(accountId: string): Promise { + return this.#assetsRepository.getByAccountId(accountId); + } + + async getAccountAssetsByIDs( + accountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + return this.#assetsRepository.getByAccountIdAndAssetTypes( + accountId, + assetTypes, + ); + } + + async getAccountAssetByID( + accountId: string, + assetType: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( + accountId, + assetType, + ); + } + + /** + * 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, + ): Promise { + this.#logger.info('Fetching assets and balances by account', { + account, + scope, + }); + + const [ + tronAccountInfoRequest, + tronAccountResourcesRequest, + stakingRewardsRequest, + ] = await Promise.allSettled([ + this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), + this.#tronHttpClient.getAccountResources(scope, account.address), + this.#tronHttpClient.getReward(scope, account.address), + ]); + + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + if (isInactiveAccount) { + this.#logger.info( + 'Account info request failed, treating as inactive account', + { account, scope }, + ); + } + + const trc20BalancesFallback = isInactiveAccount + ? await this.#trongridApiClient + .getTrc20BalancesByAddress(scope, account.address) + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + 'Failed to fetch TRC20 balances for inactive account', + { error, account, scope }, + ); + return []; + }) + : []; + + const accountData = this.#buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, + stakingRewardsRequest, + }); + + 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(async (error) => { + await this.#snapClient.trackError(error as Error); + return {}; + }), + ]); + + const enrichedAssets = this.#enrichAssetsWithMetadata( + rawAssets, + assetsMetadata, + ); + return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); + } + + /** + * Filters out spam tokens (those without price data). + * Essential assets are always kept. Tokens need price data to be included. + * + * @param assets - The assets to filter. + * @param spotPrices - Pre-fetched USD prices for assets. + * @returns The filtered assets. + */ + #filterTokensWithoutPriceData( + assets: AssetEntity[], + spotPrices: SpotPrices | Record, + ): AssetEntity[] { + const filtered = assets.filter((asset) => { + // Essential assets (TRX, staked, energy, bandwidth) are always kept + if (ESSENTIAL_ASSETS.includes(asset.assetType)) { + return true; + } + // Tokens: keep only if they have price data + const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; + return typeof spotPrice?.price === 'number'; + }); + + return filtered; + } + + /** + * 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). + * @param params.stakingRewardsRequest - The settled promise result from getReward. + * @returns NormalizedAccountData - Consistent data shape for extraction. + */ + #buildAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + trc20BalancesFallback, + stakingRewardsRequest, + }: { + tronAccountInfoRequest: PromiseSettledResult; + tronAccountResourcesRequest: PromiseSettledResult; + trc20BalancesFallback: Trc20Balance[]; + stakingRewardsRequest: PromiseSettledResult; + }): NormalizedAccountData { + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + const resources = + tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; + const stakingRewards = + stakingRewardsRequest.status === 'fulfilled' + ? Math.max(0, stakingRewardsRequest.value) + : 0; + + if (isInactiveAccount) { + return { + nativeBalance: 0, + trc10Balances: [], + trc20Balances: trc20BalancesFallback, + stakedData: { + frozenV2: [], + unfrozenV2: [], + accountResource: undefined, + }, + resources, + stakingRewards, + }; + } + + const tronAccountInfo = tronAccountInfoRequest.value; + return { + nativeBalance: tronAccountInfo.balance ?? 0, + trc10Balances: tronAccountInfo.assetV2 ?? [], + trc20Balances: tronAccountInfo.trc20 ?? [], + stakedData: { + frozenV2: tronAccountInfo.frozenV2 ?? [], + unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }, + resources, + stakingRewards, + }; + } + + /** + * 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.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), + this.#extractInLockPeriodAsset(account, scope, data.stakedData), + this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), + ...this.#extractTrc10Assets(account, scope, data.trc10Balances), + ...this.#extractTrc20Assets(account, scope, data.trc20Balances), + ...this.#extractBandwidth({ + account, + scope, + 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; + + const { + symbol: initialSymbol, + decimals: initialDecimals = 0, + iconUrl: initialIconUrl, + } = asset; + let symbol = initialSymbol; + let decimals = initialDecimals; + let iconUrl = initialIconUrl; + + if (metadata?.fungible) { + const unit = metadata.units?.[0]; + if (unit) { + symbol = unit.symbol ?? metadata.symbol ?? symbol; + decimals = unit.decimals ?? decimals; + } else { + symbol = metadata?.symbol ?? symbol; + } + iconUrl = metadata.iconUrl ?? iconUrl; + } + + const uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); + + return { + ...asset, + symbol, + decimals, + uiAmount, + iconUrl, + }; + }); + } + + /** + * Extracts the native TRX asset from the balance. + * + * @param account - The keyring account. + * @param scope - The network. + * @param balance - The native balance in sun. + * @returns AssetEntity - The native TRX asset. + */ + #extractNativeAsset( + account: KeyringAccount, + scope: Network, + balance: number, + ): AssetEntity { + return { + assetType: Networks[scope].nativeToken.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].nativeToken.symbol, + decimals: Networks[scope].nativeToken.decimals, + rawAmount: balance.toString(), + uiAmount: toUiAmount( + balance, + Networks[scope].nativeToken.decimals, + ).toString(), + iconUrl: Networks[scope].nativeToken.iconUrl, + }; + } + + /** + * Extracts staked TRX assets (for bandwidth and energy). + * + * @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 (always 2: bandwidth and energy, amounts may be 0). + */ + #extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity[] { + const assets: AssetEntity[] = []; + + let stakedBandwidthAmount = 0; + let stakedEnergyAmount = 0; + + stakedData.frozenV2?.forEach((frozen) => { + const amount = frozen.amount ?? 0; + + if (frozen.type === 'ENERGY') { + stakedEnergyAmount += amount; + } else if (!frozen.type) { + // Item without type is for bandwidth + stakedBandwidthAmount += amount; + } + }); + + const delegatedBandwidth = + stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; + const delegatedEnergy = + stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; + + stakedBandwidthAmount += delegatedBandwidth; + stakedEnergyAmount += delegatedEnergy; + + assets.push({ + assetType: Networks[scope].stakedForBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForBandwidth.symbol, + decimals: Networks[scope].stakedForBandwidth.decimals, + rawAmount: stakedBandwidthAmount.toString(), + uiAmount: toUiAmount( + stakedBandwidthAmount, + Networks[scope].stakedForBandwidth.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForBandwidth.iconUrl, + }); + + assets.push({ + assetType: Networks[scope].stakedForEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForEnergy.symbol, + decimals: Networks[scope].stakedForEnergy.decimals, + rawAmount: stakedEnergyAmount.toString(), + uiAmount: toUiAmount( + stakedEnergyAmount, + Networks[scope].stakedForEnergy.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForEnergy.iconUrl, + }); + + return assets; + } + + /** + * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). + */ + #extractReadyForWithdrawalAsset( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let readyForWithdrawalAmount = 0; + + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; + + if (expireTime <= currentTimestamp && amount > 0) { + readyForWithdrawalAmount += amount; + } + }); + + const { id, symbol, decimals, iconUrl } = + Networks[scope].readyForWithdrawal; + + return { + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: readyForWithdrawalAmount.toString(), + uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts staking rewards asset (unclaimed voting rewards). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakingRewards - Unclaimed staking rewards in sun. + * @returns AssetEntity - The staking rewards asset. + */ + #extractStakingRewardsAsset( + account: KeyringAccount, + scope: Network, + stakingRewards: number, + ): AssetEntity { + return { + assetType: Networks[scope].stakingRewards.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakingRewards.symbol, + decimals: Networks[scope].stakingRewards.decimals, + rawAmount: stakingRewards.toString(), + uiAmount: toUiAmount( + stakingRewards, + Networks[scope].stakingRewards.decimals, + ).toString(), + iconUrl: Networks[scope].stakingRewards.iconUrl, + }; + } + + /** + * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). + * This represents TRX that the user has initiated unstaking for but must wait + * the 14-day lock period before they can withdraw. + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The in-lock-period asset (amount may be 0). + */ + #extractInLockPeriodAsset( + account: KeyringAccount, + scope: Network, + stakedData: NormalizedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let inLockPeriodAmount = 0; + + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; + + if (expireTime > currentTimestamp && amount > 0) { + inLockPeriodAmount += amount; + } + }); + + const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; + + return { + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: inLockPeriodAmount.toString(), + uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts current and maximum bandwidth from the account resources. + * + * @param options - Options object. + * @param options.account - The account to extract bandwidth for. + * @param options.scope - The network to extract bandwidth for. + * @param options.tronAccountResources - The account resources to extract bandwidth for. + * @returns The bandwidth assets. + */ + #extractBandwidth({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const freeBandwidth = tronAccountResources?.freeNetLimit ?? 0; + const stakingBandwidth = tronAccountResources?.NetLimit ?? 0; + const maximumBandwidth = freeBandwidth + stakingBandwidth; + + const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; + const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; + const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; + + const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); + + return [ + { + assetType: Networks[scope].bandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].bandwidth.symbol, + decimals: Networks[scope].bandwidth.decimals, + rawAmount: availableBandwidth.toString(), + uiAmount: availableBandwidth.toString(), + iconUrl: Networks[scope].bandwidth.iconUrl, + }, + { + assetType: Networks[scope].maximumBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumBandwidth.symbol, + decimals: Networks[scope].maximumBandwidth.decimals, + rawAmount: maximumBandwidth.toString(), + uiAmount: maximumBandwidth.toString(), + iconUrl: Networks[scope].maximumBandwidth.iconUrl, + }, + ]; + } + + /** + * 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, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; + const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; + + /** + * We might have used more Energy than the maximum allocated + */ + const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); + + return [ + { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].energy.symbol, + decimals: Networks[scope].energy.decimals, + rawAmount: availableEnergy.toString(), + uiAmount: availableEnergy.toString(), + iconUrl: Networks[scope].energy.iconUrl, + }, + { + assetType: Networks[scope].maximumEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumEnergy.symbol, + decimals: Networks[scope].maximumEnergy.decimals, + rawAmount: maximumEnergy.toString(), + uiAmount: maximumEnergy.toString(), + iconUrl: Networks[scope].maximumEnergy.iconUrl, + }, + ]; + } + + /** + * Extracts TRC10 assets from the balances array. + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. + * @returns AssetEntity[] - Array of TRC10 asset entities. + */ + #extractTrc10Assets( + account: KeyringAccount, + scope: Network, + trc10Balances: TronAccount['assetV2'], + ): AssetEntity[] { + return ( + trc10Balances?.flatMap((tokenObject) => { + // assetV2 has structure: { "key": "token_id", "value": "balance" } + return { + assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: tokenObject.value?.toString() ?? '0', + uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later + }; + }) ?? [] + ); + } + + /** + * Extracts TRC20 assets from a balances array. + * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). + * + * @param account - The keyring account. + * @param scope - The network. + * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). + * @returns AssetEntity[] - Array of TRC20 asset entities. + */ + #extractTrc20Assets( + account: KeyringAccount, + scope: Network, + trc20Balances: Trc20Balance[], + ): AssetEntity[] { + return trc20Balances.flatMap((tokenObject) => { + return Object.entries(tokenObject).map(([address, balance]) => { + return { + assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, + keyringAccountId: account.id, + network: scope, + symbol: '', + decimals: 0, + rawAmount: balance, + uiAmount: '0', + iconUrl: '', // Will be enriched with metadata later + }; + }); + }); + } + + async getAssetsMetadata( + assetTypes: CaipAssetType[], + ): Promise> { + this.#logger.info('Fetching metadata for assets', assetTypes); + + const { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + } = this.#splitAssetsByType(assetTypes); + + const nativeTokensMetadata = + this.#getNativeTokensMetadata(nativeAssetTypes); + const stakedTokensMetadata = this.#getStakedTokensMetadata( + stakedNativeAssetTypes, + ); + const readyForWithdrawalTokensMetadata = + this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); + const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( + inLockPeriodAssetTypes, + ); + const stakingRewardsMetadata = this.#getStakingRewardsMetadata( + stakingRewardsAssetTypes, + ); + const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); + const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( + maximunEnergyAssetTypes, + ); + const bandwidthTokensMetadata = + this.#getBandwidthMetadata(bandwidthAssetTypes); + const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( + maximunBandwidthAssetTypes, + ); + const tokensMetadata = await this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, + ]); + + const result = { + ...nativeTokensMetadata, + ...stakedTokensMetadata, + ...readyForWithdrawalTokensMetadata, + ...inLockPeriodTokensMetadata, + ...stakingRewardsMetadata, + ...energyTokensMetadata, + ...maximunEnergyTokensMetadata, + ...bandwidthTokensMetadata, + ...maximunBandwidthTokensMetadata, + ...tokensMetadata, + }; + + this.#logger.info('Resolved assets metadata', { assetTypes, result }); + + return result; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + stakedNativeAssetTypes: StakedCaipAssetType[]; + readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; + inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; + stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; + energyAssetTypes: ResourceCaipAssetType[]; + maximunEnergyAssetTypes: ResourceCaipAssetType[]; + bandwidthAssetTypes: ResourceCaipAssetType[]; + maximunBandwidthAssetTypes: ResourceCaipAssetType[]; + tokenTrc10AssetTypes: TokenCaipAssetType[]; + tokenTrc20AssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195'), + ) as NativeCaipAssetType[]; + const stakedNativeAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/slip44:195-staked-for-'), + ) as StakedCaipAssetType[]; + const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-ready-for-withdrawal'), + ) as ReadyForWithdrawalCaipAssetType[]; + const inLockPeriodAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-in-lock-period'), + ) as InLockPeriodCaipAssetType[]; + const stakingRewardsAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-staking-rewards'), + ) as StakingRewardsCaipAssetType[]; + const energyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:energy'), + ) as ResourceCaipAssetType[]; + const maximunEnergyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-energy'), + ) as ResourceCaipAssetType[]; + const bandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:bandwidth'), + ) as ResourceCaipAssetType[]; + const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-bandwidth'), + ) as ResourceCaipAssetType[]; + const tokenTrc10AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:'), + ) as TokenCaipAssetType[]; + const tokenTrc20AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc20:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc721:'), + ) as NftCaipAssetType[]; + + return { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + }; + } + + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + nativeTokensMetadata[assetType] = { + fungible: TRX_METADATA.fungible, + name: TRX_METADATA.name, + symbol: TRX_METADATA.symbol, + iconUrl: TRX_METADATA.iconUrl, + units: [ + { + decimals: TRX_METADATA.decimals, + symbol: TRX_METADATA.symbol, + name: TRX_METADATA.name, + }, + ], + }; + } + + return nativeTokensMetadata; + } + + #getStakedTokensMetadata( + assetTypes: StakedCaipAssetType[], + ): Record { + // Can either be Staked for Bandwidth or Staked for Energy + const stakedTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + const isForEnergy = assetType.endsWith('staked-for-energy'); + + if (isForEnergy) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + }, + ], + }; + } + } + + return stakedTokensMetadata; + } + + #getReadyForWithdrawalTokensMetadata( + assetTypes: ReadyForWithdrawalCaipAssetType[], + ): Record { + const readyForWithdrawalTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + readyForWithdrawalTokensMetadata[assetType] = { + fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, + units: [ + { + decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + }, + ], + }; + } + + return readyForWithdrawalTokensMetadata; + } + + #getStakingRewardsMetadata( + assetTypes: StakingRewardsCaipAssetType[], + ): Record { + const stakingRewardsMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + stakingRewardsMetadata[assetType] = { + fungible: TRX_STAKING_REWARDS_METADATA.fungible, + name: TRX_STAKING_REWARDS_METADATA.name, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKING_REWARDS_METADATA.decimals, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + name: TRX_STAKING_REWARDS_METADATA.name, + }, + ], + }; + } + + return stakingRewardsMetadata; + } + + #getInLockPeriodMetadata( + assetTypes: InLockPeriodCaipAssetType[], + ): Record { + const inLockPeriodTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + inLockPeriodTokensMetadata[assetType] = { + fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, + units: [ + { + decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + }, + ], + }; + } + + return inLockPeriodTokensMetadata; + } + + #getBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const bandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + bandwidthTokensMetadata[assetType] = { + fungible: BANDWIDTH_METADATA.fungible, + name: BANDWIDTH_METADATA.name, + symbol: BANDWIDTH_METADATA.symbol, + iconUrl: BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: BANDWIDTH_METADATA.decimals, + symbol: BANDWIDTH_METADATA.symbol, + name: BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return bandwidthTokensMetadata; + } + + #getMaximunBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunBandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunBandwidthTokensMetadata[assetType] = { + fungible: MAX_BANDWIDTH_METADATA.fungible, + name: MAX_BANDWIDTH_METADATA.name, + symbol: MAX_BANDWIDTH_METADATA.symbol, + iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: MAX_BANDWIDTH_METADATA.decimals, + symbol: MAX_BANDWIDTH_METADATA.symbol, + name: MAX_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return maximunBandwidthTokensMetadata; + } + + #getEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const energyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + energyTokensMetadata[assetType] = { + fungible: ENERGY_METADATA.fungible, + name: ENERGY_METADATA.name, + symbol: ENERGY_METADATA.symbol, + iconUrl: ENERGY_METADATA.iconUrl, + units: [ + { + decimals: ENERGY_METADATA.decimals, + symbol: ENERGY_METADATA.symbol, + name: ENERGY_METADATA.name, + }, + ], + }; + } + + return energyTokensMetadata; + } + + #getMaximunEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunEnergyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunEnergyTokensMetadata[assetType] = { + fungible: MAX_ENERGY_METADATA.fungible, + name: MAX_ENERGY_METADATA.name, + symbol: MAX_ENERGY_METADATA.symbol, + iconUrl: MAX_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: MAX_ENERGY_METADATA.decimals, + symbol: MAX_ENERGY_METADATA.symbol, + name: MAX_ENERGY_METADATA.name, + }, + ], + }; + } + + return maximunEnergyTokensMetadata; + } + + async #getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + return this.#tokenApiClient.getTokensMetadata(assetTypes); + } + + /** + * Checks if the asset has changed compared to passed assets lookup. + * + * @param asset - The asset to check. + * @param assetsLookup - The lookup table to check against. + * @returns True if the asset has changed, false otherwise. + */ + static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { + const savedAsset = assetsLookup.find( + (item) => + item.keyringAccountId === asset.keyringAccountId && + item.assetType === asset.assetType, + ); + + if (!savedAsset) { + return true; + } + + return savedAsset.rawAmount !== asset.rawAmount; + } + + /** + * Persist the latest fetched assets and emit the corresponding keyring events. + * + * The input is treated as the latest snapshot for the account/network pairs + * included in this sync. The method compares that snapshot with the + * previously saved state to detect disappeared assets, emits asset-list + * updates, and emits balance updates including synthetic zero balances for + * assets that vanished from the latest response. + * + * @param assets - The latest asset snapshot returned by the refresh flow. + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + const hasZeroAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0' || asset.uiAmount === '0'; + + const savedAssets = await this.getAll(); + const isEssentialAsset = (asset: AssetEntity): boolean => + ESSENTIAL_ASSETS.includes(asset.assetType); + + // Track only the account/network pairs refreshed in this run. + // That prevents us from treating assets from untouched networks as disappeared. + const syncedNetworksByAccount = assets.reduce>>( + (acc, asset) => { + acc[asset.keyringAccountId] ??= new Set(); + acc[asset.keyringAccountId]?.add(asset.network); + return acc; + }, + {}, + ); + + const incomingAssetKeys = new Set( + assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), + ); + + // A saved asset is considered disappeared only if its network was part of + // this sync, it is not essential, and it is missing from the latest + // snapshot for that account. + const disappearedAssets = savedAssets.filter((savedAsset) => { + const syncedNetworks = + syncedNetworksByAccount[savedAsset.keyringAccountId]; + + if ( + !syncedNetworks?.has(savedAsset.network) || + isEssentialAsset(savedAsset) + ) { + return false; + } + + return !incomingAssetKeys.has( + `${savedAsset.keyringAccountId}:${savedAsset.assetType}`, + ); + }); + + // A token should be removed from the visible asset list only when the latest + // snapshot says its balance is zero. Essential assets stay visible even at + // zero because they are part of the permanent Tron account model. + const shouldBeInRemovedList = (asset: AssetEntity): boolean => + hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list + + // Assets are added to the visible list when they are non-zero and either: + // - we are doing a full non-incremental broadcast, or + // - they are brand new, or + // - they existed before with zero balance and now became non-zero. + const shouldBeInAddedList = (asset: AssetEntity): boolean => + !shouldBeInRemovedList(asset); + + // Build the asset-list payload in two stages: + // 1. seed the removed list with assets that vanished from the latest + // snapshot entirely + // 2. fold in the current assets to report additions and explicit zero-balance + // removals in the same event + const assetListUpdatedPayload = disappearedAssets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); + + for (const asset of assets) { + // Merge the current snapshot into the pre-seeded payload so each account + // ends up with one consolidated added/removed diff. + assetListUpdatedPayload[asset.keyringAccountId] = { + added: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), + ], + removed: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.removed ?? []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), + ], + }; + } + + // If no assets were added or removed, don't emit the event. + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + // Emit synthetic zero-balance entries for disappeared assets so clients can + // clear cached balances even when the backend omits zero-balance tokens + // instead of returning them explicitly. + const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); + + const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; + // Save assets using repository + await this.#assetsRepository.saveMany(assetsToSave); + + // Broadcast the current snapshot plus synthetic zero-balance removals so the + // client can reconcile both visible assets and cached balances in one pass. + const balancesUpdatedPayload = assetsToSave.reduce< + AccountBalancesUpdatedEvent['params']['balances'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + async getAll(): Promise { + const assetsByAccount = + (await this.#state.getKey('assets')) ?? + {}; + + return Object.values(assetsByAccount).flat(); + } + + /** + * Creates an asset entity with zero balance from a known CAIP-19 asset ID. + * Uses pre-calculated metadata from TokenMetadata. + * + * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). + * @param keyringAccountId - The keyring account ID. + * @returns The asset entity with zero balance. + */ + #createZeroBalanceAsset( + assetId: KnownCaip19Id, + keyringAccountId: string, + ): AssetEntity { + const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const { chainId } = parseCaipAssetType(assetId); + + return { + assetType: metadata.id, + keyringAccountId, + network: chainId as Network, + symbol: metadata.symbol, + decimals: metadata.decimals, + rawAmount: '0', + uiAmount: '0', + } as AssetEntity; + } + + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); + + /** + * Ensure the special assets are always present whether they have been synced or not. + * These are assets that should be visible to the user even with zero balance. + */ + const missingEssentialAssets: AssetEntity[] = []; + + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); + + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); + } + } + + return [...savedAssets, ...missingEssentialAssets]; + } + + /** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. + * + * @param caipAssetType - The CAIP-19 asset type. + * @returns The fiat ticker. + */ + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!AssetsService.isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; + }> { + const cryptoAssets = allAssets.filter( + (asset) => !AssetsService.isFiat(asset), + ); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ + async getMultipleTokenConversions( + conversions: { from: CaipAssetType; to: CaipAssetType }[], + ): Promise< + Record> + > { + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + result[from] ??= {}; + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (AssetsService.isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (AssetsService.isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; + } + + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ): Record => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; + } + + async getMultipleTokensMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (AssetsService.isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + result[assetType] ??= {}; + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } + + /** + * Get historical prices for a token pair by calling the Price API. + * Similar to the Solana snap implementation. + * + * @param from - The asset to get historical prices for. + * @param to - The currency to convert prices to. + * @returns Historical price data with intervals. + */ + async getHistoricalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; + } +} From 313d952cf24d8ce8359f8d8d72954e6005f3b749 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 17:54:03 +0100 Subject: [PATCH 2/7] fix(tron-wallet-snap): correct SnapAssetsAdapter relative import paths Adapter lives one directory deeper than AssetsService; bump client, constants, and types imports to ../../../ and ../types respectively. --- .../src/services/assets/adapters/SnapAssetsAdapter.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts index 9e14ead0..4868a435 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -23,11 +23,11 @@ import type { FiatTicker, SpotPrice, SpotPrices, -} from '../../clients/price-api/types'; +} from '../../../clients/price-api/types'; import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, VsCurrencyParamStruct, -} from '../../clients/price-api/types'; +} from '../../../clients/price-api/types'; import type { SnapClient } from '../../../clients/snap/SnapClient'; import type { TokenApiClient } from '../../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../../clients/tron-http'; @@ -37,7 +37,7 @@ import type { RawTronUnfrozenV2, Trc20Balance, TronAccount, -} from '../../clients/trongrid/types'; +} from '../../../clients/trongrid/types'; import type { KnownCaip19Id, Network } from '../../../constants'; import { BANDWIDTH_METADATA, @@ -53,7 +53,7 @@ import { TRX_STAKED_FOR_BANDWIDTH_METADATA, TRX_STAKED_FOR_ENERGY_METADATA, TRX_STAKING_REWARDS_METADATA, -} from '../../constants'; +} from '../../../constants'; import { configProvider } from '../../../context'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; @@ -70,7 +70,7 @@ import type { StakedCaipAssetType, StakingRewardsCaipAssetType, TokenCaipAssetType, -} from './types'; +} from '../types'; /** * Normalized account data structure that provides a consistent shape for both From 1824abec07c5cf2c78e619062405c87ad1161461 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 22:54:23 +0100 Subject: [PATCH 3/7] fix(tron-wallet-snap): restore CI after SnapAssetsAdapter extraction Update the manifest shasum, cover AssetsService facade delegation in tests, and use SnapAssetsAdapter.isFiat inside the adapter implementation. --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/assets/AssetsService.test.ts | 81 +++++++++++++++++++ .../src/services/assets/AssetsService.ts | 2 +- .../assets/adapters/SnapAssetsAdapter.ts | 10 +-- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 401124ab..8b647188 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "0E86PeuwFcabw+zj98vn+O/XML6E9aDeng1o7+Qq47s=", + "shasum": "0igt/4MaPm2VNFYSs67zPDZouKmDpxvw/lzM+yY4zWA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index b41e8c7d..af1d21b6 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -2830,4 +2830,85 @@ describe('AssetsService', () => { }); }); }); + + describe('facade delegation', () => { + it('delegates repository reads and market helpers to SnapAssetsAdapter', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { + const asset: AssetEntity = { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1', + uiAmount: '1', + }; + + mockAssetsRepository.getByAccountId.mockResolvedValue([asset]); + mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ + asset, + ]); + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + asset, + ); + mockPriceApiClient.getFiatExchangeRates.mockResolvedValue({ + usd: { value: 1 }, + }); + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [KnownCaip19Id.TrxMainnet]: { + id: KnownCaip19Id.TrxMainnet, + price: 1, + }, + }), + ); + + expect(AssetsService.isFiat('eip155:1/erc20:0x0')).toBe(false); + expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); + expect(AssetsService.hasChanged(asset, [])).toBe(true); + expect(AssetsService.hasChanged(asset, [asset])).toBe(false); + + await expect( + assetsService.getAccountAssets(mockAccount.id), + ).resolves.toEqual([asset]); + await expect( + assetsService.getAccountAssetsByIDs(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]), + ).resolves.toEqual([asset]); + await expect( + assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).resolves.toEqual(asset); + await expect( + assetsService.getByKeyringAccountId(mockAccount.id), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + assetType: KnownCaip19Id.TrxMainnet, + }), + ]), + ); + await expect( + assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, + ]), + ).resolves.toEqual( + expect.objectContaining({ + [KnownCaip19Id.TrxMainnet]: expect.any(Object), + }), + ); + expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( + 3600000, + ); + }, + ); + }); + }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 5cc5475d..4c27e2fa 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -114,7 +114,7 @@ export class AssetsService { return this.#snapAdapter.getByKeyringAccountId(accountId); } - async getMultipleTokenConversions( + async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< Record> diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts index 4868a435..0590aa2c 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -1478,7 +1478,7 @@ export class SnapAssetsAdapter { * @returns The fiat ticker. */ #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { - if (!AssetsService.isFiat(caipAssetType)) { + if (!SnapAssetsAdapter.isFiat(caipAssetType)) { throw new Error('Passed caipAssetType is not a fiat asset'); } @@ -1500,7 +1500,7 @@ export class SnapAssetsAdapter { cryptoPrices: Record; }> { const cryptoAssets = allAssets.filter( - (asset) => !AssetsService.isFiat(asset), + (asset) => !SnapAssetsAdapter.isFiat(asset), ); const [fiatExchangeRates, cryptoPrices] = await Promise.all([ @@ -1574,7 +1574,7 @@ export class SnapAssetsAdapter { let fromUsdRate: BigNumber; let toUsdRate: BigNumber; - if (AssetsService.isFiat(from)) { + if (SnapAssetsAdapter.isFiat(from)) { /** * Beware: * We need to invert the fiat exchange rate because exchange rate != spot price @@ -1592,7 +1592,7 @@ export class SnapAssetsAdapter { fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); } - if (AssetsService.isFiat(to)) { + if (SnapAssetsAdapter.isFiat(to)) { /** * Beware: * We need to invert the fiat exchange rate because exchange rate != spot price @@ -1733,7 +1733,7 @@ export class SnapAssetsAdapter { let unitUsdRate: BigNumber; - if (AssetsService.isFiat(unit)) { + if (SnapAssetsAdapter.isFiat(unit)) { /** * Beware: * We need to invert the fiat exchange rate because exchange rate != spot price From 8024e65314a4728347247d0c200dfd719ce8e0ee Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 22:57:46 +0100 Subject: [PATCH 4/7] style(tron-wallet-snap): format AssetsService facade --- .../tron-wallet-snap/src/services/assets/AssetsService.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 4c27e2fa..6ba8e4a3 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,10 +1,10 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; -import type { KeyringAccount } from '@metamask/keyring-api'; import type { CaipAssetType } from '@metamask/utils'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; @@ -16,8 +16,8 @@ import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import type { ILogger } from '../../utils/logger'; import type { State, UnencryptedStateValue } from '../state/State'; -import type { AssetsRepository } from './AssetsRepository'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; +import type { AssetsRepository } from './AssetsRepository'; /** * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter @@ -108,9 +108,7 @@ export class AssetsService { return this.#snapAdapter.getAll(); } - async getByKeyringAccountId( - accountId: string, - ): Promise { + async getByKeyringAccountId(accountId: string): Promise { return this.#snapAdapter.getByKeyringAccountId(accountId); } From 38785c881f275ebcd9aa8afad8f15331480b6d33 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 23:01:23 +0100 Subject: [PATCH 5/7] fix(tron-wallet-snap): satisfy eslint for facade delegation tests Move import suppressions to SnapAssetsAdapter and align test assertions with jest prefer-strict-equal rules. --- eslint-suppressions.json | 2 +- .../src/services/assets/AssetsService.test.ts | 53 ++++++++----------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dfe5b09e..d21f9192 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -311,7 +311,7 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/assets/AssetsService.ts": { + "packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts": { "import-x/no-extraneous-dependencies": { "count": 2 } diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index af1d21b6..fae7b55c 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -2869,41 +2869,34 @@ describe('AssetsService', () => { expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); - await expect( - assetsService.getAccountAssets(mockAccount.id), - ).resolves.toEqual([asset]); - await expect( - assetsService.getAccountAssetsByIDs(mockAccount.id, [ + expect(await assetsService.getAccountAssets(mockAccount.id)).toStrictEqual( + [asset], + ); + expect( + await assetsService.getAccountAssetsByIDs(mockAccount.id, [ KnownCaip19Id.TrxMainnet, ]), - ).resolves.toEqual([asset]); - await expect( - assetsService.getAccountAssetByID( + ).toStrictEqual([asset]); + expect( + await assetsService.getAccountAssetByID( mockAccount.id, KnownCaip19Id.TrxMainnet, ), - ).resolves.toEqual(asset); - await expect( - assetsService.getByKeyringAccountId(mockAccount.id), - ).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - assetType: KnownCaip19Id.TrxMainnet, - }), - ]), - ); - await expect( - assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]), - ).resolves.toEqual( - expect.objectContaining({ - [KnownCaip19Id.TrxMainnet]: expect.any(Object), - }), - ); + ).toStrictEqual(asset); + const byKeyringAccountId = + await assetsService.getByKeyringAccountId(mockAccount.id); + expect( + byKeyringAccountId.some( + (savedAsset) => savedAsset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); + const marketData = await assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, + ]); + expect(marketData[KnownCaip19Id.TrxMainnet]).toBeDefined(); expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( 3600000, ); From 4e73383969c054c7a71d6e3e331ddae13e9a6b01 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 31 Jul 2026 23:05:01 +0100 Subject: [PATCH 6/7] style(tron-wallet-snap): format facade delegation test --- .../src/services/assets/AssetsService.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index fae7b55c..549b55ec 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -2869,9 +2869,9 @@ describe('AssetsService', () => { expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); - expect(await assetsService.getAccountAssets(mockAccount.id)).toStrictEqual( - [asset], - ); + expect( + await assetsService.getAccountAssets(mockAccount.id), + ).toStrictEqual([asset]); expect( await assetsService.getAccountAssetsByIDs(mockAccount.id, [ KnownCaip19Id.TrxMainnet, @@ -2883,8 +2883,9 @@ describe('AssetsService', () => { KnownCaip19Id.TrxMainnet, ), ).toStrictEqual(asset); - const byKeyringAccountId = - await assetsService.getByKeyringAccountId(mockAccount.id); + const byKeyringAccountId = await assetsService.getByKeyringAccountId( + mockAccount.id, + ); expect( byKeyringAccountId.some( (savedAsset) => savedAsset.assetType === KnownCaip19Id.TrxMainnet, From a6cbab4a89eb5161183ee212a7c67a660ffde2c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 16:56:06 +0000 Subject: [PATCH 7/7] fix(tron-wallet-snap): update manifest shasum for Linux CI build The bundle hash differs when built with CI secret env vars for mainnet RPC URLs. Commit the shasum produced by the Linux CI build so the working tree stays clean after `yarn build`. Co-authored-by: Ulisses Ferreira --- packages/tron-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 8b647188..614d6bd6 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "0igt/4MaPm2VNFYSs67zPDZouKmDpxvw/lzM+yY4zWA=", + "shasum": "YQ6Ol+aoXzCW8h2jPOUahi/1MFrXlwTPCHEEtVqi3bg=", "location": { "npm": { "filePath": "dist/bundle.js",