From 65c376b820568f1e9a562eccd1f46f494107d8f4 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:10:19 +0100 Subject: [PATCH 01/16] refactor(tron-wallet-snap): extract shared asset utils and inject SnapAssetsAdapter Move snap-owned extraction helpers into shared utils, wire SnapAssetsAdapter from context into AssetsService, and rename getByKeyringAccountId to getAccountAssets. --- packages/tron-wallet-snap/CHANGELOG.md | 5 + packages/tron-wallet-snap/snap.manifest.json | 2 +- packages/tron-wallet-snap/src/context.ts | 12 +- .../src/handlers/keyring/keyring.test.ts | 4 +- .../src/handlers/keyring/keyring.ts | 5 +- .../src/services/assets/AssetsService.test.ts | 27 +- .../src/services/assets/AssetsService.ts | 49 +-- .../assets/adapters/SnapAssetsAdapter.ts | 347 ++---------------- .../src/services/assets/snapOwnedAssets.ts | 18 - .../src/services/assets/types.ts | 7 + .../assets/utils/buildAccountResources.ts | 15 + .../services/assets/utils/buildStakedData.ts | 28 ++ .../services/assets/utils/extractBandwidth.ts | 58 +++ .../services/assets/utils/extractEnergy.ts | 56 +++ .../assets/utils/extractInLockPeriodAsset.ts | 48 +++ .../utils/extractReadyForWithdrawalAsset.ts | 48 +++ .../assets/utils/extractStakedNativeAssets.ts | 73 ++++ .../utils/extractStakingRewardsAsset.ts | 34 ++ .../isSnapOwnedAsset.test.ts} | 27 +- .../services/assets/utils/isSnapOwnedAsset.ts | 49 +++ 20 files changed, 506 insertions(+), 406 deletions(-) delete mode 100644 packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/buildAccountResources.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractBandwidth.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractEnergy.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/extractStakingRewardsAsset.ts rename packages/tron-wallet-snap/src/services/assets/{snapOwnedAssets.test.ts => utils/isSnapOwnedAsset.test.ts} (58%) create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 4486ec75e..7c659b0f0 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Extract shared asset util functions and inject `SnapAssetsAdapter` from `context` into `AssetsService` +- Rename `getByKeyringAccountId` to `getAccountAssets` (with essential-asset synthesis) and update keyring callers + ## [3.1.0] ### Added diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 23aa33cd3..78937309b 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": "e7sZ7XY8fvO4GLcznlb5N/Fh95+ANPKS/GzYoHHz2pc=", + "shasum": "XODF4lCphkA7wyPeF0SxobKZYtVZWrPylqdLLaY0dXQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 026a1ae13..1a4ee12d9 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -25,6 +25,7 @@ import { RpcHandler } from './handlers/rpc/rpc'; import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountsRepository } from './services/accounts/AccountsRepository'; import { AccountsService } from './services/accounts/AccountsService'; +import { SnapAssetsAdapter } from './services/assets/adapters/SnapAssetsAdapter'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; import { ConfigProvider } from './services/config'; @@ -113,8 +114,7 @@ const securityAlertsApiClient = new SecurityAlertsApiClient( logger, ); -// Business Services -const assetsService = new AssetsService({ +const snapAssetsAdapter = new SnapAssetsAdapter({ logger, state, assetsRepository, @@ -123,8 +123,12 @@ const assetsService = new AssetsService({ priceApiClient, tokenApiClient, snapClient, - remoteFeatureFlagsProvider, - assetsProvider, + configProvider, +}); + +// Business Services +const assetsService = new AssetsService({ + snapAdapter: snapAssetsAdapter, }); const transactionsService = new TransactionsService({ diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts index 47fd5452f..de310fec6 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -85,7 +85,7 @@ describe('KeyringHandler', () => { }), } as unknown as jest.Mocked; mockAssetsService = { - getByKeyringAccountId: jest.fn().mockResolvedValue([]), + getAccountAssets: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; mockTransactionsService = { checkAddressActivity: jest.fn(), @@ -649,7 +649,7 @@ describe('KeyringHandler', () => { const result = await keyringHandler.getAccountAssets(mockAccount.id); expect(result).toStrictEqual([]); - expect(mockAssetsService.getByKeyringAccountId).toHaveBeenCalledWith( + expect(mockAssetsService.getAccountAssets).toHaveBeenCalledWith( mockAccount.id, ); }); diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 2c9b0b431..710278d18 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -191,7 +191,7 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.info('Listing account assets', { accountId }); const assetEntities = - await this.#assetsService.getByKeyringAccountId(accountId); + await this.#assetsService.getAccountAssets(accountId); const result = assetEntities .filter( (asset) => @@ -280,8 +280,7 @@ export class KeyringHandler implements KeyringSnapRpc { await this.#getAccountOrThrow(accountId); - const assetsList = - await this.#assetsService.getByKeyringAccountId(accountId); + const assetsList = await this.#assetsService.getAccountAssets(accountId); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) 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 29e40e3a8..19f166ed6 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -49,8 +49,12 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ (global as any).snap = {}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { configProvider } = require('../../context'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { SnapAssetsAdapter } = require('./adapters/SnapAssetsAdapter'); const mockAccount: KeyringAccount = { id: 'test-account-id', @@ -260,7 +264,7 @@ async function withAssetsService( trackError: jest.fn().mockResolvedValue(undefined), }; - const assetsService = new AssetsService({ + const snapAdapter = new SnapAssetsAdapter({ logger: mockLogger, assetsRepository: mockAssetsRepository, state: mockState, @@ -269,7 +273,9 @@ async function withAssetsService( priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, + configProvider, }); + const assetsService = new AssetsService({ snapAdapter }); return await testFunction({ assetsService, @@ -2871,9 +2877,15 @@ describe('AssetsService', () => { expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); + const accountAssets = await assetsService.getAccountAssets( + mockAccount.id, + ); expect( - await assetsService.getAccountAssets(mockAccount.id), - ).toStrictEqual([asset]); + accountAssets.some( + (savedAsset: AssetEntity) => + savedAsset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); expect( await assetsService.getAccountAssetsByIDs(mockAccount.id, [ KnownCaip19Id.TrxMainnet, @@ -2885,15 +2897,6 @@ describe('AssetsService', () => { KnownCaip19Id.TrxMainnet, ), ).toStrictEqual(asset); - const byKeyringAccountId = await assetsService.getByKeyringAccountId( - mockAccount.id, - ); - expect( - byKeyringAccountId.some( - (savedAsset: AssetEntity) => - savedAsset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); const marketData = await assetsService.getMultipleTokensMarketData([ { asset: KnownCaip19Id.TrxMainnet, diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 77f63dc87..c1ef4ba3d 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,8 +1,4 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, @@ -11,17 +7,9 @@ import type { } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; -import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; -import type { SnapClient } from '../../clients/snap/SnapClient'; -import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; -import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; -import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; -import type { ILogger } from '../../utils/logger'; -import type { State, UnencryptedStateValue } from '../state/State'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; -import type { AssetsRepository } from './AssetsRepository'; /** * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter @@ -33,37 +21,8 @@ export class AssetsService { readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; - 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; - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; - assetsProvider: AssetsProvider; - }) { - this.#snapAdapter = new SnapAssetsAdapter({ - logger, - assetsRepository, - state, - trongridApiClient, - tronHttpClient, - priceApiClient, - tokenApiClient, - snapClient, - }); + constructor({ snapAdapter }: { snapAdapter: SnapAssetsAdapter }) { + this.#snapAdapter = snapAdapter; this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } @@ -114,10 +73,6 @@ export class AssetsService { return this.#snapAdapter.getAll(); } - async getByKeyringAccountId(accountId: string): Promise { - return this.#snapAdapter.getByKeyringAccountId(accountId); - } - async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< 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 0590aa2ce..7a5b38d3b 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -34,7 +34,6 @@ 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'; @@ -54,11 +53,11 @@ import { 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 { ConfigProvider } from '../../config'; import type { State, UnencryptedStateValue } from '../../state/State'; import type { AssetsRepository } from '../AssetsRepository'; import type { @@ -68,9 +67,18 @@ import type { ReadyForWithdrawalCaipAssetType, ResourceCaipAssetType, StakedCaipAssetType, + StakedData, StakingRewardsCaipAssetType, TokenCaipAssetType, } from '../types'; +import { buildAccountResources } from '../utils/buildAccountResources'; +import { buildStakedData } from '../utils/buildStakedData'; +import { extractBandwidth } from '../utils/extractBandwidth'; +import { extractEnergy } from '../utils/extractEnergy'; +import { extractInLockPeriodAsset } from '../utils/extractInLockPeriodAsset'; +import { extractReadyForWithdrawalAsset } from '../utils/extractReadyForWithdrawalAsset'; +import { extractStakedNativeAssets } from '../utils/extractStakedNativeAssets'; +import { extractStakingRewardsAsset } from '../utils/extractStakingRewardsAsset'; /** * Normalized account data structure that provides a consistent shape for both @@ -85,11 +93,7 @@ type NormalizedAccountData = { /** 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; - }; + stakedData: StakedData; /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ resources: AccountResources | Record; /** Unclaimed staking rewards in sun (0 if no rewards). */ @@ -113,6 +117,8 @@ export class SnapAssetsAdapter { readonly #snapClient: SnapClient; + readonly #configProvider: ConfigProvider; + readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; spotPrices: number; @@ -128,6 +134,7 @@ export class SnapAssetsAdapter { priceApiClient, tokenApiClient, snapClient, + configProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; @@ -137,6 +144,7 @@ export class SnapAssetsAdapter { priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; + configProvider: ConfigProvider; }) { this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; @@ -146,8 +154,9 @@ export class SnapAssetsAdapter { this.#priceApiClient = priceApiClient; this.#tokenApiClient = tokenApiClient; this.#snapClient = snapClient; + this.#configProvider = configProvider; - const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + const { cacheTtlsMilliseconds } = this.#configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } @@ -155,10 +164,6 @@ export class SnapAssetsAdapter { return caipAssetId.includes('swift:0/iso4217:'); } - async getAccountAssets(accountId: string): Promise { - return this.#assetsRepository.getByAccountId(accountId); - } - async getAccountAssetsByIDs( accountId: string, assetTypes: string[], @@ -316,10 +321,8 @@ export class SnapAssetsAdapter { stakingRewardsRequest: PromiseSettledResult; }): NormalizedAccountData { const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - const resources = - tronAccountResourcesRequest.status === 'fulfilled' - ? tronAccountResourcesRequest.value - : {}; + const resources = buildAccountResources(tronAccountResourcesRequest); + const stakedData = buildStakedData(tronAccountInfoRequest); const stakingRewards = stakingRewardsRequest.status === 'fulfilled' ? Math.max(0, stakingRewardsRequest.value) @@ -330,11 +333,7 @@ export class SnapAssetsAdapter { nativeBalance: 0, trc10Balances: [], trc20Balances: trc20BalancesFallback, - stakedData: { - frozenV2: [], - unfrozenV2: [], - accountResource: undefined, - }, + stakedData, resources, stakingRewards, }; @@ -345,11 +344,7 @@ export class SnapAssetsAdapter { nativeBalance: tronAccountInfo.balance ?? 0, trc10Balances: tronAccountInfo.assetV2 ?? [], trc20Balances: tronAccountInfo.trc20 ?? [], - stakedData: { - frozenV2: tronAccountInfo.frozenV2 ?? [], - unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], - accountResource: tronAccountInfo.account_resource, - }, + stakedData, resources, stakingRewards, }; @@ -371,18 +366,18 @@ export class SnapAssetsAdapter { ): 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), + ...extractStakedNativeAssets(account, scope, data.stakedData), + extractReadyForWithdrawalAsset(account, scope, data.stakedData), + extractInLockPeriodAsset(account, scope, data.stakedData), + extractStakingRewardsAsset(account, scope, data.stakingRewards), ...this.#extractTrc10Assets(account, scope, data.trc10Balances), ...this.#extractTrc20Assets(account, scope, data.trc20Balances), - ...this.#extractBandwidth({ + ...extractBandwidth({ account, scope, tronAccountResources: data.resources, }), - ...this.#extractEnergy({ + ...extractEnergy({ account, scope, tronAccountResources: data.resources, @@ -484,285 +479,6 @@ export class SnapAssetsAdapter { }; } - /** - * 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. * @@ -1442,11 +1158,8 @@ export class SnapAssetsAdapter { } as AssetEntity; } - async getByKeyringAccountId( - keyringAccountId: string, - ): Promise { - const savedAssets = - await this.#assetsRepository.getByAccountId(keyringAccountId); + async getAccountAssets(accountId: string): Promise { + const savedAssets = await this.#assetsRepository.getByAccountId(accountId); /** * Ensure the special assets are always present whether they have been synced or not. @@ -1462,7 +1175,7 @@ export class SnapAssetsAdapter { if (!savedAsset) { const zeroBalanceAsset = this.#createZeroBalanceAsset( essentialAssetId as KnownCaip19Id, - keyringAccountId, + accountId, ); missingEssentialAssets.push(zeroBalanceAsset); } diff --git a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts b/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts deleted file mode 100644 index c8d44a17f..000000000 --- a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SNAP_OWNED_ASSETS } from '../../constants'; - -const SNAP_OWNED_ASSET_IDS = new Set(SNAP_OWNED_ASSETS); - -/** - * Returns whether an asset remains exclusively managed by the Snap. - * - * AssetsController does not persist certain Tron protocol assets, including - * staking positions and account resources. These assets must always be read, - * synchronized, persisted, and published by the Snap, regardless of the - * assets migration stage. - * - * @param assetId - CAIP-19 asset ID. - * @returns Whether the asset is exclusively managed by the Snap. - */ -export function isSnapOwnedAsset(assetId: string): boolean { - return SNAP_OWNED_ASSET_IDS.has(assetId); -} diff --git a/packages/tron-wallet-snap/src/services/assets/types.ts b/packages/tron-wallet-snap/src/services/assets/types.ts index 19791f839..f8635428a 100644 --- a/packages/tron-wallet-snap/src/services/assets/types.ts +++ b/packages/tron-wallet-snap/src/services/assets/types.ts @@ -2,8 +2,15 @@ import { CaipAssetTypeStruct } from '@metamask/keyring-api'; import type { TrxScope } from '@metamask/keyring-api'; import { pattern } from '@metamask/superstruct'; +import type { TronAccount } from '../../clients/trongrid/types'; import type { Network } from '../../constants'; +export type StakedData = { + frozenV2: TronAccount['frozenV2']; + unfrozenV2?: TronAccount['unfrozenV2']; + accountResource: TronAccount['account_resource'] | undefined; +}; + export type NativeCaipAssetType = `${Network}/slip44:195`; export type StakedCaipAssetType = `${TrxScope}/slip44:195-staked-for-${'energy' | 'bandwidth'}`; diff --git a/packages/tron-wallet-snap/src/services/assets/utils/buildAccountResources.ts b/packages/tron-wallet-snap/src/services/assets/utils/buildAccountResources.ts new file mode 100644 index 000000000..80bf79df7 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/buildAccountResources.ts @@ -0,0 +1,15 @@ +import type { AccountResources } from '../../../clients/tron-http'; + +/** + * Builds account resources from a settled getAccountResources request. + * + * @param tronAccountResourcesRequest - The settled promise result from getAccountResources. + * @returns Account resources, or an empty object when the request failed. + */ +export function buildAccountResources( + tronAccountResourcesRequest: PromiseSettledResult, +): AccountResources | Record { + return tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts b/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts new file mode 100644 index 000000000..9743f58cb --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts @@ -0,0 +1,28 @@ +import type { TronAccount } from '../../../clients/trongrid/types'; + +import type { StakedData } from '../types'; + +/** + * Builds staking data from a settled account info request. + * + * @param tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. + * @returns Staking data with empty defaults for inactive accounts. + */ +export function buildStakedData( + tronAccountInfoRequest: PromiseSettledResult, +): StakedData { + if (tronAccountInfoRequest.status === 'rejected') { + return { + frozenV2: [], + unfrozenV2: [], + accountResource: undefined, + }; + } + + const tronAccountInfo = tronAccountInfoRequest.value; + return { + frozenV2: tronAccountInfo.frozenV2 ?? [], + unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractBandwidth.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractBandwidth.ts new file mode 100644 index 000000000..a61437ead --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractBandwidth.ts @@ -0,0 +1,58 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { AccountResources } from '../../../clients/tron-http'; +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; + +/** + * 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. + */ +export function 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, + }, + ]; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractEnergy.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractEnergy.ts new file mode 100644 index 000000000..17e26cb39 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractEnergy.ts @@ -0,0 +1,56 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { AccountResources } from '../../../clients/tron-http'; +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; + +/** + * 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 Array containing energy and maximum energy assets. + */ +export function 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, + }, + ]; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts new file mode 100644 index 000000000..fc34e4489 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts @@ -0,0 +1,48 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { RawTronUnfrozenV2 } from '../../../clients/trongrid/types'; +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; + +import type { StakedData } from '../types'; + +/** + * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns The in-lock-period asset (amount may be 0). + */ +export function extractInLockPeriodAsset( + account: KeyringAccount, + scope: Network, + stakedData: 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, + }; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts new file mode 100644 index 000000000..c55762ea4 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts @@ -0,0 +1,48 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { RawTronUnfrozenV2 } from '../../../clients/trongrid/types'; +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; + +import type { StakedData } from '../types'; + +/** + * 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 The ready-for-withdrawal asset (amount may be 0). + */ +export function extractReadyForWithdrawalAsset( + account: KeyringAccount, + scope: Network, + stakedData: 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, + }; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts new file mode 100644 index 000000000..f9678cfa2 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts @@ -0,0 +1,73 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; + +import type { StakedData } from '../types'; + +/** + * 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 Array of staked assets (always 2: bandwidth and energy, amounts may be 0). + */ +export function extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: StakedData, +): 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; + + return [ + { + 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, + }, + { + 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, + }, + ]; +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractStakingRewardsAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractStakingRewardsAsset.ts new file mode 100644 index 000000000..af7125d64 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractStakingRewardsAsset.ts @@ -0,0 +1,34 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { Network } from '../../../constants'; +import { Networks } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; + +/** + * Extracts staking rewards asset (unclaimed voting rewards). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakingRewards - Unclaimed staking rewards in sun. + * @returns The staking rewards asset. + */ +export function 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, + }; +} diff --git a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts similarity index 58% rename from packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts rename to packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts index 84a8b4eee..e11d23ee7 100644 --- a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts @@ -1,5 +1,12 @@ -import { KnownCaip19Id, SNAP_OWNED_ASSETS } from '../../constants'; -import { isSnapOwnedAsset } from './snapOwnedAssets'; +import { + KnownCaip19Id, + Network, + SNAP_OWNED_ASSETS, +} from '../../../constants'; +import { + getSnapOwnedAssetIdsForScope, + isSnapOwnedAsset, +} from './isSnapOwnedAsset'; describe('isSnapOwnedAsset', () => { it.each(SNAP_OWNED_ASSETS)( @@ -32,3 +39,19 @@ describe('isSnapOwnedAsset', () => { ).toBe(false); }); }); + +describe('getSnapOwnedAssetIdsForScope', () => { + it.each([ + Network.Mainnet, + Network.Nile, + Network.Shasta, + ] as const)('returns only snap-owned assets for %s', (scope) => { + const assetIds = getSnapOwnedAssetIdsForScope(scope); + + expect(assetIds).toHaveLength(9); + expect(assetIds.every((assetId) => isSnapOwnedAsset(assetId))).toBe(true); + expect( + assetIds.every((assetId) => assetId.startsWith(`${scope}/`)), + ).toBe(true); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts new file mode 100644 index 000000000..a8c42184d --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts @@ -0,0 +1,49 @@ +import type { CaipAssetType } from '@metamask/utils'; + +import { + Networks, + SNAP_OWNED_ASSETS, + type Network, +} from '../../../constants'; + +const SNAP_OWNED_ASSET_IDS = new Set(SNAP_OWNED_ASSETS); + +/** + * Returns the full snap-owned asset ID set for a network scope. + * + * Matches the assets produced by `fetchAssetsAndBalancesForAccount` for that + * scope (staking positions and account resources, including zero balances). + * + * @param scope - The network to query. + * @returns CAIP-19 asset IDs exclusively managed by the Snap on that network. + */ +export function getSnapOwnedAssetIdsForScope(scope: Network): CaipAssetType[] { + const network = Networks[scope]; + + return [ + network.stakedForBandwidth.id, + network.stakedForEnergy.id, + network.readyForWithdrawal.id, + network.stakingRewards.id, + network.inLockPeriod.id, + network.bandwidth.id, + network.maximumBandwidth.id, + network.energy.id, + network.maximumEnergy.id, + ]; +} + +/** + * Returns whether an asset remains exclusively managed by the Snap. + * + * AssetsController does not persist certain Tron protocol assets, including + * staking positions and account resources. These assets must always be read, + * synchronized, persisted, and published by the Snap, regardless of the + * assets migration stage. + * + * @param assetId - CAIP-19 asset ID. + * @returns Whether the asset is exclusively managed by the Snap. + */ +export function isSnapOwnedAsset(assetId: string): boolean { + return SNAP_OWNED_ASSET_IDS.has(assetId); +} From 82f1e438a4986b2c415d77efa1d3774ebd1e6447 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:13:45 +0100 Subject: [PATCH 02/16] chore(tron-wallet-snap): link changelog entries to #143 --- packages/tron-wallet-snap/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 7c659b0f0..a091cac27 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Extract shared asset util functions and inject `SnapAssetsAdapter` from `context` into `AssetsService` -- Rename `getByKeyringAccountId` to `getAccountAssets` (with essential-asset synthesis) and update keyring callers +- Extract shared asset util functions and inject `SnapAssetsAdapter` from `context` into `AssetsService` ([#143](https://github.com/MetaMask/internal-snaps/pull/143)) +- Rename `getByKeyringAccountId` to `getAccountAssets` (with essential-asset synthesis) and update keyring callers ([#143](https://github.com/MetaMask/internal-snaps/pull/143)) ## [3.1.0] From f86b1f17c5d4a95d2684d3075a4962fc526b7d92 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:27:08 +0100 Subject: [PATCH 03/16] fix(tron-wallet-snap): format asset utils and sync manifest shasum from CI Apply oxfmt to extracted asset utils and set snap.manifest.json shasum to the Linux CI build output so the build job's clean-working-tree check passes. --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../services/assets/utils/buildStakedData.ts | 1 - .../assets/utils/extractInLockPeriodAsset.ts | 1 - .../utils/extractReadyForWithdrawalAsset.ts | 1 - .../assets/utils/extractStakedNativeAssets.ts | 1 - .../assets/utils/isSnapOwnedAsset.test.ts | 29 ++++++++----------- .../services/assets/utils/isSnapOwnedAsset.ts | 6 +--- 7 files changed, 14 insertions(+), 27 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 78937309b..0f47ebb3f 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": "XODF4lCphkA7wyPeF0SxobKZYtVZWrPylqdLLaY0dXQ=", + "shasum": "NpxOo6DkB0sBh8xpisBu3o+7G6MJriDNsADcHVb/Qp8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts b/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts index 9743f58cb..7a1f75c15 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/buildStakedData.ts @@ -1,5 +1,4 @@ import type { TronAccount } from '../../../clients/trongrid/types'; - import type { StakedData } from '../types'; /** diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts index fc34e4489..cfcc41b05 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractInLockPeriodAsset.ts @@ -5,7 +5,6 @@ import type { Network } from '../../../constants'; import { Networks } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; - import type { StakedData } from '../types'; /** diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts index c55762ea4..ab839c267 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractReadyForWithdrawalAsset.ts @@ -5,7 +5,6 @@ import type { Network } from '../../../constants'; import { Networks } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; - import type { StakedData } from '../types'; /** diff --git a/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts b/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts index f9678cfa2..6af69d448 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/extractStakedNativeAssets.ts @@ -4,7 +4,6 @@ import type { Network } from '../../../constants'; import { Networks } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; - import type { StakedData } from '../types'; /** diff --git a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts index e11d23ee7..21ccddec6 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.test.ts @@ -1,8 +1,4 @@ -import { - KnownCaip19Id, - Network, - SNAP_OWNED_ASSETS, -} from '../../../constants'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../../constants'; import { getSnapOwnedAssetIdsForScope, isSnapOwnedAsset, @@ -41,17 +37,16 @@ describe('isSnapOwnedAsset', () => { }); describe('getSnapOwnedAssetIdsForScope', () => { - it.each([ - Network.Mainnet, - Network.Nile, - Network.Shasta, - ] as const)('returns only snap-owned assets for %s', (scope) => { - const assetIds = getSnapOwnedAssetIdsForScope(scope); + it.each([Network.Mainnet, Network.Nile, Network.Shasta] as const)( + 'returns only snap-owned assets for %s', + (scope) => { + const assetIds = getSnapOwnedAssetIdsForScope(scope); - expect(assetIds).toHaveLength(9); - expect(assetIds.every((assetId) => isSnapOwnedAsset(assetId))).toBe(true); - expect( - assetIds.every((assetId) => assetId.startsWith(`${scope}/`)), - ).toBe(true); - }); + expect(assetIds).toHaveLength(9); + expect(assetIds.every((assetId) => isSnapOwnedAsset(assetId))).toBe(true); + expect(assetIds.every((assetId) => assetId.startsWith(`${scope}/`))).toBe( + true, + ); + }, + ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts index a8c42184d..83d7ba101 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts @@ -1,10 +1,6 @@ import type { CaipAssetType } from '@metamask/utils'; -import { - Networks, - SNAP_OWNED_ASSETS, - type Network, -} from '../../../constants'; +import { Networks, SNAP_OWNED_ASSETS, type Network } from '../../../constants'; const SNAP_OWNED_ASSET_IDS = new Set(SNAP_OWNED_ASSETS); From 32875882dbe02c9373f97e7bbb444031b97afd1c Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:30:53 +0100 Subject: [PATCH 04/16] fix(tron-wallet-snap): split type import in isSnapOwnedAsset Satisfy import-x/consistent-type-specifier-style for CI eslint. --- .../src/services/assets/utils/isSnapOwnedAsset.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts index 83d7ba101..e8cb9b2f6 100644 --- a/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts +++ b/packages/tron-wallet-snap/src/services/assets/utils/isSnapOwnedAsset.ts @@ -1,6 +1,7 @@ import type { CaipAssetType } from '@metamask/utils'; -import { Networks, SNAP_OWNED_ASSETS, type Network } from '../../../constants'; +import { Networks, SNAP_OWNED_ASSETS } from '../../../constants'; +import type { Network } from '../../../constants'; const SNAP_OWNED_ASSET_IDS = new Set(SNAP_OWNED_ASSETS); From 79f6a12bb648596b3739df67187dbf16d04039d0 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:11:49 +0100 Subject: [PATCH 05/16] feat(tron-wallet-snap): add CoreAssetsAdapter and mapControllerAsset Introduce CoreAssetsAdapter with AssetsController reads and snap-owned fetch/publish helpers. Wire it into AssetsService unused pending routing. --- eslint-suppressions.json | 102 +------ packages/tron-wallet-snap/CHANGELOG.md | 4 + packages/tron-wallet-snap/package.json | 1 + packages/tron-wallet-snap/src/context.ts | 8 + .../src/services/assets/AssetsService.test.ts | 14 +- .../src/services/assets/AssetsService.ts | 18 +- .../assets/adapters/CoreAssetsAdapter.ts | 248 ++++++++++++++++++ .../assets/utils/mapControllerAsset.test.ts | 61 +++++ .../assets/utils/mapControllerAsset.ts | 34 +++ yarn.lock | 1 + 10 files changed, 386 insertions(+), 105 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5b13d4f15..ceb709105 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1352,15 +1352,7 @@ "count": 20 } }, - "packages/tron-wallet-snap/src/caching/InMemoryCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/caching/StateCache.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 1 } @@ -1375,20 +1367,7 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/clients/price-api/types.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/security-alerts-api/utils.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - }, "no-restricted-syntax": { "count": 5 } @@ -1401,11 +1380,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/clients/trongrid/TrongridApiClient.test.ts": { "require-atomic-updates": { "count": 1 @@ -1424,16 +1398,6 @@ "count": 42 } }, - "packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/handlers/clientRequest/validation.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/handlers/cronjob/cronjob.test.tsx": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1452,11 +1416,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/accounts/AccountsService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/assets/AssetsRepository.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 @@ -1464,22 +1423,12 @@ }, "packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 3 + "count": 2 }, "@typescript-eslint/no-explicit-any": { "count": 1 } }, - "packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/config/ConfigProvider.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/FeeCalculatorService.ts": { "no-restricted-syntax": { "count": 1 @@ -1493,11 +1442,6 @@ "count": 15 } }, - "packages/tron-wallet-snap/src/services/send/SendService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/send/TransactionExpirationRefresherService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -1508,11 +1452,6 @@ "count": 7 } }, - "packages/tron-wallet-snap/src/services/staking/StakingService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/state/State.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -1521,11 +1460,6 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/transaction-expiration-refresher/TransactionExpirationRefresherService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/services/transaction-scan/TransactionScanService.ts": { "no-restricted-syntax": { "count": 5 @@ -1542,14 +1476,6 @@ }, "@typescript-eslint/no-explicit-any": { "count": 7 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/services/wallet/WalletService.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/components/TransactionAlert/TransactionAlert.test.tsx": { @@ -1568,9 +1494,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 3 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.test.tsx": { @@ -1581,14 +1504,6 @@ "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx": { "@typescript-eslint/no-explicit-any": { "count": 14 - }, - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx": { - "import-x/no-extraneous-dependencies": { - "count": 1 } }, "packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest.test.tsx": { @@ -1604,26 +1519,11 @@ "count": 2 } }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, - "packages/tron-wallet-snap/src/utils/deriveTronFromCoinTypeNode.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/utils/getExplorerUrl.test.ts": { "n/no-process-env": { "count": 3 } }, - "packages/tron-wallet-snap/src/validation/keyring-structs.test.ts": { - "import-x/no-extraneous-dependencies": { - "count": 1 - } - }, "packages/tron-wallet-snap/src/validation/transaction.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index a091cac27..a076b594f 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) + ### Changed - Extract shared asset util functions and inject `SnapAssetsAdapter` from `context` into `AssetsService` ([#143](https://github.com/MetaMask/internal-snaps/pull/143)) diff --git a/packages/tron-wallet-snap/package.json b/packages/tron-wallet-snap/package.json index c09483487..7debfa820 100644 --- a/packages/tron-wallet-snap/package.json +++ b/packages/tron-wallet-snap/package.json @@ -60,6 +60,7 @@ "@metamask/snaps-jest": "^10.2.0", "@metamask/snaps-sdk": "^11.2.0", "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.9.0", "@types/lodash": "^4.17.15", "async-mutex": "^0.5.0", "bignumber.js": "^9.3.1", diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 1a4ee12d9..12f19d81e 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -25,6 +25,7 @@ import { RpcHandler } from './handlers/rpc/rpc'; import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountsRepository } from './services/accounts/AccountsRepository'; import { AccountsService } from './services/accounts/AccountsService'; +import { CoreAssetsAdapter } from './services/assets/adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './services/assets/adapters/SnapAssetsAdapter'; import { AssetsRepository } from './services/assets/AssetsRepository'; import { AssetsService } from './services/assets/AssetsService'; @@ -125,10 +126,17 @@ const snapAssetsAdapter = new SnapAssetsAdapter({ snapClient, configProvider, }); +const coreAssetsAdapter = new CoreAssetsAdapter({ + logger, + assetsProvider, + trongridApiClient, + tronHttpClient, +}); // Business Services const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, + coreAdapter: coreAssetsAdapter, }); const transactionsService = new TransactionsService({ 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 19f166ed6..7f261b999 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -54,6 +54,8 @@ const { configProvider } = require('../../context'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); // eslint-disable-next-line @typescript-eslint/no-require-imports +const { CoreAssetsAdapter } = require('./adapters/CoreAssetsAdapter'); +// eslint-disable-next-line @typescript-eslint/no-require-imports const { SnapAssetsAdapter } = require('./adapters/SnapAssetsAdapter'); const mockAccount: KeyringAccount = { @@ -275,7 +277,17 @@ async function withAssetsService( snapClient: mockSnapClient, configProvider, }); - const assetsService = new AssetsService({ snapAdapter }); + const coreAdapter = new CoreAssetsAdapter({ + logger: mockLogger, + assetsProvider: { + getAccountAssetByID: jest.fn().mockResolvedValue(null), + getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + }, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + }); + const assetsService = new AssetsService({ snapAdapter, coreAdapter }); return await testFunction({ assetsService, diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index c1ef4ba3d..9af372ec6 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -9,20 +9,32 @@ import type { CaipAssetType } from '@metamask/utils'; import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter routing can be introduced later - * without changing callers. + * (legacy snap-owned reads/writes). Core adapter is initialized for upcoming + * routing without changing callers. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; + // Initialized for upcoming Core routing; not read until the migration PR lands. + // eslint-disable-next-line no-unused-private-class-members -- reserved adapter slot + readonly #coreAdapter: CoreAssetsAdapter; + readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; - constructor({ snapAdapter }: { snapAdapter: SnapAssetsAdapter }) { + constructor({ + snapAdapter, + coreAdapter, + }: { + snapAdapter: SnapAssetsAdapter; + coreAdapter: CoreAssetsAdapter; + }) { this.#snapAdapter = snapAdapter; + this.#coreAdapter = coreAdapter; this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts new file mode 100644 index 000000000..fce4600e6 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -0,0 +1,248 @@ +import type { Caip19AssetId } from '@metamask/assets-controller'; +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, + KeyringAccount, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; + +import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; +import { Network } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import { mapControllerAsset } from '../utils/mapControllerAsset'; +import { buildAccountResources } from '../utils/buildAccountResources'; +import { buildStakedData } from '../utils/buildStakedData'; +import { extractBandwidth } from '../utils/extractBandwidth'; +import { extractEnergy } from '../utils/extractEnergy'; +import { extractInLockPeriodAsset } from '../utils/extractInLockPeriodAsset'; +import { extractReadyForWithdrawalAsset } from '../utils/extractReadyForWithdrawalAsset'; +import { extractStakedNativeAssets } from '../utils/extractStakedNativeAssets'; +import { extractStakingRewardsAsset } from '../utils/extractStakingRewardsAsset'; +import { isSnapOwnedAsset } from '../utils/isSnapOwnedAsset'; + +/** + * Uses the AssetsController for fungible reads. Snap-owned (special) assets are + * published via keyring events without local persistence when migration is active. + */ +export class CoreAssetsAdapter { + readonly #logger: ILogger; + + readonly #assetsProvider: AssetsProvider; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + constructor({ + logger, + assetsProvider, + trongridApiClient, + tronHttpClient, + }: { + logger: ILogger; + assetsProvider: AssetsProvider; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + }) { + this.#logger = createPrefixedLogger(logger, '[CoreAssetsAdapter]'); + this.#assetsProvider = assetsProvider; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + } + + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + this.#logger.info('Getting account asset by ID', { accountId, assetId }); + const asset = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise<(AssetEntity | null)[]> { + this.#logger.info('Getting account assets by IDs', { accountId, assetIds }); + const assets = await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + return assetIds.map((assetId) => { + const asset = assets[assetId as Caip19AssetId]; + return asset ? mapControllerAsset(accountId, asset) : null; + }); + } + + async getAccountAssetsByScope( + scope: Network, + keyringAccountId: string, + ): Promise { + this.#logger.info('Getting account assets by scope', { + scope, + keyringAccountId, + }); + const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( + scope, + keyringAccountId, + ); + + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(keyringAccountId, asset), + ); + } + + async getAccountAssets(accountId: string): Promise { + this.#logger.info('Getting account assets', { accountId }); + const [mainnetAssets, nileAssets, shastaAssets] = await Promise.all([ + this.#assetsProvider.getAccountAssetsByScope(Network.Mainnet, accountId), + this.#assetsProvider.getAccountAssetsByScope(Network.Nile, accountId), + this.#assetsProvider.getAccountAssetsByScope(Network.Shasta, accountId), + ]); + + const allUnmappedAssets = [ + ...Object.values(mainnetAssets), + ...Object.values(nileAssets), + ...Object.values(shastaAssets), + ]; + const allAssets = allUnmappedAssets.map((asset) => + mapControllerAsset(accountId, asset), + ); + + return allAssets; + } + + /** + * We used to fetch all assets and balances but now the Snap is only responsible for fetching snap-owned assets. + * - Energy & Bandwidth + * - Staked TRX and full staking lifecycle (rewards, in lock period, ready for withdrawal) + * + * @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 for account', { + scope, + account, + }); + + 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), + ]); + + if (tronAccountInfoRequest.status === 'rejected') { + this.#logger.info( + 'Account info request failed, treating as inactive account', + { account, scope }, + ); + } + + const stakedData = buildStakedData(tronAccountInfoRequest); + const resources = buildAccountResources(tronAccountResourcesRequest); + const stakingRewards = + stakingRewardsRequest.status === 'fulfilled' + ? Math.max(0, stakingRewardsRequest.value) + : 0; + + return [ + ...extractStakedNativeAssets(account, scope, stakedData), + extractReadyForWithdrawalAsset(account, scope, stakedData), + extractInLockPeriodAsset(account, scope, stakedData), + extractStakingRewardsAsset(account, scope, stakingRewards), + ...extractBandwidth({ + account, + scope, + tronAccountResources: resources, + }), + ...extractEnergy({ + account, + scope, + tronAccountResources: resources, + }), + ]; + } + + /** + * Publishes snap-owned assets to the extension without persisting locally. + * + * Filters to snap-owned assets, reports each as `added`, and emits balance + * updates for those assets. + * + * @param assets - Assets to publish (non snap-owned entries are ignored). + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Publishing snap-owned assets', assets); + + const snapOwnedAssets = assets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + + if (snapOwnedAssets.length === 0) { + return; + } + + const assetListUpdatedPayload = snapOwnedAssets.reduce< + AccountAssetListUpdatedEvent['params']['assets'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [ + ...(acc[asset.keyringAccountId]?.added ?? []), + asset.assetType, + ], + removed: [], + }, + }), + {}, + ); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + + const balancesUpdatedPayload = snapOwnedAssets.reduce< + AccountBalancesUpdatedEvent['params']['balances'] + >( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } +} diff --git a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts new file mode 100644 index 000000000..2e997fcaa --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.test.ts @@ -0,0 +1,61 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { CaipAssetType } from '@metamask/utils'; + +import { Network } from '../../../constants'; +import { mapControllerAsset } from './mapControllerAsset'; + +describe('mapControllerAsset', () => { + const accountId = 'account-id'; + const unknownAssetId = 'tron:728126428/trc20:unknown'; + + it('maps controller metadata when present', () => { + const asset = { + id: unknownAssetId, + chainId: Network.Mainnet, + balance: { amount: '1234567' }, + metadata: { + type: 'fungible', + symbol: 'TKN', + name: 'Token', + decimals: 6, + image: 'https://example.com/token.png', + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: unknownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TKN', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + iconUrl: 'https://example.com/token.png', + }); + }); + + it('uses empty defaults when metadata is missing', () => { + const assetId = 'tron:728126428/trc20:missing' as CaipAssetType; + const asset = { + id: assetId, + chainId: Network.Mainnet, + balance: { amount: '42' }, + metadata: { type: 'fungible', name: 'Missing' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: assetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: '', + decimals: 0, + rawAmount: '42', + uiAmount: '42', + iconUrl: '', + }); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts new file mode 100644 index 000000000..57fa305ac --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/mapControllerAsset.ts @@ -0,0 +1,34 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { Network } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * @param accountId - Keyring account ID. + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export function mapControllerAsset( + accountId: string, + asset: Asset, +): AssetEntity { + const assetId = asset.id; + const decimals = asset.metadata.decimals ?? 0; + const symbol = asset.metadata.symbol ?? ''; + const iconUrl = asset.metadata.image ?? ''; + const { amount } = asset.balance; + + return { + assetType: assetId, + keyringAccountId: accountId, + network: asset.chainId as Network, + symbol, + decimals, + rawAmount: amount, + uiAmount: toUiAmount(amount, decimals).toString(), + iconUrl, + } as AssetEntity; +} diff --git a/yarn.lock b/yarn.lock index 9f40d8b0d..cae93ad04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3781,6 +3781,7 @@ __metadata: "@metamask/snaps-jest": "npm:^10.2.0" "@metamask/snaps-sdk": "npm:^11.2.0" "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.9.0" "@types/lodash": "npm:^4.17.15" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.3.1" From 13bfa6cc198cab0645ec7fbca75939ffc676b203 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:13:45 +0100 Subject: [PATCH 06/16] chore(tron-wallet-snap): link changelog entry to #144 --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index a076b594f..b8b01826e 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) +- Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) ([#144](https://github.com/MetaMask/internal-snaps/pull/144)) ### Changed From a42b7522da277c9d5a16f1cea8a2ab9d8c14b3b8 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:07:54 +0100 Subject: [PATCH 07/16] fix(tron-wallet-snap): sync manifest shasum after CoreAssetsAdapter rebase --- 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 0f47ebb3f..d3793927f 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": "NpxOo6DkB0sBh8xpisBu3o+7G6MJriDNsADcHVb/Qp8=", + "shasum": "0YaD1wEj/s5NH8rqIzGKWfTyJCn7Cx46maqhOr8nXos=", "location": { "npm": { "filePath": "dist/bundle.js", From c594c3bec183317161f082104882ae12bce5e4b3 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:12:38 +0100 Subject: [PATCH 08/16] fix(tron-wallet-snap): sync CI manifest shasum and format CoreAssetsAdapter --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/assets/adapters/CoreAssetsAdapter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index d3793927f..375f632c4 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": "0YaD1wEj/s5NH8rqIzGKWfTyJCn7Cx46maqhOr8nXos=", + "shasum": "rzJ7T7soBpaaRbny2ZCZqKMw+gLK0L1q4LuV3fLFqVk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts index fce4600e6..1d1f34586 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -14,7 +14,6 @@ import { Network } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { createPrefixedLogger } from '../../../utils/logger'; import type { ILogger } from '../../../utils/logger'; -import { mapControllerAsset } from '../utils/mapControllerAsset'; import { buildAccountResources } from '../utils/buildAccountResources'; import { buildStakedData } from '../utils/buildStakedData'; import { extractBandwidth } from '../utils/extractBandwidth'; @@ -24,6 +23,7 @@ import { extractReadyForWithdrawalAsset } from '../utils/extractReadyForWithdraw import { extractStakedNativeAssets } from '../utils/extractStakedNativeAssets'; import { extractStakingRewardsAsset } from '../utils/extractStakingRewardsAsset'; import { isSnapOwnedAsset } from '../utils/isSnapOwnedAsset'; +import { mapControllerAsset } from '../utils/mapControllerAsset'; /** * Uses the AssetsController for fungible reads. Snap-owned (special) assets are From 9057509259a1146f944f5a5bb47285bb75844f66 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:17:17 +0100 Subject: [PATCH 09/16] fix(tron-wallet-snap): add test return types and sync manifest shasum for CI --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/assets/AssetsService.test.ts | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 375f632c4..f7baff20d 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": "rzJ7T7soBpaaRbny2ZCZqKMw+gLK0L1q4LuV3fLFqVk=", + "shasum": "3Mn3cc4eA7OmuOAA4zs+kMMQGCj1gs982z50E37I+pc=", "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 7f261b999..0dffc8320 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -28,7 +28,16 @@ type MockState = { jest.mock('../../context', () => ({ configProvider: { - get() { + get(): { + priceApi: { + cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; + }; + activeNetworks: never[]; + } { return { priceApi: { cacheTtlsMilliseconds: { @@ -143,7 +152,9 @@ const minimalTronAccount = createMockTronAccount({ * @param overrides - Account-specific fields to set. * @returns A mock AccountResources object. */ -function getMockAccountResources(overrides: Record = {}) { +function getMockAccountResources( + overrides: Record = {}, +): Record { return { freeNetLimit: 600, TotalNetLimit: 0, @@ -161,7 +172,10 @@ function getMockAccountResources(overrides: Record = {}) { * @param assetType - The CAIP-19 asset type to match. * @returns The matching asset, or undefined. */ -function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { +function findAsset( + assets: AssetEntity[], + assetType: KnownCaip19Id, +): AssetEntity | undefined { return assets.find((a: AssetEntity) => a.assetType === assetType); } From 191ba4b9a96aad5ef9f6922999cb2698481ff89c Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:24:52 +0100 Subject: [PATCH 10/16] chore: prune stale eslint suppressions in AssetsService.test.ts --- eslint-suppressions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ceb709105..292b878c6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1422,9 +1422,6 @@ } }, "packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 2 - }, "@typescript-eslint/no-explicit-any": { "count": 1 } @@ -1534,4 +1531,4 @@ "count": 2 } } -} +} \ No newline at end of file From c043490aaa3fffe49808c634034845c718db9bb2 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:29:34 +0100 Subject: [PATCH 11/16] chore: format eslint-suppressions.json --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 292b878c6..d18b3d805 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1531,4 +1531,4 @@ "count": 2 } } -} \ No newline at end of file +} From 98134fb501e3c3acd641ac2d45c6245ad4975cab Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:12:48 +0100 Subject: [PATCH 12/16] feat(tron-wallet-snap): route asset reads through Core when migration is on Add feature-flag resolution and route fungible reads / snap-owned saveMany through CoreAssetsAdapter when the Tron assets migration flag is active. --- packages/tron-wallet-snap/CHANGELOG.md | 2 + packages/tron-wallet-snap/src/context.ts | 1 + .../src/services/assets/AssetsService.test.ts | 685 +++++++++++------- .../src/services/assets/AssetsService.ts | 66 +- 4 files changed, 489 insertions(+), 265 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index b8b01826e..448110e25 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Route fungible asset reads through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) - Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) ([#144](https://github.com/MetaMask/internal-snaps/pull/144)) ### Changed @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.1.0] + ### Added - Add Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 12f19d81e..fc178da57 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -137,6 +137,7 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ const assetsService = new AssetsService({ snapAdapter: snapAssetsAdapter, coreAdapter: coreAssetsAdapter, + remoteFeatureFlagsProvider, }); const transactionsService = new TransactionsService({ 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 0dffc8320..fe0e5bb96 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,6 +1,15 @@ +import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { + AssetsProvider, + RemoteFeatureFlagsProvider, +} from '@metamask/snap-networks-utils'; import { MOCK_EXCHANGE_RATES } from '../../clients/price-api/mocks/exchange-rates'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; @@ -10,10 +19,14 @@ import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; import { TrongridAccountNotFoundError } from '../../clients/trongrid/errors'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network } from '../../constants'; +import type { TronAccount } from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; +import type { ConfigProvider } from '../config'; +import { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; import type { NativeCaipAssetType, TokenCaipAssetType } from './types'; @@ -58,14 +71,74 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ (global as any).snap = {}; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { configProvider } = require('../../context'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { CoreAssetsAdapter } = require('./adapters/CoreAssetsAdapter'); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { SnapAssetsAdapter } = require('./adapters/SnapAssetsAdapter'); + +const TRON_FLAG_KEY = SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron; + +function createMessengerCallMock( + getState: () => unknown, + getAccountAssetByID: jest.Mock, + getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), +): CoreMessengerCaller['call'] { + return async (actionType, ...args) => { + switch (actionType) { + case 'RemoteFeatureFlagController:getState': + return getState() as Awaited>; + case 'AssetsController:getAccountAssetByID': + return getAccountAssetByID(...args); + case 'AssetsController:getAccountAssetsByIDs': + return getAccountAssetsByIDs(...args); + case 'AssetsController:getAccountAssetsByScope': + return getAccountAssetsByScope(...args); + default: + return undefined; + } + }; +} + +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { + symbol: string; + name: string; + decimals: number; + image?: string; + }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.name, + decimals: metadata.decimals, + image: metadata.image, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + +/** + * Builds a SpotPrices map for test mocks. + * + * @param entries - Map of asset ID to price info. + * @returns SpotPrices object. + */ +const createSpotPrices = ( + entries: Record, +): SpotPrices => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + { id: value.id, price: value.price }, + ]), + ); const mockAccount: KeyringAccount = { id: 'test-account-id', @@ -88,22 +161,6 @@ const emptyAccountResources: AccountResources = { TotalEnergyWeight: 0, }; -/** - * Creates properly typed SpotPrices for tests. - * - * @param entries - Map of asset ID to price info. - * @returns SpotPrices object. - */ -const createSpotPrices = ( - entries: Record, -): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - /** * Creates a properly typed TronAccount for tests. * Uses snake_case property names to match Tron API response format. @@ -208,6 +265,8 @@ type WithAssetsServiceCallback = (payload: { >; mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; + mockCoreMessenger: jest.Mocked; + setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; /** @@ -280,28 +339,72 @@ async function withAssetsService( trackError: jest.fn().mockResolvedValue(undefined), }; + const mockGetAccountAssetByID = jest.fn(); + const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); + const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); + let migrationStage = SnapsAssetsMigrationStage.Off; + const mockCoreMessenger: jest.Mocked = { + call: jest.fn().mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { stage: migrationStage }, + }, + }), + mockGetAccountAssetByID, + mockGetAccountAssetsByIDs, + mockGetAccountAssetsByScope, + ), + ), + }; + + const setMigrationStage = (stage: SnapsAssetsMigrationStage): void => { + migrationStage = stage; + }; + + const assetsProvider = new AssetsProvider({ + messenger: mockCoreMessenger as never, + }); + const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: mockCoreMessenger as never, + }); + + const mockConfigProvider: jest.Mocked> = { + get: jest.fn().mockReturnValue({ + priceApi: { + cacheTtlsMilliseconds: { + fiatExchangeRates: 3600000, + spotPrices: 3600000, + historicalPrices: 3600000, + }, + }, + activeNetworks: [], + }), + }; + const snapAdapter = new SnapAssetsAdapter({ logger: mockLogger, - assetsRepository: mockAssetsRepository, - state: mockState, - trongridApiClient: mockTrongridApiClient, - tronHttpClient: mockTronHttpClient, - priceApiClient: mockPriceApiClient, - tokenApiClient: mockTokenApiClient, - snapClient: mockSnapClient, - configProvider, + assetsRepository: mockAssetsRepository as never, + state: mockState as never, + trongridApiClient: mockTrongridApiClient as never, + tronHttpClient: mockTronHttpClient as never, + priceApiClient: mockPriceApiClient as never, + tokenApiClient: mockTokenApiClient as never, + snapClient: mockSnapClient as never, + configProvider: mockConfigProvider as never, }); const coreAdapter = new CoreAssetsAdapter({ logger: mockLogger, - assetsProvider: { - getAccountAssetByID: jest.fn().mockResolvedValue(null), - getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), - getAccountAssetsByScope: jest.fn().mockResolvedValue({}), - }, - trongridApiClient: mockTrongridApiClient, - tronHttpClient: mockTronHttpClient, + assetsProvider, + trongridApiClient: mockTrongridApiClient as never, + tronHttpClient: mockTronHttpClient as never, + }); + + const assetsService = new AssetsService({ + snapAdapter, + coreAdapter, + remoteFeatureFlagsProvider, }); - const assetsService = new AssetsService({ snapAdapter, coreAdapter }); return await testFunction({ assetsService, @@ -312,6 +415,8 @@ async function withAssetsService( mockPriceApiClient, mockTokenApiClient, mockSnapClient, + mockCoreMessenger, + setMigrationStage, }); } @@ -363,10 +468,8 @@ describe('AssetsService', () => { expect(trxAsset).toBeDefined(); expect(trxAsset?.rawAmount).toBe('0'); - const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const trc20Asset = assets.find( - (asset: AssetEntity) => - asset.assetType === expectedTrc20AssetType, + (asset: AssetEntity) => asset.assetType === trc20AssetId, ); expect(trc20Asset).toBeDefined(); expect(trc20Asset?.rawAmount).toBe('24249143'); @@ -374,7 +477,7 @@ describe('AssetsService', () => { ); }); - it('returns zero TRX and resources when fallback also returns empty', async () => { + it('returns protocol resources when inactive account has empty resources', async () => { await withAssetsService( async ({ assetsService, @@ -396,17 +499,6 @@ describe('AssetsService', () => { mockAccount, ); - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - const bandwidthAsset = assets.find( (asset: AssetEntity) => asset.assetType === KnownCaip19Id.BandwidthMainnet, @@ -421,75 +513,12 @@ describe('AssetsService', () => { ); }); - it('gracefully handles fallback endpoint failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - new Error('Network error'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('tracks fallback endpoint errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const error = new Error('Network error'); - - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - error, - ); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - - it('filters out TRC20 tokens without price data from inactive account', async () => { + it('returns protocol assets when inactive account info fails', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -497,20 +526,8 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); - - const trc20BalancesWithSpam: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price - { TSpamToken123456789: '1000000000' }, // Spam token - no price - ]; mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam, - ); - - const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, - }), + [], ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -518,30 +535,24 @@ describe('AssetsService', () => { mockAccount, ); - const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const usdtAsset = assets.find( - (asset: AssetEntity) => asset.assetType === usdtAssetType, - ); - expect(usdtAsset).toBeDefined(); - - const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; - const spamAsset = assets.find( - (asset: AssetEntity) => asset.assetType === spamAssetType, - ); - expect(spamAsset).toBeUndefined(); + expect(assets.length).toBeGreaterThan(0); + expect( + assets.some((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); }, ); }); }); describe('partial failure handling', () => { - it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { + it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { await withAssetsService( async ({ assetsService, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -552,19 +563,8 @@ describe('AssetsService', () => { NetLimit: 0, EnergyLimit: 0, }); - - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), + [], ); const assets = await assetsService.fetchAssetsAndBalancesForAccount( @@ -574,20 +574,19 @@ describe('AssetsService', () => { expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); + ).toHaveBeenCalled(); + expect( + assets.some((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); - const trxAsset = assets.find( + const bandwidthAsset = assets.find( (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, + asset.assetType === KnownCaip19Id.BandwidthMainnet, ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('100000'); + expect(bandwidthAsset).toBeDefined(); + expect(bandwidthAsset?.rawAmount).toBe('600'); }, ); }); @@ -615,12 +614,12 @@ describe('AssetsService', () => { mockAccount, ); - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('1000000'); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); const bandwidthAsset = assets.find( (asset: AssetEntity) => @@ -631,38 +630,6 @@ describe('AssetsService', () => { }, ); }); - - it('tracks spot price errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - const error = new Error('Spot price endpoint unavailable'); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockPriceApiClient.getMultipleSpotPrices.mockRejectedValue(error); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); }); describe('bandwidth', () => { @@ -1551,7 +1518,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, ]), @@ -1596,7 +1562,6 @@ describe('AssetsService', () => { await assetsService.saveMany(assets); - expect(await assetsService.getAll()).toStrictEqual(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), KeyringEvent.AccountAssetListUpdated, @@ -1667,9 +1632,6 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - // If an asset is missing from the received list - // - emits the event 'notify:accountAssetListUpdated' with the asset in the 'removed' property - // - emits the event 'notify:accountBalancesUpdated' with the balance for the removed asset sets to 0 await assetsService.saveMany(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( @@ -1758,7 +1720,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.MaximumEnergyMainnet, KnownCaip19Id.MaximumBandwidthMainnet, ]), @@ -1819,7 +1780,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), @@ -1870,7 +1830,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxReadyForWithdrawalMainnet, ]), removed: [], @@ -1948,7 +1907,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, ]), removed: [], @@ -2025,7 +1983,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.BandwidthMainnet, ]), removed: [], @@ -2223,10 +2180,8 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.EnergyMainnet, KnownCaip19Id.BandwidthMainnet, - trc20AssetId, ]), removed: [], }, @@ -2302,7 +2257,6 @@ describe('AssetsService', () => { assets: { [mockAccount.id]: { added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxStakedForEnergyMainnet, ]), removed: [], @@ -2864,8 +2818,259 @@ describe('AssetsService', () => { }); }); + describe('getAssetsMetadata', () => { + it('resolves metadata for native, protocol, and token asset types', async () => { + await withAssetsService(async ({ assetsService, mockTokenApiClient }) => { + const trc20 = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; + const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; + + mockTokenApiClient.getTokensMetadata.mockResolvedValue({ + [trc20]: { + fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, + }, + [trc10]: { + fungible: { symbol: 'T', name: 'Token', decimals: 0 }, + }, + } as never); + + const assetTypes = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + trc10, + trc20, + ]; + + const metadata = await assetsService.getAssetsMetadata(assetTypes); + + expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); + expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); + expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ + trc10, + trc20, + ]); + }); + }); + }); + + describe('assets migration', () => { + const accountId = mockAccount.id; + const fungibleAssetId = KnownCaip19Id.TrxMainnet; + const activeMigrationStage = + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; + + it('routes getAccountAssetByID through AssetsController when migration is active', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn().mockResolvedValue( + buildControllerAsset(fungibleAssetId, '2000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + ), + ), + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + fungibleAssetId, + ); + + expect(asset).toMatchObject({ + assetType: fungibleAssetId, + rawAmount: '2000000', + uiAmount: '2', + }); + }); + }); + + it('routes getAccountAssetsByIDs through AssetsController when migration is active', async () => { + await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { + const trx = KnownCaip19Id.TrxMainnet; + const usdt = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { + symbol: 'USDT', + name: 'Tether', + decimals: 6, + }), + }; + }), + ), + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + trx, + usdt, + ]); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [trx, usdt], + ); + expect(results[0]?.rawAmount).toBe('1000000'); + expect(results[1]?.rawAmount).toBe('500000'); + }); + }); + + it('routes getAccountAssets through AssetsController when migration is active', async () => { + await withAssetsService( + async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { + setMigrationStage(activeMigrationStage); + + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + () => ({ + remoteFeatureFlags: { + [TRON_FLAG_KEY]: { + stage: activeMigrationStage, + }, + }, + }), + jest.fn(), + jest.fn(), + jest.fn().mockResolvedValue({ + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '2000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + }), + ), + ); + + const assets = await assetsService.getAccountAssets(accountId); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Mainnet, + ); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Nile, + ); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByScope', + accountId, + Network.Shasta, + ); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(true); + }, + ); + }); + + it('emits only snap-owned assets and does not persist when migration is active', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + setMigrationStage, + }) => { + setMigrationStage(activeMigrationStage); + + const specialAsset: AssetEntity = { + assetType: KnownCaip19Id.BandwidthMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'BANDWIDTH', + decimals: 0, + rawAmount: '600', + uiAmount: '600', + iconUrl: '', + }; + const fungibleAsset: AssetEntity = { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }; + + await assetsService.saveMany([specialAsset, fungibleAsset]); + + expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: [KnownCaip19Id.BandwidthMainnet], + removed: [], + }, + }, + }, + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: { + [KnownCaip19Id.BandwidthMainnet]: { + unit: 'BANDWIDTH', + amount: '600', + }, + }, + }, + }, + ); + }, + ); + }); + }); + describe('facade delegation', () => { - it('delegates repository reads and market helpers to SnapAssetsAdapter', async () => { + it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { await withAssetsService( async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { const asset: AssetEntity = { @@ -2879,13 +3084,9 @@ describe('AssetsService', () => { uiAmount: '1', }; - mockAssetsRepository.getByAccountId.mockResolvedValue([asset]); mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ asset, ]); - mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( - asset, - ); mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( MOCK_EXCHANGE_RATES, ); @@ -2902,37 +3103,17 @@ describe('AssetsService', () => { expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); expect(AssetsService.hasChanged(asset, [])).toBe(true); expect(AssetsService.hasChanged(asset, [asset])).toBe(false); - - const accountAssets = await assetsService.getAccountAssets( - mockAccount.id, - ); expect( - accountAssets.some( - (savedAsset: AssetEntity) => - savedAsset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); + await assetsService.getAccountAssetsByIDs(mockAccount.id, []), + ).toStrictEqual([]); expect( - await assetsService.getAccountAssetsByIDs(mockAccount.id, [ - KnownCaip19Id.TrxMainnet, + await assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, ]), - ).toStrictEqual([asset]); - expect( - await assetsService.getAccountAssetByID( - mockAccount.id, - KnownCaip19Id.TrxMainnet, - ), - ).toStrictEqual(asset); - const marketData = await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]); - expect(marketData[KnownCaip19Id.TrxMainnet]).toBeDefined(); - expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( - 3600000, - ); + ).toBeDefined(); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 9af372ec6..2e3c80a48 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,4 +1,10 @@ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, @@ -13,31 +19,45 @@ import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** - * Assets domain facade. Currently delegates all behavior to SnapAssetsAdapter - * (legacy snap-owned reads/writes). Core adapter is initialized for upcoming - * routing without changing callers. + * Assets domain facade. Reads use the Snap adapter while migration is off, and + * the Core adapter once migration is active. Fetch always uses the Snap adapter. + * When migration is active, save routes snap-owned assets through Core (emit-only, + * no local persistence). */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; - // Initialized for upcoming Core routing; not read until the migration PR lands. - // eslint-disable-next-line no-unused-private-class-members -- reserved adapter slot readonly #coreAdapter: CoreAssetsAdapter; + readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; constructor({ snapAdapter, coreAdapter, + remoteFeatureFlagsProvider, }: { snapAdapter: SnapAssetsAdapter; coreAdapter: CoreAssetsAdapter; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; }) { this.#snapAdapter = snapAdapter; this.#coreAdapter = coreAdapter; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; } + async #shouldReturnAssetsFromCore(): Promise { + const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron, + ); + return ( + parseSnapsAssetsMigrationStage(flagValue) !== + SnapsAssetsMigrationStage.Off + ); + } + static isFiat(caipAssetId: CaipAssetType): boolean { return SnapAssetsAdapter.isFiat(caipAssetId); } @@ -46,22 +66,30 @@ export class AssetsService { return SnapAssetsAdapter.hasChanged(asset, assetsLookup); } - async getAccountAssets(accountId: string): Promise { - return this.#snapAdapter.getAccountAssets(accountId); - } - async getAccountAssetsByIDs( accountId: string, - assetTypes: string[], + assetIds: string[], ): Promise<(AssetEntity | null)[]> { - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetTypes); + if (assetIds.length === 0) { + return []; + } + + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetsByIDs(accountId, assetIds); + } + + return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); } async getAccountAssetByID( accountId: string, - assetType: string, + assetId: string, ): Promise { - return this.#snapAdapter.getAccountAssetByID(accountId, assetType); + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssetByID(accountId, assetId); + } + + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } async fetchAssetsAndBalancesForAccount( @@ -78,6 +106,10 @@ export class AssetsService { } async saveMany(assets: AssetEntity[]): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.saveMany(assets); + } + return this.#snapAdapter.saveMany(assets); } @@ -85,6 +117,14 @@ export class AssetsService { return this.#snapAdapter.getAll(); } + async getAccountAssets(accountId: string): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssets(accountId); + } + + return this.#snapAdapter.getAccountAssets(accountId); + } + async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< From fa7f893ffb95299b8e09a85b22661c64e51247ab Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:14:57 +0100 Subject: [PATCH 13/16] chore(tron-wallet-snap): fix Unreleased changelog formatting --- packages/tron-wallet-snap/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 448110e25..4e62ad4e7 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.1.0] - ### Added - Add Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) From 48860e3807a60866f9caf2a54053031503ffc646 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 15:17:55 +0100 Subject: [PATCH 14/16] feat(tron-wallet-snap): route snap-owned fetch through Core when migration is on When the Tron assets migration flag is active, fetchAssetsAndBalancesForAccount uses CoreAssetsAdapter so only snap-owned assets are fetched on-chain. --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- .../src/services/assets/AssetsService.test.ts | 53 +++++++++++++++++++ .../src/services/assets/AssetsService.ts | 12 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 4e62ad4e7..8f4307e68 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Route fungible asset reads through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) +- Route fungible asset reads and snap-owned fetch/save through Core AssetsController when the Tron assets migration flag is active ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) - Add `CoreAssetsAdapter` and `mapControllerAsset` for AssetsController integration (wired unused until routing lands) ([#144](https://github.com/MetaMask/internal-snaps/pull/144)) ### Changed 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 fe0e5bb96..8177c0cbf 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3005,6 +3005,59 @@ describe('AssetsService', () => { ); }); + it('fetches only snap-owned assets when migration is active', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + setMigrationStage, + }) => { + setMigrationStage(activeMigrationStage); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ + address: mockAccount.address, + balance: 5_000_000, + trc20: [ + { + TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000000', + }, + ], + assetV2: [], + frozenV2: [], + unfrozenV2: [], + } as unknown as TronAccount); + mockTronHttpClient.getAccountResources.mockResolvedValue({ + ...emptyAccountResources, + freeNetLimit: 600, + EnergyLimit: 1000, + }); + mockTronHttpClient.getReward.mockResolvedValue(0); + + const assets = await assetsService.fetchAssetsAndBalancesForAccount( + Network.Mainnet, + mockAccount, + ); + + expect( + mockTrongridApiClient.getTrc20BalancesByAddress, + ).not.toHaveBeenCalled(); + expect(assets.length).toBeGreaterThan(0); + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); + }, + ); + }); + it('emits only snap-owned assets and does not persist when migration is active', async () => { await withAssetsService( async ({ diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 2e3c80a48..10d207815 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -19,10 +19,10 @@ import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; /** - * Assets domain facade. Reads use the Snap adapter while migration is off, and - * the Core adapter once migration is active. Fetch always uses the Snap adapter. - * When migration is active, save routes snap-owned assets through Core (emit-only, - * no local persistence). + * Assets domain facade. Reads and snap-owned fetch/save use the Snap adapter + * while migration is off, and the Core adapter once migration is active. When + * migration is active, fetch returns only snap-owned assets and save publishes + * them via keyring events without local persistence. */ export class AssetsService { readonly #snapAdapter: SnapAssetsAdapter; @@ -96,6 +96,10 @@ export class AssetsService { scope: Network, account: KeyringAccount, ): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); + } + return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } From e73dd2e4376c6adaa552ccb8f94d992a89d55e18 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:35:00 +0100 Subject: [PATCH 15/16] fix(tron-wallet-snap): sync manifest shasum and format tests after rebase --- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/services/assets/AssetsService.test.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index f7baff20d..d065dc6d8 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": "3Mn3cc4eA7OmuOAA4zs+kMMQGCj1gs982z50E37I+pc=", + "shasum": "G97+MP+S8ey/gHxuZYgUnsfDLaoDsLLTqzd0P8Xyu0E=", "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 8177c0cbf..5eb93c404 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3060,11 +3060,7 @@ describe('AssetsService', () => { it('emits only snap-owned assets and does not persist when migration is active', async () => { await withAssetsService( - async ({ - assetsService, - mockAssetsRepository, - setMigrationStage, - }) => { + async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { setMigrationStage(activeMigrationStage); const specialAsset: AssetEntity = { From c0bae22ac5a8062921e551020047cec37963da1a Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 10 Aug 2026 16:39:23 +0100 Subject: [PATCH 16/16] fix(tron-wallet-snap): sync manifest shasum for CI Linux build --- 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 d065dc6d8..9e0fdb637 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": "G97+MP+S8ey/gHxuZYgUnsfDLaoDsLLTqzd0P8Xyu0E=", + "shasum": "/+UBD1D/nG2/4YyYITJKLK8Nc6DcA979PExNHNV9czY=", "location": { "npm": { "filePath": "dist/bundle.js",