From 3be3d31e3d8a61eaff44b12de8c5f948558c6335 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Tue, 1 Sep 2026 13:21:27 +0100 Subject: [PATCH] refactor(tron-wallet-snap): remove snap-owned asset abstractions --- eslint-suppressions.json | 10 + packages/tron-wallet-snap/CHANGELOG.md | 54 +- packages/tron-wallet-snap/snap.manifest.json | 24 +- packages/tron-wallet-snap/src/context.ts | 68 +- .../src/handlers/assets/assets.ts | 18 +- .../handlers/clientRequest/clientRequest.ts | 53 +- .../src/handlers/keyring/keyring.ts | 48 +- .../services/accounts/AccountsService.test.ts | 621 ++-- .../src/services/accounts/AccountsService.ts | 34 - .../src/services/assets/AssetsService.test.ts | 2548 +++++------------ .../src/services/assets/AssetsService.ts | 1033 ++++++- .../assets/adapters/SnapAssetsAdapter.ts | 1537 +++------- .../confirmation/ConfirmationHandler.ts | 20 +- .../src/services/send/SendService.ts | 32 +- .../views/ConfirmSignTransaction/render.tsx | 10 +- 15 files changed, 2522 insertions(+), 3588 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 380ef1dae..5b077f315 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1662,6 +1662,16 @@ "count": 2 } }, + "packages/tron-wallet-snap/src/services/assets/AssetsRepository.ts": { + "import-x/no-extraneous-dependencies": { + "count": 1 + } + }, + "packages/tron-wallet-snap/src/services/assets/AssetsService.ts": { + "import-x/no-extraneous-dependencies": { + "count": 2 + } + }, "packages/tron-wallet-snap/src/services/assets/AssetsService.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 29b0b54d0..c8d1f0a9e 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -7,53 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- **BREAKING** Bump `@metamask/keyring-api` from `^23.7.0` to `^24.1.0` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) -- **BREAKING** Bump `@metamask/keyring-snap-sdk` from `^9.2.1` to `^10.0.0` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) -- **BREAKING** Bump `@metamask/snaps-sdk` from `^11.2.0` to `^12.0.1` ([#214](https://github.com/MetaMask/internal-snaps/pull/214)) - -## [3.2.0] - ### Added -- Use Core's `AssetsController` as the source of truth for fungible assets when the Tron assets migration feature flag is enabled ([#145](https://github.com/MetaMask/internal-snaps/pull/145)) +- **BREAKING** Implement Keyring API v2 (`KeyringSnapRpc` interface): rename `listAccounts` → `getAccounts`, `listAccountAssets` → `getAccountAssets`, `listAccountTransactions` → `getAccountTransactions`; `getAccount` now throws instead of returning `undefined`; add `exportAccount` with hexadecimal private key export using `sensitive()` for redaction; remove v1-only methods `createAccount`, `discoverAccounts`, `filterAccountChains`, and `updateAccount`. ([#56](https://github.com/MetaMask/internal-snaps/pull/56)) +- Add `bip44:discover` support to `createAccounts`: checks on-chain activity across all Tron networks before persisting; returns `[]` if no activity to signal end-of-discovery to the client. ([#56](https://github.com/MetaMask/internal-snaps/pull/56)) +- Add `endowment:keyring` capabilities to manifest declaring the `tron:728126428` scope, hexadecimal private key export, and BIP-44 derivation strategies. ([#56](https://github.com/MetaMask/internal-snaps/pull/56)) +- Wire Core messenger endowment and instantiate `RemoteFeatureFlagsProvider` and `AssetsProvider` from `@metamask/snap-networks-utils` v1.0.0 (plumbing only; no Core routing yet). +- Route fungible asset reads through the shared `AssetsProvider` from `@metamask/snap-networks-utils` using account-scoped `AssetsController:getAccountAssetByID`, `AssetsController:getAccountAssetsByIDs`, and `AssetsController:getAccountAssetsByScope` actions based on migration stage (TRX, TRC10, TRC20). Protocol assets (energy, bandwidth, staking, lock/withdrawal, rewards) remain Snap-owned. Resolution order: remote feature flags → Off default. ### Changed -- Reduce BIP-44 account discovery to a single entropy fetch by reusing the coin-type deriver for the on-chain activity check ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) -- Reduce extension RPC round trips in `keyring_createAccounts` from 5 to at most 4 ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) - - `mergeKeyringAccounts` now returns the merge result instead of requiring a post-merge state re-read, and the existing-accounts read runs in parallel with the BIP-32 entropy fetch. - - `snap_getBip32Entropy` is now called even when all requested indices already exist (this path only occurs on idempotent retries); no new permissions are required. - -### Fixed - -- Fix account deletion failing against keyring v2 clients by removing the `AccountDeleted` event emission from `keyring_deleteAccount` ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) - - v2 clients reject v1 lifecycle events, which aborted the deletion before the account was removed from state. Deletion is client-initiated in v2, so no event is needed. -- Coalesce concurrent account synchronization runs for the same accounts so stacked triggers (cronjob and background events) share one run instead of duplicating network fetches, state writes, and keyring events ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) -- Bump `@metamask/utils` from `^11.9.0` to `^11.11.9` ([#161](https://github.com/MetaMask/internal-snaps/pull/161)) -- Estimate native TRX/TRC-10 sends that activate a new account as 1 TRX plus 100 Bandwidth (or 0.1 TRX when staked Bandwidth is insufficient), instead of TransferContract byte size ([#175](https://github.com/MetaMask/internal-snaps/pull/175)) -- Fix SUN → USDT swaps routed through Rango and SunSwap displaying a zero SUN amount in transaction activity ([#134](https://github.com/MetaMask/internal-snaps/pull/134)) - -## [3.1.0] - -### Added - -- Add Core messenger plumbing (`coreMessenger`, `RemoteFeatureFlagsProvider`, `AssetsProvider`) for upcoming AssetsController migration ([#95](https://github.com/MetaMask/internal-snaps/pull/95)) - -### Fixed - -- Scope `bip44:discover` activity checks and account creation to the networks declared in the snap manifest, preventing unnecessary calls to testnet APIs during discovery ([#135](https://github.com/MetaMask/internal-snaps/pull/135)) - -## [3.0.0] - -### Added - -- **BREAKING** Implement Keyring API v2 (`KeyringSnapRpc` interface) ([#56](https://github.com/MetaMask/internal-snaps/pull/56), [#101](https://github.com/MetaMask/internal-snaps/pull/101), [#105](https://github.com/MetaMask/internal-snaps/pull/105)) +- Move assets handler logic (metadata, conversions, market data, historical prices) into `AssetsService`; slim `SnapAssetsAdapter` to snap-owned fetch/save/read only. Cron asset sync uses `syncSnapOwnedAssets` for protocol assets. +- Update `snap.manifest.json` bundle shasum ([#82](https://github.com/MetaMask/internal-snaps/pull/82)) -### Fixed +### Removed -- Disclose the mandatory 9999 TRX `WitnessCreateContract` account-upgrade burn on confirmation ([#73](https://github.com/MetaMask/internal-snaps/pull/73)) +- Assets migration feature-flag routing. Fungible reads (`getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`) now always use Core `AssetsController` via `AssetsProvider`; snap-owned protocol assets remain on the Snap adapter. Removed `RemoteFeatureFlagController:getState` messenger endowment. ## [2.0.0] @@ -67,8 +36,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/snaps-sdk` from `^11.1.1` to `^11.2.0` ([#43](https://github.com/MetaMask/internal-snaps/pull/43)) - Bump `@metamask/superstruct` from `^3.2.1` to `^3.4.1` ([#43](https://github.com/MetaMask/internal-snaps/pull/43)) -[Unreleased]: https://github.com/MetaMask/internal-snaps/compare/@metamask/tron-wallet-snap@3.2.0...HEAD -[3.2.0]: https://github.com/MetaMask/internal-snaps/compare/@metamask/tron-wallet-snap@3.1.0...@metamask/tron-wallet-snap@3.2.0 -[3.1.0]: https://github.com/MetaMask/internal-snaps/compare/@metamask/tron-wallet-snap@3.0.0...@metamask/tron-wallet-snap@3.1.0 -[3.0.0]: https://github.com/MetaMask/internal-snaps/compare/@metamask/tron-wallet-snap@2.0.0...@metamask/tron-wallet-snap@3.0.0 +[Unreleased]: https://github.com/MetaMask/internal-snaps/compare/@metamask/tron-wallet-snap@2.0.0...HEAD [2.0.0]: https://github.com/MetaMask/internal-snaps/releases/tag/@metamask/tron-wallet-snap@2.0.0 diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index b7aac060f..b3528eca6 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "3.2.0", + "version": "2.0.0", "description": "Manage Tron using MetaMask", "proposedName": "Tron", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "QnhXo3QTBC3XgD/9FKIEjyoSnVdVtZ3uGansykUe9uc=", + "shasum": "ac/9ewd8ZZXTLl5K5ZMg5PSsUeITzUwYtvfHPNjKQu4=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -23,22 +23,7 @@ }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"], - "capabilities": { - "scopes": ["tron:728126428"], - "privateKey": { - "exportFormats": [ - { - "encoding": "hexadecimal" - } - ] - }, - "bip44": { - "deriveIndex": true, - "deriveIndexRange": true, - "discover": true - } - } + "allowedOrigins": ["https://portfolio.metamask.io"] }, "snap_getBip32Entropy": [ { @@ -66,13 +51,12 @@ }, "endowment:messenger": { "actions": [ - "RemoteFeatureFlagController:getState", "AssetsController:getAccountAssetByID", "AssetsController:getAccountAssetsByIDs", "AssetsController:getAccountAssetsByScope" ] } }, - "platformVersion": "12.0.1", + "platformVersion": "11.2.0", "manifestVersion": "0.1" } diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 700d35105..96bec72e1 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -1,11 +1,5 @@ -import { - AssetsProvider, - RemoteFeatureFlagsProvider, -} from '@metamask/snap-networks-utils'; -import type { - AssetsProviderMessenger, - RemoteFeatureFlagsProviderMessenger, -} from '@metamask/snap-networks-utils'; +import { AssetsProvider } from '@metamask/snap-networks-utils'; +import type { AssetsProviderMessenger } from '@metamask/snap-networks-utils'; import { getMessenger } from '@metamask/snaps-sdk'; import { InMemoryCache } from './caching/InMemoryCache'; @@ -13,7 +7,6 @@ import { StateCache } from './caching/StateCache'; import { PriceApiClient } from './clients/price-api/PriceApiClient'; import { SecurityAlertsApiClient } from './clients/security-alerts-api/SecurityAlertsApiClient'; import { SnapClient } from './clients/snap/SnapClient'; -import { TokenApiClient } from './clients/token-api/TokenApiClient'; import { TronHttpClient } from './clients/tron-http/TronHttpClient'; import { TrongridApiClient } from './clients/trongrid/TrongridApiClient'; import { TronWebFactory } from './clients/tronweb/TronWebFactory'; @@ -27,8 +20,6 @@ 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'; import { ConfigProvider } from './services/config'; import { ConfirmationHandler } from './services/confirmation/ConfirmationHandler'; import { FeeCalculatorService } from './services/send/FeeCalculatorService'; @@ -52,8 +43,8 @@ import logger, { noOpLogger } from './utils/logger'; * * Dependency injection order: * 1. Core services (ConfigProvider, State, Connection) - * 2. Repositories (AssetsRepository, TransactionsRepository, AccountsRepository) - * 3. Business services (AssetsService, TransactionsService, AccountsService) + * 2. Repositories (TransactionsRepository, AccountsRepository) + * 3. Business services (TransactionsService, AccountsService) * 4. Handlers (AssetsHandler, CronHandler, KeyringHandler, RpcHandler, UserInputHandler) */ export const configProvider = new ConfigProvider(); @@ -62,7 +53,6 @@ const state = new State({ encrypted: false, defaultState: { keyringAccounts: {}, - assets: {}, tokenPrices: {}, transactions: {}, mapInterfaceNameToId: {}, @@ -73,7 +63,6 @@ const snapClient = new SnapClient({ logger }); // Repositories - depend on State const accountsRepository = new AccountsRepository(state); -const assetsRepository = new AssetsRepository(state); const transactionsRepository = new TransactionsRepository(state); // Clients @@ -95,16 +84,10 @@ const tronWebFactory = new TronWebFactory({ const priceCache = new InMemoryCache(noOpLogger); const priceApiClient = new PriceApiClient(configProvider, priceCache); -// Token API client -const tokenApiClient = new TokenApiClient(configProvider); - /** * Core controllers plumbing */ const coreMessenger = getMessenger(); -const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ - messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, -}); const assetsProvider = new AssetsProvider({ messenger: coreMessenger as AssetsProviderMessenger, }); @@ -115,36 +98,6 @@ const securityAlertsApiClient = new SecurityAlertsApiClient( logger, ); -const snapAssetsAdapter = new SnapAssetsAdapter({ - logger, - state, - assetsRepository, - trongridApiClient, - tronHttpClient, - priceApiClient, - tokenApiClient, - snapClient, - configProvider, -}); -const coreAssetsAdapter = new CoreAssetsAdapter({ - getAccountAssetByID: assetsProvider.getAccountAssetByID.bind(assetsProvider), - getAccountAssetsByIDs: - assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), - getAccountAssetsByScope: - assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - getAddressInfo: - trongridApiClient.getAccountInfoByAddress.bind(trongridApiClient), - getAddressResources: tronHttpClient.getAccountResources.bind(tronHttpClient), - getAddressStakingRewards: tronHttpClient.getReward.bind(tronHttpClient), -}); - -// Business Services -const assetsService = new AssetsService({ - snapAdapter: snapAssetsAdapter, - coreAdapter: coreAssetsAdapter, - remoteFeatureFlagsProvider, -}); - const transactionsService = new TransactionsService({ logger, transactionsRepository, @@ -159,7 +112,6 @@ const accountsService = new AccountsService({ snapClient, accountsRepository, configProvider, - assetsService, transactionsService, }); @@ -179,7 +131,7 @@ const sendService = new SendService({ logger, snapClient, accountsService, - assetsService, + assetsProvider, tronWebFactory, feeCalculatorService, transactionExpirationRefresherService, @@ -208,7 +160,7 @@ const confirmationHandler = new ConfirmationHandler({ snapClient, state, tronWebFactory, - assetsService, + assetsProvider, feeCalculatorService, logger, }); @@ -218,13 +170,13 @@ const confirmationHandler = new ConfirmationHandler({ */ const assetsHandler = new AssetsHandler({ logger, - assetsService, + assetsProvider, }); const clientRequestHandler = new ClientRequestHandler({ logger, snapClient, accountsService, - assetsService, + assetsProvider, sendService, tronWebFactory, feeCalculatorService, @@ -271,7 +223,6 @@ export type SnapExecutionContext = { state: State; priceApiClient: PriceApiClient; feeCalculatorService: FeeCalculatorService; - assetsService: AssetsService; accountsService: AccountsService; transactionsService: TransactionsService; sendService: SendService; @@ -285,7 +236,6 @@ export type SnapExecutionContext = { * Core messenger plumbing. */ coreMessenger: CoreMessengerClient; - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; assetsProvider: AssetsProvider; /** * Handlers @@ -309,7 +259,6 @@ const snapContext: SnapExecutionContext = { state, priceApiClient, feeCalculatorService, - assetsService, accountsService, transactionsService, sendService, @@ -320,7 +269,6 @@ const snapContext: SnapExecutionContext = { transactionScanService, transactionExpirationRefresherService, coreMessenger, - remoteFeatureFlagsProvider, assetsProvider, /** * Handlers diff --git a/packages/tron-wallet-snap/src/handlers/assets/assets.ts b/packages/tron-wallet-snap/src/handlers/assets/assets.ts index 5a35db6c2..5f944a4d4 100644 --- a/packages/tron-wallet-snap/src/handlers/assets/assets.ts +++ b/packages/tron-wallet-snap/src/handlers/assets/assets.ts @@ -10,22 +10,22 @@ import type { OnAssetsMarketDataResponse, } from '@metamask/snaps-sdk'; -import type { AssetsService } from '../../services/assets/AssetsService'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; export class AssetsHandler { readonly #logger: Logger; - readonly #assetsService: AssetsService; + readonly #priceApiClient: PriceApiClient; constructor({ logger, - assetsService, + priceApiClient, }: { logger: Logger; - assetsService: AssetsService; + priceApiClient: PriceApiClient; }) { this.#logger = logger.withPrefix('[🪙 AssetsHandler]'); - this.#assetsService = assetsService; + this.#priceApiClient = priceApiClient; } async onAssetHistoricalPrice( @@ -35,7 +35,7 @@ export class AssetsHandler { const { from, to } = params; - const historicalPrice = await this.#assetsService.getHistoricalPrice( + const historicalPrice = await this.#priceApiClient.getHistoricalPrice( from, to, ); @@ -53,7 +53,7 @@ export class AssetsHandler { const { conversions } = params; const conversionRates = - await this.#assetsService.getMultipleTokenConversions(conversions); + await this.#priceApiClient.getMultipleTokenConversions(conversions); return { conversionRates, @@ -63,7 +63,7 @@ export class AssetsHandler { async onAssetsLookup( params: OnAssetsLookupArguments, ): Promise { - const assets = await this.#assetsService.getAssetsMetadata(params.assets); + const assets = await this.#priceApiClient.getAssetsMetadata(params.assets); return { assets }; } @@ -71,7 +71,7 @@ export class AssetsHandler { async onAssetsMarketData( params: OnAssetsMarketDataArguments, ): Promise { - const marketData = await this.#assetsService.getMultipleTokensMarketData( + const marketData = await this.#priceApiClient.getMultipleTokensMarketData( params.assets, ); diff --git a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts index 775ed93a8..abdb5dcd0 100644 --- a/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/tron-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -1,5 +1,5 @@ import { TransactionStatus } from '@metamask/keyring-api'; -import type { Logger } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { InvalidParamsError, @@ -27,7 +27,6 @@ import { ZERO, } from '../../constants'; import type { AccountsService } from '../../services/accounts/AccountsService'; -import type { AssetsService } from '../../services/assets/AssetsService'; import type { NativeCaipAssetType, StakedCaipAssetType, @@ -77,7 +76,7 @@ export class ClientRequestHandler { readonly #accountsService: AccountsService; - readonly #assetsService: AssetsService; + readonly #assetsProvider: AssetsProvider; readonly #sendService: SendService; @@ -98,7 +97,7 @@ export class ClientRequestHandler { constructor({ logger, accountsService, - assetsService, + assetsProvider, sendService, feeCalculatorService, tronWebFactory, @@ -110,7 +109,7 @@ export class ClientRequestHandler { }: { logger: Logger; accountsService: AccountsService; - assetsService: AssetsService; + assetsProvider: AssetsProvider; sendService: SendService; feeCalculatorService: FeeCalculatorService; tronWebFactory: TronWebFactory; @@ -122,7 +121,7 @@ export class ClientRequestHandler { }) { this.#logger = logger.withPrefix('[👋 ClientRequestHandler]'); this.#accountsService = accountsService; - this.#assetsService = assetsService; + this.#assetsProvider = assetsProvider; this.#sendService = sendService; this.#feeCalculatorService = feeCalculatorService; this.#tronWebFactory = tronWebFactory; @@ -369,7 +368,7 @@ export class ClientRequestHandler { const scope = chainId as Network; const [asset, nativeTokenAsset, bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(accountId, [ + await this.#assetsProvider.getAccountAssetsByIDs(accountId, [ assetId, Networks[scope].nativeToken.id, Networks[scope].bandwidth.id, @@ -377,15 +376,17 @@ export class ClientRequestHandler { ]); const valueBN = new BigNumber(value); - const assetToSendBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const assetToSendBalance = asset + ? new BigNumber(asset.balance.amount) + : ZERO; const nativeTokenBalance = nativeTokenAsset - ? new BigNumber(nativeTokenAsset.uiAmount) + ? new BigNumber(nativeTokenAsset.balance.amount) : ZERO; const bandwidthBalance = bandwidthAsset - ? new BigNumber(bandwidthAsset.uiAmount) + ? new BigNumber(bandwidthAsset.balance.amount) : ZERO; const energyBalance = energyAsset - ? new BigNumber(energyAsset.uiAmount) + ? new BigNumber(energyAsset.balance.amount) : ZERO; if (!asset || valueBN.isGreaterThan(assetToSendBalance)) { @@ -482,7 +483,7 @@ export class ClientRequestHandler { }; } - const asset = await this.#assetsService.getAccountAssetByID( + const asset = await this.#assetsProvider.getAccountAssetByID( fromAccountId, assetId, ); @@ -526,7 +527,7 @@ export class ClientRequestHandler { /** * Get available Energy and Bandwidth from account assets. */ - this.#assetsService.getAccountAssetsByIDs(fromAccountId, [ + this.#assetsProvider.getAccountAssetsByIDs(fromAccountId, [ Networks[scope].bandwidth.id, Networks[scope].energy.id, ]), @@ -672,7 +673,7 @@ export class ClientRequestHandler { * Get available Energy and Bandwidth from account assets. */ const [bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(accountId, [ + await this.#assetsProvider.getAccountAssetsByIDs(accountId, [ Networks[scope].bandwidth.id, Networks[scope].energy.id, ]); @@ -727,12 +728,14 @@ export class ClientRequestHandler { const scope = Network.Mainnet; - const asset = await this.#assetsService.getAccountAssetByID( + const asset = await this.#assetsProvider.getAccountAssetByID( fromAccountId, Networks[scope].nativeToken.id, ); - const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const accountBalance = asset + ? new BigNumber(asset.balance.amount) + : ZERO; const requestBalance = BigNumber(value); /** @@ -803,7 +806,7 @@ export class ClientRequestHandler { const { accountId, assetId, value } = request.params; await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAccountAssetByID( + const asset = await this.#assetsProvider.getAccountAssetByID( accountId, assetId, ); @@ -811,7 +814,9 @@ export class ClientRequestHandler { /** * If the account doesn't have this asset, treat it as having zero balance */ - const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const accountBalance = asset + ? new BigNumber(asset.balance.amount) + : ZERO; const requestBalance = new BigNumber(value); if (requestBalance.isGreaterThan(accountBalance)) { @@ -850,12 +855,14 @@ export class ClientRequestHandler { const account = await this.#accountsService.findByIdOrThrow(fromAccountId); - const asset = await this.#assetsService.getAccountAssetByID( + const asset = await this.#assetsProvider.getAccountAssetByID( fromAccountId, assetId, ); - const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const accountBalance = asset + ? new BigNumber(asset.balance.amount) + : ZERO; const requestBalance = new BigNumber(value); /** * Check if account has enough of the asset... @@ -912,12 +919,14 @@ export class ClientRequestHandler { const stakedAssetId = `${assetId}-staked-for-${purpose.toLowerCase()}`; await this.#accountsService.findByIdOrThrow(accountId); - const asset = await this.#assetsService.getAccountAssetByID( + const asset = await this.#assetsProvider.getAccountAssetByID( accountId, stakedAssetId, ); - const accountBalance = asset ? new BigNumber(asset.uiAmount) : ZERO; + const accountBalance = asset + ? new BigNumber(asset.balance.amount) + : ZERO; const requestBalance = new BigNumber(value); /** diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 048e63f41..70eff8142 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -15,7 +15,7 @@ import type { } from '@metamask/keyring-api/v2'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import { validateOrigin } from '@metamask/snap-networks-utils'; -import type { Logger } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError, SnapError, @@ -31,13 +31,11 @@ import type { import { sortBy } from 'lodash'; import type { SnapClient } from '../../clients/snap/SnapClient'; -import { ESSENTIAL_ASSETS } from '../../constants'; import type { Network } from '../../constants'; import { asStrictKeyringAccount } from '../../entities/keyring-account'; import type { TronKeyringAccount } from '../../entities/keyring-account'; import { originPermissions } from '../../permissions'; import type { AccountsService } from '../../services/accounts/AccountsService'; -import type { AssetsService } from '../../services/assets/AssetsService'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; import type { TransactionsService } from '../../services/transactions/TransactionsService'; import type { WalletService } from '../../services/wallet/WalletService'; @@ -67,7 +65,7 @@ export class KeyringHandler implements KeyringSnapRpc { readonly #accountsService: AccountsService; - readonly #assetsService: AssetsService; + readonly #assetsProvider: AssetsProvider; readonly #transactionsService: TransactionsService; @@ -79,7 +77,7 @@ export class KeyringHandler implements KeyringSnapRpc { logger, snapClient, accountsService, - assetsService, + assetsProvider, transactionsService, walletService, confirmationHandler, @@ -87,7 +85,7 @@ export class KeyringHandler implements KeyringSnapRpc { logger: Logger; snapClient: SnapClient; accountsService: AccountsService; - assetsService: AssetsService; + assetsProvider: AssetsProvider; transactionsService: TransactionsService; walletService: WalletService; confirmationHandler: ConfirmationHandler; @@ -95,7 +93,7 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger = logger.withPrefix('[🔑 KeyringHandler]'); this.#snapClient = snapClient; this.#accountsService = accountsService; - this.#assetsService = assetsService; + this.#assetsProvider = assetsProvider; this.#transactionsService = transactionsService; this.#walletService = walletService; this.#confirmationHandler = confirmationHandler; @@ -183,15 +181,14 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.info('Listing account assets', { accountId }); - const assetEntities = - await this.#assetsService.getAccountAssets(accountId); - const result = assetEntities - .filter( - (asset) => - ESSENTIAL_ASSETS.includes(asset.assetType) || - Number(asset.rawAmount) > 0, - ) - .map((asset) => asset.assetType); + const result = Object.values( + await this.#assetsProvider.getAccountAssetsByScope( + accountId, + Network.Mainnet, + ), + ) + .filter((asset) => Number(asset.balance.amount) > 0) + .map((asset) => asset.id); this.#logger.info('Account assets', { accountId, result }); @@ -273,22 +270,23 @@ export class KeyringHandler implements KeyringSnapRpc { await this.#getAccountOrThrow(accountId); - const assetsList = await this.#assetsService.getAccountAssets(accountId); + const assetsList = Object.values( + await this.#assetsProvider.getAccountAssetsByScope( + accountId, + Network.Mainnet, + ), + ); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) // Remove token assets with zero balance - .filter( - (asset) => - ESSENTIAL_ASSETS.includes(asset.assetType) || - Number(asset.rawAmount) > 0, - ); + .filter((asset) => Number(asset.balance.amount) > 0); const result = assetsToUse.reduce>( (acc, asset) => { - acc[asset.assetType] = { - unit: asset.symbol, - amount: asset.uiAmount, + acc[asset.id] = { + unit: asset.metadata.symbol, + amount: asset.balance.amount, }; return acc; }, diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts index cbe0fe083..967b12cb2 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -3,32 +3,42 @@ import { BIP44PurposeNodeToken, mnemonicPhraseToBytes, } from '@metamask/key-tree'; -import type { JsonBIP44Node, JsonSLIP10Node } from '@metamask/key-tree'; +import type { JsonBIP44Node } from '@metamask/key-tree'; import type { CreateAccountOptions as KeyringBatchCreateAccountOptions, Transaction, } from '@metamask/keyring-api'; -import { AccountCreationType, TrxAccountType } from '@metamask/keyring-api'; -import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; -import type { Logger } from '@metamask/snap-networks-utils'; -import { LogLevel } from '@metamask/snap-networks-utils'; +import { + AccountCreationType, + KeyringEvent, + TrxAccountType, + TrxScope, +} from '@metamask/keyring-api'; +import { + emitSnapKeyringEvent, + getSelectedAccounts, +} from '@metamask/keyring-snap-sdk'; import type { SnapClient } from '../../clients/snap/SnapClient'; import { Network } from '../../constants'; -import type { NativeAsset } from '../../entities/assets'; import type { TronKeyringAccount } from '../../entities/keyring-account'; +import type { ILogger } from '../../utils/logger'; import { mockLogger } from '../../utils/mockLogger'; import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; import type { Config } from '../config/ConfigProvider'; import type { TransactionsService } from '../transactions/TransactionsService'; import type { AccountsRepository } from './AccountsRepository'; -import { AccountsService, SUPPORTED_SCOPES } from './AccountsService'; +import { AccountsService } from './AccountsService'; jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), getSelectedAccounts: jest.fn().mockResolvedValue([]), })); +const mockedEmitSnapKeyringEvent = emitSnapKeyringEvent as jest.MockedFunction< + typeof emitSnapKeyringEvent +>; const mockedGetSelectedAccounts = getSelectedAccounts as jest.MockedFunction< typeof getSelectedAccounts >; @@ -52,7 +62,6 @@ const EMPTY_NETWORK_URLS: Record = { const MOCK_CONFIG: Config = { environment: 'test', - logLevel: LogLevel.INFO, networks: [], activeNetworks: [], priceApi: { @@ -108,10 +117,8 @@ type WithAccountsServiceCallback = (payload: { > >; mockConfigProvider: jest.Mocked>; - mockLogger: Logger; - mockAssetsService: jest.Mocked< - Pick - >; + mockLogger: ILogger; + mockAssetsService: jest.Mocked>; mockSnapClient: jest.Mocked< Pick >; @@ -204,24 +211,15 @@ async function withAccountsService( .mockImplementation( async (newAccounts: Record) => { const occupied = new Set(keyringAccounts.map(getAccountIndexKey)); - const added: Record = {}; - for (const [id, account] of Object.entries(newAccounts)) { + for (const account of Object.values(newAccounts)) { const indexKey = getAccountIndexKey(account); if (!occupied.has(indexKey)) { keyringAccounts.push(account); occupied.add(indexKey); - added[id] = account; } } - - return { - merged: Object.fromEntries( - keyringAccounts.map((account) => [account.id, account]), - ), - added, - }; }, ), delete: jest.fn().mockImplementation(async (id: string) => { @@ -267,10 +265,9 @@ async function withAccountsService( }; const mockAssetsService: jest.Mocked< - Pick + Pick > = { - fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), - saveMany: jest.fn().mockResolvedValue(undefined), + syncSnapOwnedAssets: jest.fn().mockResolvedValue(undefined), }; const mockTransactionsService: jest.Mocked< @@ -319,13 +316,65 @@ describe('AccountsService', () => { }); }); + describe('deriveAccount', () => { + it('returns TronKeyringAccount with correct structure for index 0', async () => { + await withAccountsService(async ({ accountsService, mockSnapClient }) => { + const result = await accountsService.deriveAccount({ + entropySource: 'test-entropy', + index: 0, + }); + + expect(result).toMatchObject({ + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + methods: ['signMessage', 'signTransaction'], + }); + expect(result.id).toBeDefined(); + expect(typeof result.id).toBe('string'); + expect(result.address).toBeDefined(); + expect(result.address.length).toBeGreaterThan(0); + expect(result.options.entropy).toMatchObject({ + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }); + + expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith({ + entropySource: 'test-entropy', + path: ['m', "44'", "195'", "0'", '0', '0'], + curve: 'secp256k1', + }); + }); + }); + + it('returns correct derivation path for index 5', async () => { + await withAccountsService(async ({ accountsService, mockSnapClient }) => { + const result = await accountsService.deriveAccount({ + entropySource: 'test-entropy', + index: 5, + }); + + expect(result.derivationPath).toBe("m/44'/195'/0'/0/5"); + expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith( + expect.objectContaining({ + path: ['m', "44'", "195'", "0'", '0', '5'], + }), + ); + }); + }); + }); + describe('deriveTronKeypair', () => { it('throws when getBip32Entropy returns missing key material', async () => { await withAccountsService(async ({ accountsService, mockSnapClient }) => { mockSnapClient.getBip32Entropy.mockResolvedValue({ privateKey: undefined, publicKey: undefined, - } as unknown as JsonSLIP10Node); + } as unknown as JsonBIP44Node); await expect( accountsService.deriveTronKeypair({ @@ -367,10 +416,9 @@ describe('AccountsService', () => { expect( mockAccountsRepository.findByEntropySourceAndRange, ).toHaveBeenCalledWith('test-entropy', { from: 0, to: 1 }); - // No post-merge re-read: the merge result is used instead. expect( mockAccountsRepository.findByEntropySourceAndRange, - ).toHaveBeenCalledTimes(1); + ).toHaveBeenCalledTimes(2); expect(mockAccountsRepository.getAll).not.toHaveBeenCalled(); expect( @@ -431,26 +479,16 @@ describe('AccountsService', () => { index: 0, type: TrxAccountType.Eoa, address: 'TConcurrent0', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], options: {}, methods: ['signMessage', 'signTransaction'], }; await withAccountsService( - async ({ - accountsService, - mockAccountsRepository, - mockLogger: accountsMockLogger, - }) => { - // The first read sees nothing; a concurrent writer wins the merge, - // so the winner only appears in the merge result. - mockAccountsRepository.findByEntropySourceAndRange.mockResolvedValue( - [], - ); - mockAccountsRepository.mergeKeyringAccounts.mockResolvedValue({ - merged: { [concurrentAccount.id]: concurrentAccount }, - added: {}, - }); + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.findByEntropySourceAndRange + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([concurrentAccount]); const result = await accountsService.createAccounts({ type: AccountCreationType.Bip44DeriveIndex, @@ -460,13 +498,6 @@ describe('AccountsService', () => { expect(result).toHaveLength(1); expect(result[0]?.id).toBe('concurrent-0'); - expect( - mockAccountsRepository.findByEntropySourceAndRange, - ).toHaveBeenCalledTimes(1); - expect(accountsMockLogger.log).toHaveBeenCalledWith( - '[🔑 AccountsService]', - expect.stringMatching(/"created":0/u), - ); }, coinJson, ); @@ -481,7 +512,7 @@ describe('AccountsService', () => { index: 0, type: TrxAccountType.Eoa, address: 'TExisting0', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], options: {}, methods: ['signMessage', 'signTransaction'], }; @@ -492,7 +523,7 @@ describe('AccountsService', () => { index: 1, type: TrxAccountType.Eoa, address: 'TExisting1', - scopes: SUPPORTED_SCOPES as unknown as Network[], + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], options: {}, methods: ['signMessage', 'signTransaction'], }; @@ -516,38 +547,12 @@ describe('AccountsService', () => { expect( mockAccountsRepository.mergeKeyringAccounts, ).not.toHaveBeenCalled(); - // The coin-type entropy fetch runs in parallel with the state read, - // so it happens (speculatively) even when the range already exists. - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledTimes(1); - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith({ - entropySource: 'test-entropy', - path: ['m', "44'", "195'"], - curve: 'secp256k1', - }); + expect(mockSnapClient.getBip32Entropy).not.toHaveBeenCalled(); }, coinJson, ); }); - it('logs phase timings for a batch creation', async () => { - const coinJson = await getTronTestCoinTypeJson(); - - await withAccountsService(async ({ accountsService }) => { - await accountsService.createAccounts({ - type: AccountCreationType.Bip44DeriveIndexRange, - entropySource: 'test-entropy', - range: { from: 0, to: 1 }, - }); - - expect(mockLogger.log).toHaveBeenCalledWith( - '[🔑 AccountsService]', - expect.stringMatching( - /^\[createAccounts\] Phase timings \{.*"created":2.*"readAndEntropyMs":\d+.*"deriveMs":\d+.*"mergeMs":\d+.*"totalMs":\d+.*\}$/u, - ), - ); - }, coinJson); - }); - it('throws before storage or entropy access when the range is invalid', async () => { await withAccountsService( async ({ accountsService, mockAccountsRepository, mockSnapClient }) => { @@ -627,9 +632,9 @@ describe('AccountsService', () => { mockAccountsRepository, mockTransactionsService, }) => { - mockTransactionsService.checkAddressActivity.mockResolvedValueOnce( - true, - ); + mockTransactionsService.checkAddressActivity + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); const result = await accountsService.createAccounts({ type: AccountCreationType.Bip44Discover, @@ -649,70 +654,300 @@ describe('AccountsService', () => { coinJson, ); }); + }); - it('fetches entropy once for bip44:discover, reusing the coin-type deriver for the activity check', async () => { - const coinJson = await getTronTestCoinTypeJson(); + describe('create', () => { + it('creates and persists a new account', async () => { + mockedEmitSnapKeyringEvent.mockResolvedValue(); await withAccountsService( - async ({ - accountsService, - mockSnapClient, - mockTransactionsService, - }) => { - mockTransactionsService.checkAddressActivity.mockResolvedValueOnce( - true, + async ({ accountsService, mockAccountsRepository }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'test-uuid-123', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TTestAddress1234567890123456789', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); + + const result = await accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }); + + expect(result.id).toBe('test-uuid-123'); + expect(mockAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'test-uuid-123' }), ); + expect(mockedEmitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountCreated, + expect.objectContaining({ + account: expect.objectContaining({ id: 'test-uuid-123' }), + }), + ); + }, + ); + }); - const result = await accountsService.createAccounts({ - type: AccountCreationType.Bip44Discover, + it('uses default entropy source and lowest unused index when options are omitted', async () => { + mockedEmitSnapKeyringEvent.mockResolvedValue(); + + const existingAccount: TronKeyringAccount = { + id: 'existing-default-0', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TExistingDefault0', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository, mockSnapClient }) => { + mockAccountsRepository.getAll.mockResolvedValue([existingAccount]); + const deriveAccount = jest + .spyOn(accountsService, 'deriveAccount') + .mockResolvedValue({ + id: 'default-create-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/1", + index: 1, + type: TrxAccountType.Eoa, + address: 'TDefaultCreate1', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/1", + groupIndex: 1, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); + + const result = await accountsService.create(); + + expect(result.id).toBe('default-create-id'); + expect(mockSnapClient.listEntropySources).toHaveBeenCalledTimes(1); + expect(deriveAccount).toHaveBeenCalledWith({ entropySource: 'test-entropy', - groupIndex: 2, + index: 1, }); + expect(mockAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'default-create-id', index: 1 }), + ); + }, + ); + }); - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledTimes(1); - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith({ + it('returns existing account when same derivation path exists', async () => { + const existingAccount: TronKeyringAccount = { + id: 'existing-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TExisting123456789012345678901', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.getAll.mockResolvedValue([existingAccount]); + + const result = await accountsService.create({ entropySource: 'test-entropy', - path: ['m', "44'", "195'"], - curve: 'secp256k1', + index: 0, }); - // The address probed for activity is the one persisted. - const checkedAddress = - mockTransactionsService.checkAddressActivity.mock.calls[0]?.[1]; - expect(result[0]?.address).toBe(checkedAddress); + expect(result.id).toBe('existing-id'); + expect(mockAccountsRepository.create).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalled(); }, - coinJson, ); }); - it('fetches entropy once for bip44:discover even when no activity is found', async () => { - const coinJson = await getTronTestCoinTypeJson(); + it('rolls back persisted account when event emission fails', async () => { + mockedEmitSnapKeyringEvent.mockRejectedValue( + new Error('Event emission failed'), + ); await withAccountsService( - async ({ - accountsService, - mockSnapClient, - mockTransactionsService, - }) => { - mockTransactionsService.checkAddressActivity.mockResolvedValue(false); + async ({ accountsService, mockAccountsRepository }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'rollback-test-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TRollback12345678901234567890', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], + }); - const result = await accountsService.createAccounts({ - type: AccountCreationType.Bip44Discover, + await expect( + accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }), + ).rejects.toThrow('Event emission failed'); + + expect(mockAccountsRepository.create).toHaveBeenCalled(); + expect(mockAccountsRepository.delete).toHaveBeenCalledWith( + 'rollback-test-id', + ); + }, + ); + }); + + it('preserves the original error when rollback delete also fails', async () => { + mockedEmitSnapKeyringEvent.mockRejectedValue( + new Error('Event emission failed'), + ); + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.delete.mockRejectedValue( + new Error('Delete failed'), + ); + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'rollback-fail-id', entropySource: 'test-entropy', - groupIndex: 0, + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TRollback12345678901234567890', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: { + entropy: { + type: 'mnemonic', + id: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + groupIndex: 0, + }, + exportable: true, + }, + methods: ['signMessage', 'signTransaction'], }); - expect(result).toStrictEqual([]); - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledTimes(1); - expect(mockSnapClient.getBip32Entropy).toHaveBeenCalledWith({ + await expect( + accountsService.create({ + entropySource: 'test-entropy', + index: 0, + }), + ).rejects.toThrow('Event emission failed'); + + expect(mockAccountsRepository.delete).toHaveBeenCalledWith( + 'rollback-fail-id', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ accountId: 'rollback-fail-id' }), + 'Failed to rollback account creation', + ); + }, + ); + }); + + it('passes metamask options through to emit', async () => { + mockedEmitSnapKeyringEvent.mockResolvedValue(); + + await withAccountsService(async ({ accountsService }) => { + jest.spyOn(accountsService, 'deriveAccount').mockResolvedValue({ + id: 'meta-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TMeta1234567890123456789012', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }); + + await accountsService.create({ + entropySource: 'test-entropy', + index: 0, + metamask: { correlationId: 'corr-123' }, + }); + + expect(mockedEmitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountCreated, + expect.objectContaining({ + metamask: { correlationId: 'corr-123' }, + }), + ); + }); + }); + + it('returns the persisted account and warns when repository create returns a conflicting account', async () => { + const conflictingAccount: TronKeyringAccount = { + id: 'pre-existing-conflict-id', + entropySource: 'test-entropy', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + type: TrxAccountType.Eoa, + address: 'TConflict12345678901234567890', + scopes: [TrxScope.Mainnet, TrxScope.Nile, TrxScope.Shasta], + options: {}, + methods: ['signMessage', 'signTransaction'], + }; + + await withAccountsService( + async ({ accountsService, mockAccountsRepository }) => { + mockAccountsRepository.create.mockResolvedValue(conflictingAccount); + + const result = await accountsService.create({ entropySource: 'test-entropy', - path: ['m', "44'", "195'"], - curve: 'secp256k1', + index: 0, }); + + expect(result.id).toBe('pre-existing-conflict-id'); + expect(mockLogger.warn).toHaveBeenCalled(); }, - coinJson, ); }); + + it('throws when no primary entropy source is available', async () => { + await withAccountsService(async ({ accountsService, mockSnapClient }) => { + mockSnapClient.listEntropySources.mockResolvedValue([ + { id: 'non-primary', primary: false }, + ]); + + await expect(accountsService.create()).rejects.toThrow( + 'No default entropy source found', + ); + }); + }); }); describe('getAll', () => { @@ -955,7 +1190,7 @@ describe('AccountsService', () => { }); describe('synchronizeAssets', () => { - it('calls fetch for each account and scope, then saveMany', async () => { + it('calls syncSnapOwnedAssets with accounts and active networks', async () => { const account: TronKeyringAccount = { id: 'sync-asset-id', address: 'TSyncAsset12345678901234567', @@ -967,18 +1202,6 @@ describe('AccountsService', () => { derivationPath: "m/44'/195'/0'/0/0", index: 0, }; - const mockAssets: NativeAsset[] = [ - { - assetType: `${Network.Mainnet}/slip44:195`, - keyringAccountId: 'sync-asset-id', - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - ]; await withAccountsService( async ({ accountsService, mockConfigProvider, mockAssetsService }) => { @@ -986,23 +1209,15 @@ describe('AccountsService', () => { ...MOCK_CONFIG, activeNetworks: [Network.Mainnet, Network.Shasta], }); - mockAssetsService.fetchAssetsAndBalancesForAccount.mockResolvedValue( - mockAssets, - ); await accountsService.synchronizeAssets([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Shasta, account); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith( - expect.arrayContaining(mockAssets), + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledTimes( + 1, + ); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [Network.Mainnet, Network.Shasta], ); }, ); @@ -1027,10 +1242,10 @@ describe('AccountsService', () => { await accountsService.synchronizeAssets([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).not.toHaveBeenCalled(); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [], + ); }, ); }); @@ -1119,111 +1334,15 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [Network.Mainnet], + ); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledWith(Network.Mainnet, account); }, ); }); - - const makeSyncAccount = ( - id: string, - index: number, - ): TronKeyringAccount => ({ - id, - address: `TCoalesce${index}2345678901234567890`, - type: TrxAccountType.Eoa, - options: {}, - methods: [], - scopes: [], - entropySource: 'e1', - derivationPath: `m/44'/195'/0'/0/${index}`, - index, - }); - - it('coalesces concurrent synchronize calls for the same accounts into one run', async () => { - const account = makeSyncAccount('coalesce-id', 0); - - await withAccountsService( - async ({ - accountsService, - mockConfigProvider, - mockAssetsService, - mockTransactionsService, - }) => { - mockConfigProvider.get.mockReturnValue({ - ...MOCK_CONFIG, - activeNetworks: [Network.Mainnet], - }); - - await Promise.all([ - accountsService.synchronize([account]), - accountsService.synchronize([account]), - accountsService.synchronize([account]), - ]); - - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(1); - expect( - mockTransactionsService.fetchNewTransactionsForAccount, - ).toHaveBeenCalledTimes(1); - expect(mockAssetsService.saveMany).toHaveBeenCalledTimes(1); - expect(mockTransactionsService.saveMany).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('runs synchronize again once the previous run has finished', async () => { - const account = makeSyncAccount('sequential-id', 0); - - await withAccountsService( - async ({ accountsService, mockConfigProvider, mockAssetsService }) => { - mockConfigProvider.get.mockReturnValue({ - ...MOCK_CONFIG, - activeNetworks: [Network.Mainnet], - }); - - await accountsService.synchronize([account]); - await accountsService.synchronize([account]); - - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - }, - ); - }); - - it('does not coalesce concurrent synchronize calls for different accounts', async () => { - const accountA = makeSyncAccount('different-a', 0); - const accountB = makeSyncAccount('different-b', 1); - - await withAccountsService( - async ({ accountsService, mockConfigProvider, mockAssetsService }) => { - mockConfigProvider.get.mockReturnValue({ - ...MOCK_CONFIG, - activeNetworks: [Network.Mainnet], - }); - - await Promise.all([ - accountsService.synchronize([accountA]), - accountsService.synchronize([accountB]), - ]); - - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountA); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountB); - }, - ); - }); }); }); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 9b65e0389..30cfadcde 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -25,7 +25,6 @@ import type { TronKeyringAccount } from '../../entities/keyring-account'; import { createTronBip44AddressDeriver } from '../../utils/deriveTronFromCoinTypeNode'; import { sanitizeSensitiveError } from '../../utils/errors'; import { DerivationPathStruct } from '../../validation/structs'; -import type { AssetsService } from '../assets/AssetsService'; import type { ConfigProvider } from '../config'; import type { TransactionsService } from '../transactions/TransactionsService'; import type { AccountsRepository } from './AccountsRepository'; @@ -101,8 +100,6 @@ export class AccountsService { readonly #logger: Logger; - readonly #assetsService: AssetsService; - readonly #transactionsService: TransactionsService; readonly #snapClient: SnapClient; @@ -113,21 +110,18 @@ export class AccountsService { accountsRepository, configProvider, logger, - assetsService, snapClient, transactionsService, }: { accountsRepository: AccountsRepository; configProvider: ConfigProvider; logger: Logger; - assetsService: AssetsService; snapClient: SnapClient; transactionsService: TransactionsService; }) { this.#logger = logger.withPrefix('[🔑 AccountsService]'); this.#configProvider = configProvider; this.#accountsRepository = accountsRepository; - this.#assetsService = assetsService; this.#transactionsService = transactionsService; this.#snapClient = snapClient; } @@ -413,34 +407,6 @@ export class AccountsService { return this.#accountsRepository.delete(id); } - /** - * Synchronizes only assets for the given accounts. - * This method can be called independently to sync assets without syncing transactions. - * - * @param accounts - The accounts to synchronize assets for. - */ - async synchronizeAssets(accounts: TronKeyringAccount[]): Promise { - const scopes = this.#configProvider.get().activeNetworks; - const combinations = accounts.flatMap((account) => - scopes.map((scope) => ({ account, scope })), - ); - - const assetResponses = await Promise.allSettled( - combinations.map(async ({ account, scope }) => { - return this.#assetsService.fetchAssetsAndBalancesForAccount( - scope, - account, - ); - }), - ); - - const assets = assetResponses.flatMap((response) => - response.status === 'fulfilled' ? response.value : [], - ); - - await this.#assetsService.saveMany(assets); - } - async synchronizeTransactions(accounts: TronKeyringAccount[]): Promise { const scopes = this.#configProvider.get().activeNetworks; const combinations = accounts.flatMap((account) => 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 000a84306..7272e6b87 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,17 +1,8 @@ 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'; import type { SpotPrices } from '../../clients/price-api/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; @@ -24,33 +15,12 @@ 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'; - -/** - * Subset of State methods. - */ -type MockState = { - getKey: jest.Mock; - setKey: jest.Mock; - setKeyWith: jest.Mock; -}; +import type { TokenCaipAssetType } from './types'; jest.mock('../../context', () => ({ configProvider: { - get(): { - priceApi: { - cacheTtlsMilliseconds: { - fiatExchangeRates: number; - spotPrices: number; - historicalPrices: number; - }; - }; - activeNetworks: never[]; - } { + get() { return { priceApi: { cacheTtlsMilliseconds: { @@ -74,18 +44,13 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); -const TRON_FLAG_KEY = SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron; - function createMessengerCallMock( - getState: () => unknown, getAccountAssetByID: jest.Mock, getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), ): CoreMessengerCaller['call'] { return async (actionType, ...args) => { switch (actionType) { - case 'RemoteFeatureFlagController:getState': - return getState() as Awaited>; case 'AssetsController:getAccountAssetByID': return getAccountAssetByID(...args); case 'AssetsController:getAccountAssetsByIDs': @@ -124,22 +89,6 @@ function buildControllerAsset( } as Asset; } -/** - * Builds a SpotPrices map for test mocks. - * - * @param entries - Map of asset ID to price info. - * @returns SpotPrices object. - */ -const createSpotPrices = ( - entries: Record, -): SpotPrices => - Object.fromEntries( - Object.entries(entries).map(([key, value]) => [ - key, - { id: value.id, price: value.price }, - ]), - ); - const mockAccount: KeyringAccount = { id: 'test-account-id', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', @@ -161,6 +110,22 @@ 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. @@ -209,9 +174,7 @@ const minimalTronAccount = createMockTronAccount({ * @param overrides - Account-specific fields to set. * @returns A mock AccountResources object. */ -function getMockAccountResources( - overrides: Record = {}, -): Record { +function getMockAccountResources(overrides: Record = {}) { return { freeNetLimit: 600, TotalNetLimit: 0, @@ -229,10 +192,7 @@ function getMockAccountResources( * @param assetType - The CAIP-19 asset type to match. * @returns The matching asset, or undefined. */ -function findAsset( - assets: AssetEntity[], - assetType: KnownCaip19Id, -): AssetEntity | undefined { +function findAsset(assets: AssetEntity[], assetType: KnownCaip19Id) { return assets.find((a: AssetEntity) => a.assetType === assetType); } @@ -242,12 +202,12 @@ type WithAssetsServiceCallback = (payload: { Pick< AssetsRepository, | 'saveMany' + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' > >; - mockState: MockState; mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, @@ -266,7 +226,6 @@ type WithAssetsServiceCallback = (payload: { mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; mockCoreMessenger: jest.Mocked; - setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; /** @@ -283,6 +242,7 @@ async function withAssetsService( const mockAssetsRepository: jest.Mocked< Pick< AssetsRepository, + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' @@ -290,17 +250,12 @@ async function withAssetsService( > > = { saveMany: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue([]), getByAccountId: jest.fn().mockResolvedValue([]), getByAccountIdAndAssetType: jest.fn().mockResolvedValue(null), getByAccountIdAndAssetTypes: jest.fn().mockResolvedValue([]), }; - const mockState: MockState = { - getKey: jest.fn().mockResolvedValue({}), - setKey: jest.fn().mockResolvedValue(undefined), - setKeyWith: jest.fn().mockResolvedValue(undefined), - }; - const mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, @@ -342,99 +297,97 @@ async function withAssetsService( 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, + call: jest + .fn() + .mockImplementation( + createMessengerCallMock( + 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 assetsProvider = { + getAccountAssetByID: ( + accountId: string, + assetId: Caip19AssetId, + ): Promise => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetByID', + accountId, + assetId, + ) as Promise, + getAccountAssetsByIDs: ( + accountId: string, + assetIds: Caip19AssetId[], + ): Promise> => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetsByIDs', + accountId, + assetIds, + ) as Promise>, + getAccountAssetsByScope: ( + scope: string, + accountId: string, + ): Promise> => + mockCoreMessenger.call( + 'AssetsController:getAccountAssetsByScope', + scope, + accountId, + ) as Promise>, }; - const snapAdapter = new SnapAssetsAdapter({ - logger: mockLogger, - assetsRepository: mockAssetsRepository as never, - state: mockState as never, - trongridApiClient: mockTrongridApiClient as never, - tronHttpClient: mockTronHttpClient as never, - priceApiClient: mockPriceApiClient as never, - tokenApiClient: mockTokenApiClient as never, - snapClient: mockSnapClient as never, - configProvider: mockConfigProvider as never, - }); - const coreAdapter = new CoreAssetsAdapter({ - getAccountAssetByID: - assetsProvider.getAccountAssetByID.bind(assetsProvider), - getAccountAssetsByIDs: - assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), - getAccountAssetsByScope: - assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - getAddressInfo: mockTrongridApiClient.getAccountInfoByAddress, - getAddressResources: mockTronHttpClient.getAccountResources, - getAddressStakingRewards: mockTronHttpClient.getReward, - }); - const assetsService = new AssetsService({ - snapAdapter, - coreAdapter, - remoteFeatureFlagsProvider, + logger: mockLogger, + assetsRepository: mockAssetsRepository, + trongridApiClient: mockTrongridApiClient, + tronHttpClient: mockTronHttpClient, + priceApiClient: mockPriceApiClient, + tokenApiClient: mockTokenApiClient, + snapClient: mockSnapClient, + assetsProvider, }); return await testFunction({ assetsService, mockAssetsRepository, - mockState, mockTrongridApiClient, mockTronHttpClient, mockPriceApiClient, mockTokenApiClient, mockSnapClient, mockCoreMessenger, - setMigrationStage, }); } +/** + * Runs syncSnapOwnedAssets and returns the assets passed to repository saveMany. + * + * @param assetsService - The assets service under test. + * @param mockAssetsRepository - The mocked assets repository. + * @returns The snap-owned assets persisted by the sync. + */ +async function syncAndGetSavedAssets( + assetsService: InstanceType, + mockAssetsRepository: jest.Mocked>, +): Promise { + await assetsService.syncSnapOwnedAssets([mockAccount], [Network.Mainnet]); + expect(mockAssetsRepository.saveMany).toHaveBeenCalled(); + return mockAssetsRepository.saveMany.mock.calls.at(-1)?.[0] as AssetEntity[]; +} + describe('AssetsService', () => { - describe('fetchAssetsAndBalancesForAccount', () => { + describe('syncSnapOwnedAssets', () => { describe('inactive account fallback', () => { - it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { + it('returns zero snap-owned resources when account info fails (inactive account)', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, - mockPriceApiClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new TrongridAccountNotFoundError(), @@ -443,176 +396,38 @@ describe('AssetsService', () => { emptyAccountResources, ); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = - `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); - }, - ); - }); - - it('returns protocol resources when inactive account has empty resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + ).not.toHaveBeenCalled(); - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, + const bandwidthAsset = findAsset( + assets, + KnownCaip19Id.BandwidthMainnet, ); + const energyAsset = findAsset(assets, KnownCaip19Id.EnergyMainnet); expect(bandwidthAsset).toBeDefined(); expect(energyAsset).toBeDefined(); - }, - ); - }); - - it('returns protocol assets when inactive account info fails', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(assets.length).toBeGreaterThan(0); expect( - assets.some((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), + assets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, ), - ).toBe(true); + ).toBe(false); }, ); }); }); describe('partial failure handling', () => { - it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - NetLimit: 0, - EnergyLimit: 0, - }); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = - `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalled(); - expect( - assets.some((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('600'); - }, - ); - }); - it('continues with zero resources when only resources request fails', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -627,21 +442,20 @@ describe('AssetsService', () => { new Error('Resources endpoint unavailable'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, ), - ).toBe(true); + ).toBe(false); - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, + const bandwidthAsset = findAsset( + assets, + KnownCaip19Id.BandwidthMainnet, ); expect(bandwidthAsset).toBeDefined(); expect(bandwidthAsset?.rawAmount).toBe('0'); @@ -655,6 +469,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -663,9 +478,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -679,6 +494,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -689,9 +505,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 200 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -705,6 +521,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -715,9 +532,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -731,6 +548,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -745,9 +563,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -763,6 +581,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -771,9 +590,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -788,6 +607,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -798,9 +618,9 @@ describe('AssetsService', () => { getMockAccountResources({}), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -815,6 +635,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -825,9 +646,9 @@ describe('AssetsService', () => { getMockAccountResources({ NetLimit: 48 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -844,6 +665,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -855,9 +677,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -874,6 +696,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -888,9 +711,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -907,6 +730,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -924,9 +748,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -943,6 +767,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -959,9 +784,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -978,6 +803,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -997,9 +823,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const readyForWithdrawalAsset = findAsset( @@ -1018,6 +844,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1029,9 +856,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1048,6 +875,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1065,9 +893,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1084,6 +912,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1101,9 +930,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1120,6 +949,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1142,9 +972,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1161,6 +991,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1180,9 +1011,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1199,20 +1030,18 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( new Error('account not found'), ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const inLockPeriodAsset = findAsset( @@ -1231,6 +1060,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1239,9 +1069,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1255,6 +1085,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1265,9 +1096,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1281,6 +1112,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1291,9 +1123,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1307,6 +1139,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1317,9 +1150,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1335,6 +1168,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1343,9 +1177,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1359,6 +1193,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1369,9 +1204,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1387,6 +1222,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1396,9 +1232,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(0); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1413,6 +1249,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1422,9 +1259,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(5000000); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); const stakingRewardsAsset = findAsset( @@ -1442,6 +1279,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1453,9 +1291,9 @@ describe('AssetsService', () => { new Error('API Error'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1466,1030 +1304,71 @@ describe('AssetsService', () => { ); }); }); - }); - - describe('getHistoricalPrice', () => { - it('tracks historical price errors', async () => { - await withAssetsService( - async ({ assetsService, mockSnapClient, mockPriceApiClient }) => { - const error = new Error('Price error'); - - mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); - - await assetsService.getHistoricalPrice( - KnownCaip19Id.TrxMainnet, - 'tron:728126428/slip44:usd', - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - }); - describe('saveMany', () => { - it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); + describe('persistence and events via sync', () => { + it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); - await assetsService.saveMany(assets); + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.BandwidthMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [mockAccount.id]: { + added: expect.arrayContaining([ + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + ]), + removed: [], + }, }, }, - }, - ); - }, - ); - }); - - it('correctly updates non-essential assets with zero amounts', async () => { - await withAssetsService(async ({ assetsService, mockState }) => { - const trc20AssetId = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: assets, - }); - - await assetsService.saveMany(assets); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, + ); }, ); }); - }); - it('updates stale non-essential assets balance to 0 if missed from the latest snapshot', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId as TokenCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '1658250000', - uiAmount: '1658.25', - iconUrl: '', - }, - ]; - const finalSavedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet as NativeCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId as TokenCaipAssetType, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [savedAssets[0] as AssetEntity]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - finalSavedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: [KnownCaip19Id.TrxMainnet], - removed: [trc20AssetId], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [trc20AssetId]: { - unit: 'USDT', - amount: '0', - }, - }, - }, - }, - ); - }, - ); - }); - - it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.MaximumEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'MAX-ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.MaximumBandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'MAX-BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.MaximumEnergyMainnet, - KnownCaip19Id.MaximumBandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps staked assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForBandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-BANDWIDTH', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxStakedForBandwidthMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('keeps ready for withdrawal assets even with zero amounts', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const assets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxReadyForWithdrawalMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'trx-ready-for-withdrawal', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue(assets); - - await assetsService.saveMany(assets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - describe('updating assets from 0 to >0', () => { - it('adds energy to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('adds bandwidth to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('adds TRC20 token to the asset list when it updates from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('handles multiple assets updating from 0 to >0 simultaneously', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - - it('handles staked assets updating from 0 to >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '5000000', - uiAmount: '5', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.TrxStakedForEnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'sTRX-ENERGY', - decimals: 6, - rawAmount: '3000000', - uiAmount: '3', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxStakedForEnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - }, - ); - }); - }); - - describe('updating assets going down', () => { - it('updates energy balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '100000', - uiAmount: '100000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '35000', - uiAmount: '35000', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.EnergyMainnet]: { - unit: 'ENERGY', - amount: '35000', - }, - }, - }, - }, - ); - }, - ); - }); - - it('updates bandwidth balance when it decreases but remains >0', async () => { - await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '5000', - uiAmount: '5000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '4700', - uiAmount: '4700', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, - ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, - ); - - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, - ); - }, - ); - }); - - it('updates TRC20 token balance when it decreases but remains >0', async () => { + it('does not zero or remove TRC20 when missing from sync snapshot', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = - `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const savedAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, @@ -2507,310 +1386,235 @@ describe('AssetsService', () => { network: Network.Mainnet, symbol: 'USDT', decimals: 6, - rawAmount: '100000000', - uiAmount: '100', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '50000000', - uiAmount: '50', + rawAmount: '1658250000', + uiAmount: '1658.25', iconUrl: '', }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - trc20AssetId, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), + ); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + + const persistedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + persistedAssets.find((asset) => asset.assetType === trc20AssetId), + ).toBeUndefined(); + expect( + persistedAssets.find( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBeUndefined(); + expect( + persistedAssets.find( + (asset) => asset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).toBeDefined(); }, ); }); - it('keeps energy in the list when it drops to 0', async () => { + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '50000', - uiAmount: '50000', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.EnergyMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'ENERGY', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; - - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.MaximumEnergyMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.MaximumBandwidthMainnet, + ), + ).toBe(true); }, ); }); - it('keeps bandwidth in the list when it drops to 0', async () => { + it('keeps staked assets even with zero amounts', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '300', - uiAmount: '300', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '0', - uiAmount: '0', - iconUrl: '', - }, - ]; + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); - await assetsService.saveMany(updatedAssets); + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === + KnownCaip19Id.TrxStakedForBandwidthMainnet, + ), + ).toBe(true); + expect( + savedAssets.some( + (asset) => + asset.assetType === KnownCaip19Id.TrxStakedForEnergyMainnet, + ), + ).toBe(true); + }, + ); + }); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + it('keeps ready for withdrawal assets even with zero amounts', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue({}); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + + const savedAssets = + mockAssetsRepository.saveMany.mock.calls[0]?.[0] ?? []; + expect( + savedAssets.some( + (asset) => + asset.assetType === + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + ), + ).toBe(true); }, ); }); - it('handles both energy and bandwidth fluctuating in a transaction', async () => { + it('emits balance updates when snap-owned energy increases', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '80000', - uiAmount: '80000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1500', - uiAmount: '1500', + rawAmount: '0', + uiAmount: '0', iconUrl: '', }, ]; - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 50000 }), + ); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [mockAccount.id]: expect.objectContaining({ + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '50000', + }, + }), + }, }, + ); + }, + ); + }); + + it('emits balance updates when snap-owned energy decreases but remains >0', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const savedAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '45000', - uiAmount: '45000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1235', - uiAmount: '1235', + rawAmount: '100000', + uiAmount: '100000', iconUrl: '', }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountAssetListUpdated, - { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ + EnergyLimit: 100000, + EnergyUsed: 65000, + }), + ); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -2818,20 +1622,12 @@ describe('AssetsService', () => { KeyringEvent.AccountBalancesUpdated, { balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '2', - }, + [mockAccount.id]: expect.objectContaining({ [KnownCaip19Id.EnergyMainnet]: { unit: 'ENERGY', - amount: '45000', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '1235', + amount: '35000', }, - }, + }), }, }, ); @@ -2885,25 +1681,90 @@ describe('AssetsService', () => { }); }); - describe('assets migration', () => { + describe('AssetsController routing', () => { const accountId = mockAccount.id; const fungibleAssetId = KnownCaip19Id.TrxMainnet; - const activeMigrationStage = - SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback; + const snapAssetId = KnownCaip19Id.EnergyMainnet; + + it('syncSnapOwnedAssets returns protocol assets only', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + trc20: [{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000' }], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); - it('routes getAccountAssetByID through AssetsController when migration is active', async () => { + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); + + const assets = + mockAssetsRepository.saveMany.mock.calls.at(-1)?.[0] ?? []; + + expect( + assets.every((asset: AssetEntity) => + SNAP_OWNED_ASSETS.includes(asset.assetType), + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === fungibleAssetId, + ), + ).toBe(false); + }, + ); + }); + + it('routes snap-owned reads through the repository', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + snapAssetId, + ); + + expect(asset).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalledWith( + 'AssetsController:getAccountAssetByID', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('routes fungible reads through AssetsController', async () => { await withAssetsService(async ({ assetsService, mockCoreMessenger }) => { mockCoreMessenger.call.mockImplementation( createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: activeMigrationStage, - }, - }, - }), jest.fn().mockResolvedValue( - buildControllerAsset(fungibleAssetId, '2', { + buildControllerAsset(fungibleAssetId, '2000000', { symbol: 'TRX', name: 'TRON', decimals: 6, @@ -2925,29 +1786,22 @@ describe('AssetsService', () => { }); }); - it('routes getAccountAssetsByIDs through AssetsController when migration is active', async () => { + it('getAccountAssetsByIDs uses a single AssetsController:getAccountAssetsByIDs call for fungibles', 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, '1', { + [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { symbol: 'TRX', name: 'TRON', decimals: 6, }), - [usdt as Caip19AssetId]: buildControllerAsset(usdt, '0.5', { + [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { symbol: 'USDT', name: 'Tether', decimals: 6, @@ -2972,196 +1826,220 @@ describe('AssetsService', () => { }); }); - it('routes getAccountAssets through AssetsController when migration is active', async () => { + it('getAccountAssetsByIDs batches snap-owned reads without calling AssetsController', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + snapAssetId, + ]); + + expect(results[0]).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).not.toHaveBeenCalled(); + }, + ); + }); + + it('getAccountAssetsByIDs merges snap-owned and fungible reads in request order', async () => { await withAssetsService( - async ({ assetsService, mockCoreMessenger, setMigrationStage }) => { - setMigrationStage(activeMigrationStage); + async ({ assetsService, mockAssetsRepository, mockCoreMessenger }) => { + const snapAsset: AssetEntity = { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '250', + uiAmount: '250', + iconUrl: '', + }; + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); mockCoreMessenger.call.mockImplementation( createMessengerCallMock( - () => ({ - remoteFeatureFlags: { - [TRON_FLAG_KEY]: { - stage: activeMigrationStage, - }, - }, - }), - jest.fn(), jest.fn(), - jest.fn().mockResolvedValue({ - [fungibleAssetId as Caip19AssetId]: buildControllerAsset( - fungibleAssetId, - '2', - { - symbol: 'TRX', - name: 'TRON', - decimals: 6, - }, - ), + jest.fn().mockImplementation(async () => { + return { + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '3000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + }; }), ), ); - const assets = await assetsService.getAccountAssets(accountId); + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + fungibleAssetId, + snapAssetId, + ]); + expect(results[0]?.rawAmount).toBe('3000000'); + expect(results[1]).toStrictEqual(snapAsset); expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', - accountId, - Network.Mainnet, - ); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', - accountId, - Network.Nile, - ); - expect(mockCoreMessenger.call).toHaveBeenCalledWith( - 'AssetsController:getAccountAssetsByScope', + 'AssetsController:getAccountAssetsByIDs', accountId, - Network.Shasta, + [fungibleAssetId], ); + }, + ); + }); + + it('getByKeyringAccountId excludes fungibles', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository }) => { + mockAssetsRepository.getByAccountId.mockResolvedValue([ + { + assetType: fungibleAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + { + assetType: snapAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '100', + uiAmount: '100', + iconUrl: '', + }, + ]); + + const assets = await assetsService.getByKeyringAccountId(accountId); + expect( assets.some( (asset: AssetEntity) => asset.assetType === fungibleAssetId, ), + ).toBe(false); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === snapAssetId, + ), ).toBe(true); }, ); }); - it('fetches only snap-owned assets when migration is active', async () => { + it('syncSnapOwnedAssets emits only snap-owned assets', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, 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); + mockAssetsRepository.getAll.mockResolvedValue([]); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 100 }), + ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); - 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); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + expect.objectContaining({ + assets: expect.objectContaining({ + [accountId]: expect.objectContaining({ + added: expect.arrayContaining([snapAssetId]), + }), + }), + }), + ); }, ); }); + }); - it('emits only snap-owned assets and does not persist when migration is active', async () => { + describe('getHistoricalPrice', () => { + it('tracks historical price errors', 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: '', - }; + async ({ assetsService, mockSnapClient, mockPriceApiClient }) => { + const error = new Error('Price error'); - await assetsService.saveMany([specialAsset, fungibleAsset]); + mockPriceApiClient.getHistoricalPrices.mockRejectedValue(error); - 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', - }, - }, - }, - }, + await assetsService.getHistoricalPrice( + KnownCaip19Id.TrxMainnet, + 'tron:728126428/slip44:usd', ); + + expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); }, ); }); }); describe('facade delegation', () => { - it('delegates static helpers and empty batch reads to SnapAssetsAdapter', async () => { + it('routes fungible reads through AssetsProvider and keeps handler logic in AssetsService', async () => { await withAssetsService( - async ({ assetsService, mockAssetsRepository, mockPriceApiClient }) => { - const asset: AssetEntity = { - iconUrl: '', - assetType: KnownCaip19Id.TrxMainnet, + async ({ + assetsService, + mockAssetsRepository, + mockCoreMessenger, + mockPriceApiClient, + }) => { + const snapAsset: AssetEntity = { + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, + symbol: 'ENERGY', + decimals: 0, rawAmount: '1', uiAmount: '1', }; - mockAssetsRepository.getByAccountIdAndAssetTypes.mockResolvedValue([ - asset, - ]); - mockPriceApiClient.getFiatExchangeRates.mockResolvedValue( - MOCK_EXCHANGE_RATES, + mockAssetsRepository.getByAccountId.mockResolvedValue([snapAsset]); + mockAssetsRepository.getByAccountIdAndAssetType.mockResolvedValue( + snapAsset, + ); + mockCoreMessenger.call.mockImplementation( + createMessengerCallMock( + jest.fn().mockResolvedValue( + buildControllerAsset(KnownCaip19Id.TrxMainnet, '1', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + ), + ), ); + mockPriceApiClient.getFiatExchangeRates.mockResolvedValue({ + usd: { value: 1 }, + }); mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( createSpotPrices({ [KnownCaip19Id.TrxMainnet]: { @@ -3173,19 +2051,41 @@ describe('AssetsService', () => { expect(AssetsService.isFiat('eip155:1/erc20:0x0')).toBe(false); expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); - expect(AssetsService.hasChanged(asset, [])).toBe(true); - expect(AssetsService.hasChanged(asset, [asset])).toBe(false); + expect( - await assetsService.getAccountAssetsByIDs(mockAccount.id, []), - ).toStrictEqual([]); + await assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).toMatchObject({ + assetType: KnownCaip19Id.TrxMainnet, + rawAmount: '1', + }); expect( - await assetsService.getMultipleTokensMarketData([ - { - asset: KnownCaip19Id.TrxMainnet, - unit: 'swift:0/iso4217:usd', - }, - ]), - ).toBeDefined(); + await assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.EnergyMainnet, + ), + ).toStrictEqual(snapAsset); + const byKeyringAccountId = await assetsService.getByKeyringAccountId( + mockAccount.id, + ); + expect( + byKeyringAccountId.some( + (savedAsset) => + savedAsset.assetType === KnownCaip19Id.EnergyMainnet, + ), + ).toBe(true); + const marketData = await assetsService.getMultipleTokensMarketData([ + { + asset: KnownCaip19Id.TrxMainnet, + unit: 'swift:0/iso4217:usd', + }, + ]); + expect(marketData[KnownCaip19Id.TrxMainnet]).toBeDefined(); + expect(assetsService.cacheTtlsMilliseconds.historicalPrices).toBe( + 3600000, + ); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 1df8c8eb3..3fb0c3b69 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,70 +1,180 @@ import type { Caip19AssetId } from '@metamask/assets-controller'; -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 { AssetsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, FungibleAssetMarketData, + FungibleAssetMetadata, HistoricalPriceIntervals, } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; import type { CaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { pick } from 'lodash'; -import type { Network } from '../../constants'; +import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; +import type { FiatTicker, SpotPrice } from '../../clients/price-api/types'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + VsCurrencyParamStruct, +} from '../../clients/price-api/types'; +import type { SnapClient } from '../../clients/snap/SnapClient'; +import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; +import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; +import { Network } from '../../constants'; +import { + BANDWIDTH_METADATA, + ENERGY_METADATA, + MAX_BANDWIDTH_METADATA, + MAX_ENERGY_METADATA, + TRX_IN_LOCK_PERIOD_METADATA, + TRX_METADATA, + TRX_READY_FOR_WITHDRAWAL_METADATA, + TRX_STAKED_FOR_BANDWIDTH_METADATA, + TRX_STAKED_FOR_ENERGY_METADATA, + TRX_STAKING_REWARDS_METADATA, +} from '../../constants'; +import { configProvider } from '../../context'; import type { AssetEntity } from '../../entities/assets'; -import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; +import type { AssetsRepository } from './AssetsRepository'; +import { mapControllerAsset } from './mapControllerAsset'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; +import type { + InLockPeriodCaipAssetType, + NativeCaipAssetType, + NftCaipAssetType, + ReadyForWithdrawalCaipAssetType, + ResourceCaipAssetType, + StakedCaipAssetType, + StakingRewardsCaipAssetType, + TokenCaipAssetType, +} from './types'; -/** - * 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; + readonly #logger: ILogger; + + readonly #priceApiClient: PriceApiClient; - readonly #coreAdapter: CoreAssetsAdapter; + readonly #tokenApiClient: TokenApiClient; - readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + readonly #snapClient: SnapClient; - readonly cacheTtlsMilliseconds: SnapAssetsAdapter['cacheTtlsMilliseconds']; + readonly #snapAdapter: SnapAssetsAdapter; + + readonly #assetsProvider: AssetsProvider; + + readonly cacheTtlsMilliseconds: { + fiatExchangeRates: number; + spotPrices: number; + historicalPrices: number; + }; constructor({ - snapAdapter, - coreAdapter, - remoteFeatureFlagsProvider, + logger, + assetsRepository, + trongridApiClient, + tronHttpClient, + priceApiClient, + tokenApiClient, + snapClient, + assetsProvider, }: { - snapAdapter: SnapAssetsAdapter; - coreAdapter: CoreAssetsAdapter; - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + logger: ILogger; + assetsRepository: AssetsRepository; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + priceApiClient: PriceApiClient; + tokenApiClient: TokenApiClient; + snapClient: SnapClient; + assetsProvider: AssetsProvider; }) { - this.#snapAdapter = snapAdapter; - this.#coreAdapter = coreAdapter; - this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; - this.cacheTtlsMilliseconds = this.#snapAdapter.cacheTtlsMilliseconds; + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); + this.#priceApiClient = priceApiClient; + this.#tokenApiClient = tokenApiClient; + this.#snapClient = snapClient; + this.#assetsProvider = assetsProvider; + this.#snapAdapter = new SnapAssetsAdapter({ + logger, + assetsRepository, + trongridApiClient, + tronHttpClient, + }); + + const { cacheTtlsMilliseconds } = configProvider.get().priceApi; + this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; + } + + async #getProviderAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + const asset = await this.#assetsProvider.getAccountAssetByID( + accountId, + assetId as Caip19AssetId, + ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); } - async #shouldReturnAssetsFromCore(): Promise { - const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( - SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron, + async #getProviderAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise> { + const controllerAssets = await this.#assetsProvider.getAccountAssetsByIDs( + accountId, + assetIds as Caip19AssetId[], + ); + + return Object.fromEntries( + assetIds.map((assetId) => { + const controllerAsset = controllerAssets[assetId as Caip19AssetId]; + return [ + assetId, + controllerAsset + ? mapControllerAsset(accountId, controllerAsset) + : null, + ]; + }), + ); + } + + async #getProviderAccountAssetsByScope( + scope: Network, + accountId: string, + ): Promise { + const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( + scope, + accountId, + ); + + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(accountId, asset), ); - const result = - parseSnapsAssetsMigrationStage(flagValue) !== - SnapsAssetsMigrationStage.Off; - return result; } static isFiat(caipAssetId: CaipAssetType): boolean { - return SnapAssetsAdapter.isFiat(caipAssetId); + return caipAssetId.includes('swift:0/iso4217:'); } - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - return SnapAssetsAdapter.hasChanged(asset, assetsLookup); + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + if (isSnapOwnedAsset(assetId)) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + } + + return this.#getProviderAccountAssetByID(accountId, assetId); } async getAccountAssetsByIDs( @@ -75,80 +185,711 @@ export class AssetsService { return []; } - if (await this.#shouldReturnAssetsFromCore()) { - const assets = await this.#coreAdapter.getAccountAssetsByIDs( - accountId, - assetIds as Caip19AssetId[], - ); - return assets; - } + const result: (AssetEntity | null)[] = new Array(assetIds.length).fill( + null, + ); + const fungibleIds: string[] = []; + const fungibleIndices: number[] = []; - return this.#snapAdapter.getAccountAssetsByIDs(accountId, assetIds); - } + for (const [index, assetId] of assetIds.entries()) { + if (isSnapOwnedAsset(assetId)) { + result[index] = await this.#snapAdapter.getAccountAssetByID( + accountId, + assetId, + ); + } else { + fungibleIds.push(assetId); + fungibleIndices.push(index); + } + } - async getAccountAssetByID( - accountId: string, - assetId: string, - ): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - const asset = await this.#coreAdapter.getAccountAssetByID( - accountId, - assetId as Caip19AssetId, - ); - return asset; + if (fungibleIds.length === 0) { + return result; } - return this.#snapAdapter.getAccountAssetByID(accountId, assetId); + const fungibleResults = await this.#getProviderAccountAssetsByIDs( + accountId, + fungibleIds, + ); + + fungibleIds.forEach((assetId, fungibleIndex) => { + const resultIndex = fungibleIndices[fungibleIndex]; + if (resultIndex !== undefined) { + result[resultIndex] = fungibleResults[assetId] ?? null; + } + }); + + return result; } - async fetchAssetsAndBalancesForAccount( + async getAccountAssetsByScope( scope: Network, - account: KeyringAccount, + accountId: string, ): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - const assetsAndBalances = - await this.#coreAdapter.fetchAssetsAndBalancesForAccount( - scope, - account, - ); - return assetsAndBalances; - } + const snapAssets = await this.#snapAdapter.getAccountAssetsByScope( + scope, + accountId, + ); + const snapOwnedAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + const coreAssets = await this.#getProviderAccountAssetsByScope( + scope, + accountId, + ); - return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); + return [ + ...coreAssets.filter((asset) => !isSnapOwnedAsset(asset.assetType)), + ...snapOwnedAssets, + ]; + } + + async getByKeyringAccountId(accountId: string): Promise { + const assets = await this.#snapAdapter.getAccountAssetsByScope( + Network.Mainnet, + accountId, + ); + + return assets.filter((asset) => isSnapOwnedAsset(asset.assetType)); + } + + async syncSnapOwnedAssets( + accounts: KeyringAccount[], + scopes: Network[], + ): Promise { + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), + ); + const responses = await Promise.allSettled( + combinations.map(({ account, scope }) => + this.#snapAdapter.fetchSnapOwnedAssetsForAccount(scope, account), + ), + ); + const assets = responses.flatMap((response) => + response.status === 'fulfilled' ? response.value : [], + ); + await this.#snapAdapter.saveMany(assets); } async getAssetsMetadata( assetTypes: CaipAssetType[], ): Promise> { - return this.#snapAdapter.getAssetsMetadata(assetTypes); + this.#logger.info('Fetching metadata for assets', assetTypes); + + const { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + } = this.#splitAssetsByType(assetTypes); + + const nativeTokensMetadata = + this.#getNativeTokensMetadata(nativeAssetTypes); + const stakedTokensMetadata = this.#getStakedTokensMetadata( + stakedNativeAssetTypes, + ); + const readyForWithdrawalTokensMetadata = + this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); + const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( + inLockPeriodAssetTypes, + ); + const stakingRewardsMetadata = this.#getStakingRewardsMetadata( + stakingRewardsAssetTypes, + ); + const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); + const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( + maximunEnergyAssetTypes, + ); + const bandwidthTokensMetadata = + this.#getBandwidthMetadata(bandwidthAssetTypes); + const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( + maximunBandwidthAssetTypes, + ); + const tokensMetadata = await this.#getTokensMetadata([ + ...tokenTrc10AssetTypes, + ...tokenTrc20AssetTypes, + ]); + + const result = { + ...nativeTokensMetadata, + ...stakedTokensMetadata, + ...readyForWithdrawalTokensMetadata, + ...inLockPeriodTokensMetadata, + ...stakingRewardsMetadata, + ...energyTokensMetadata, + ...maximunEnergyTokensMetadata, + ...bandwidthTokensMetadata, + ...maximunBandwidthTokensMetadata, + ...tokensMetadata, + }; + + this.#logger.info('Resolved assets metadata', { assetTypes, result }); + + return result; + } + + #splitAssetsByType(assetTypes: CaipAssetType[]): { + nativeAssetTypes: NativeCaipAssetType[]; + stakedNativeAssetTypes: StakedCaipAssetType[]; + readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; + inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; + stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; + energyAssetTypes: ResourceCaipAssetType[]; + maximunEnergyAssetTypes: ResourceCaipAssetType[]; + bandwidthAssetTypes: ResourceCaipAssetType[]; + maximunBandwidthAssetTypes: ResourceCaipAssetType[]; + tokenTrc10AssetTypes: TokenCaipAssetType[]; + tokenTrc20AssetTypes: TokenCaipAssetType[]; + nftAssetTypes: NftCaipAssetType[]; + } { + const nativeAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195'), + ) as NativeCaipAssetType[]; + const stakedNativeAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/slip44:195-staked-for-'), + ) as StakedCaipAssetType[]; + const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-ready-for-withdrawal'), + ) as ReadyForWithdrawalCaipAssetType[]; + const inLockPeriodAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-in-lock-period'), + ) as InLockPeriodCaipAssetType[]; + const stakingRewardsAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:195-staking-rewards'), + ) as StakingRewardsCaipAssetType[]; + const energyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:energy'), + ) as ResourceCaipAssetType[]; + const maximunEnergyAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-energy'), + ) as ResourceCaipAssetType[]; + const bandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:bandwidth'), + ) as ResourceCaipAssetType[]; + const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => + assetType.endsWith('/slip44:maximum-bandwidth'), + ) as ResourceCaipAssetType[]; + const tokenTrc10AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc10:'), + ) as TokenCaipAssetType[]; + const tokenTrc20AssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc20:'), + ) as TokenCaipAssetType[]; + const nftAssetTypes = assetTypes.filter((assetType) => + assetType.includes('/trc721:'), + ) as NftCaipAssetType[]; + + return { + nativeAssetTypes, + stakedNativeAssetTypes, + readyForWithdrawalAssetTypes, + inLockPeriodAssetTypes, + stakingRewardsAssetTypes, + energyAssetTypes, + maximunEnergyAssetTypes, + bandwidthAssetTypes, + maximunBandwidthAssetTypes, + tokenTrc10AssetTypes, + tokenTrc20AssetTypes, + nftAssetTypes, + }; } - async saveMany(assets: AssetEntity[]): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.saveMany(assets); + #getNativeTokensMetadata( + assetTypes: NativeCaipAssetType[], + ): Record { + const nativeTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + nativeTokensMetadata[assetType] = { + fungible: TRX_METADATA.fungible, + name: TRX_METADATA.name, + symbol: TRX_METADATA.symbol, + iconUrl: TRX_METADATA.iconUrl, + units: [ + { + decimals: TRX_METADATA.decimals, + symbol: TRX_METADATA.symbol, + name: TRX_METADATA.name, + }, + ], + }; } - return this.#snapAdapter.saveMany(assets); + return nativeTokensMetadata; } - async getAll(): Promise { - return this.#snapAdapter.getAll(); + #getStakedTokensMetadata( + assetTypes: StakedCaipAssetType[], + ): Record { + // Can either be Staked for Bandwidth or Staked for Energy + const stakedTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); + + if (isForBandwdidth) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, + symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, + name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + const isForEnergy = assetType.endsWith('staked-for-energy'); + + if (isForEnergy) { + stakedTokensMetadata[assetType] = { + fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, + symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, + name: TRX_STAKED_FOR_ENERGY_METADATA.name, + }, + ], + }; + } + } + + return stakedTokensMetadata; } - async getAccountAssets(accountId: string): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.getAccountAssets(accountId); + #getReadyForWithdrawalTokensMetadata( + assetTypes: ReadyForWithdrawalCaipAssetType[], + ): Record { + const readyForWithdrawalTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + readyForWithdrawalTokensMetadata[assetType] = { + fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, + units: [ + { + decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, + symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, + name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, + }, + ], + }; } - return this.#snapAdapter.getAccountAssets(accountId); + return readyForWithdrawalTokensMetadata; } + #getStakingRewardsMetadata( + assetTypes: StakingRewardsCaipAssetType[], + ): Record { + const stakingRewardsMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + stakingRewardsMetadata[assetType] = { + fungible: TRX_STAKING_REWARDS_METADATA.fungible, + name: TRX_STAKING_REWARDS_METADATA.name, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, + units: [ + { + decimals: TRX_STAKING_REWARDS_METADATA.decimals, + symbol: TRX_STAKING_REWARDS_METADATA.symbol, + name: TRX_STAKING_REWARDS_METADATA.name, + }, + ], + }; + } + + return stakingRewardsMetadata; + } + + #getInLockPeriodMetadata( + assetTypes: InLockPeriodCaipAssetType[], + ): Record { + const inLockPeriodTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + inLockPeriodTokensMetadata[assetType] = { + fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, + units: [ + { + decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, + symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, + name: TRX_IN_LOCK_PERIOD_METADATA.name, + }, + ], + }; + } + + return inLockPeriodTokensMetadata; + } + + #getBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const bandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + bandwidthTokensMetadata[assetType] = { + fungible: BANDWIDTH_METADATA.fungible, + name: BANDWIDTH_METADATA.name, + symbol: BANDWIDTH_METADATA.symbol, + iconUrl: BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: BANDWIDTH_METADATA.decimals, + symbol: BANDWIDTH_METADATA.symbol, + name: BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return bandwidthTokensMetadata; + } + + #getMaximunBandwidthMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunBandwidthTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunBandwidthTokensMetadata[assetType] = { + fungible: MAX_BANDWIDTH_METADATA.fungible, + name: MAX_BANDWIDTH_METADATA.name, + symbol: MAX_BANDWIDTH_METADATA.symbol, + iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, + units: [ + { + decimals: MAX_BANDWIDTH_METADATA.decimals, + symbol: MAX_BANDWIDTH_METADATA.symbol, + name: MAX_BANDWIDTH_METADATA.name, + }, + ], + }; + } + + return maximunBandwidthTokensMetadata; + } + + #getEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const energyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + energyTokensMetadata[assetType] = { + fungible: ENERGY_METADATA.fungible, + name: ENERGY_METADATA.name, + symbol: ENERGY_METADATA.symbol, + iconUrl: ENERGY_METADATA.iconUrl, + units: [ + { + decimals: ENERGY_METADATA.decimals, + symbol: ENERGY_METADATA.symbol, + name: ENERGY_METADATA.name, + }, + ], + }; + } + + return energyTokensMetadata; + } + + #getMaximunEnergyMetadata( + assetTypes: ResourceCaipAssetType[], + ): Record { + const maximunEnergyTokensMetadata: Record< + CaipAssetType, + FungibleAssetMetadata | null + > = {}; + + for (const assetType of assetTypes) { + maximunEnergyTokensMetadata[assetType] = { + fungible: MAX_ENERGY_METADATA.fungible, + name: MAX_ENERGY_METADATA.name, + symbol: MAX_ENERGY_METADATA.symbol, + iconUrl: MAX_ENERGY_METADATA.iconUrl, + units: [ + { + decimals: MAX_ENERGY_METADATA.decimals, + symbol: MAX_ENERGY_METADATA.symbol, + name: MAX_ENERGY_METADATA.name, + }, + ], + }; + } + + return maximunEnergyTokensMetadata; + } + + async #getTokensMetadata( + assetTypes: TokenCaipAssetType[], + ): Promise> { + return this.#tokenApiClient.getTokensMetadata(assetTypes); + } + + #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { + if (!AssetsService.isFiat(caipAssetType)) { + throw new Error('Passed caipAssetType is not a fiat asset'); + } + + const fiatTicker = + parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); + + return fiatTicker as FiatTicker; + } + + /** + * Fetches fiat exchange rates and crypto prices for the given assets. + * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. + * + * @param allAssets - Array of all CAIP asset types (both fiat and crypto). + * @returns Promise resolving to fiat exchange rates and crypto prices. + */ + async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ + fiatExchangeRates: Record; + cryptoPrices: Record; + }> { + const cryptoAssets = allAssets.filter( + (asset) => !AssetsService.isFiat(asset), + ); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.#priceApiClient.getFiatExchangeRates(), + this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + /** + * Get the token conversions for a list of asset pairs. + * It caches the results for 1 hour. + * + * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. This is not entirely accurate but it's the + * best we can do with the current API. + * + * @param conversions - The asset pairs to get the conversions for. + * @returns The token conversions. + */ async getMultipleTokenConversions( conversions: { from: CaipAssetType; to: CaipAssetType }[], ): Promise< Record> > { - return this.#snapAdapter.getMultipleTokenConversions(conversions); + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + result[from] ??= {}; + + let fromUsdRate: BigNumber; + let toUsdRate: BigNumber; + + if (AssetsService.isFiat(from)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(from)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); + } + + if (AssetsService.isFiat(to)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(to)]?.value; + + if (!fiatExchangeRate) { + result[from][to] = null; + return; + } + + toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); + } + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + }); + + return result; + } + + /** + * Computes the market data object in the target currency. + * + * @param spotPrice - The spot price of the asset in source currency. + * @param rate - The rate to convert the market data to from source currency to target currency. + * @returns The market data in the target currency. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + const toCurrency = (value: number | null | undefined): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; + + const includeIfDefined = ( + key: string, + value: number | null | undefined, + ): Record => { + return value === null || value === undefined ? {} : { [key]: value }; + }; + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), + ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: toCurrency(marketDataInUsd.marketCap), + totalVolume: toCurrency(marketDataInUsd.totalVolume), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), + allTimeLow: toCurrency(marketDataInUsd.allTimeLow), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; } async getMultipleTokensMarketData( @@ -159,9 +900,78 @@ export class AssetsService { ): Promise< Record> > { - return this.#snapAdapter.getMultipleTokensMarketData(assets); + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + let unitUsdRate: BigNumber; + + if (AssetsService.isFiat(unit)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const fiatExchangeRate = + fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; + + if (!fiatExchangeRate) { + return; + } + + unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); + } else { + unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); + } + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + result[assetType] ??= {}; + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; } + /** + * Get historical prices for a token pair by calling the Price API. + * Similar to the Solana snap implementation. + * + * @param from - The asset to get historical prices for. + * @param to - The currency to convert prices to. + * @returns Historical price data with intervals. + */ async getHistoricalPrice( from: CaipAssetType, to: CaipAssetType, @@ -170,6 +980,63 @@ export class AssetsService { updateTime: number; expirationTime?: number; }> { - return this.#snapAdapter.getHistoricalPrice(from, to); + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + assert(toTicker, VsCurrencyParamStruct); + + const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; + + // For each time period, call the Price API to fetch the historical prices + const promises = timePeriodsToFetch.map(async (timePeriod) => + this.#priceApiClient + .getHistoricalPrices({ + assetType: from, + timePeriod, + vsCurrency: toTicker, + }) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch(async (error) => { + await this.#snapClient.trackError(error as Error); + this.#logger.warn( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, + }; + + return result; } } 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 fc5754404..b273636f1 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -5,94 +5,36 @@ import type { KeyringAccount, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { Logger } from '@metamask/snap-networks-utils'; -import type { - AssetConversion, - AssetMetadata, - FungibleAssetMarketData, - FungibleAssetMetadata, - HistoricalPriceIntervals, -} from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; import type { CaipAssetType } from '@metamask/utils'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; -import { pick } from 'lodash'; +import { parseCaipAssetType } from '@metamask/utils'; -import type { PriceApiClient } from '../../../clients/price-api/PriceApiClient'; -import type { - FiatTicker, - SpotPrice, - SpotPrices, -} from '../../../clients/price-api/types'; -import { - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - VsCurrencyParamStruct, -} from '../../../clients/price-api/types'; -import type { SnapClient } from '../../../clients/snap/SnapClient'; -import type { TokenApiClient } from '../../../clients/token-api/TokenApiClient'; import type { AccountResources } from '../../../clients/tron-http'; import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; import type { - Trc20Balance, + RawTronUnfrozenV2, TronAccount, } from '../../../clients/trongrid/types'; import type { KnownCaip19Id, Network } from '../../../constants'; -import { - BANDWIDTH_METADATA, - ENERGY_METADATA, - ESSENTIAL_ASSETS, - MAX_BANDWIDTH_METADATA, - MAX_ENERGY_METADATA, - Networks, - TokenMetadata, - TRX_IN_LOCK_PERIOD_METADATA, - TRX_METADATA, - TRX_READY_FOR_WITHDRAWAL_METADATA, - TRX_STAKED_FOR_BANDWIDTH_METADATA, - TRX_STAKED_FOR_ENERGY_METADATA, - TRX_STAKING_REWARDS_METADATA, -} from '../../../constants'; +import { ESSENTIAL_ASSETS, Networks, TokenMetadata } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { toUiAmount } from '../../../utils/conversion'; -import type { ConfigProvider } from '../../config'; -import type { State, UnencryptedStateValue } from '../../state/State'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; import type { AssetsRepository } from '../AssetsRepository'; -import type { - InLockPeriodCaipAssetType, - NativeCaipAssetType, - NftCaipAssetType, - 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'; +import { isSnapOwnedAsset } from '../snapOwnedAssets'; /** - * Normalized account data structure that provides a consistent shape for both - * active and inactive accounts. This allows extraction functions to work - * without needing to know the account's activation state. + * Slim account data shape for snap-owned asset extraction. + * Provides a consistent shape for both active and inactive accounts. */ -type NormalizedAccountData = { - /** Native TRX balance in sun (0 for inactive accounts). */ - nativeBalance: number; - /** TRC10 token balances as `{ key: tokenId, value: balance }[]` (empty for inactive accounts). */ - trc10Balances: TronAccount['assetV2']; - /** TRC20 token balances from either account info or fallback endpoint. */ - trc20Balances: Trc20Balance[]; +type SnapOwnedAccountData = { /** Staking data including frozen balances and delegated resources. */ - stakedData: StakedData; + stakedData: { + frozenV2: TronAccount['frozenV2']; + unfrozenV2: TronAccount['unfrozenV2']; + accountResource: TronAccount['account_resource'] | undefined; + }; /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ resources: AccountResources | Record; /** Unclaimed staking rewards in sun (0 if no rewards). */ @@ -100,67 +42,29 @@ type NormalizedAccountData = { }; export class SnapAssetsAdapter { - readonly #logger: Logger; + readonly #logger: ILogger; readonly #assetsRepository: AssetsRepository; - readonly #state: State; - readonly #trongridApiClient: TrongridApiClient; readonly #tronHttpClient: TronHttpClient; - readonly #priceApiClient: PriceApiClient; - - readonly #tokenApiClient: TokenApiClient; - - readonly #snapClient: SnapClient; - - readonly #configProvider: ConfigProvider; - - readonly cacheTtlsMilliseconds: { - fiatExchangeRates: number; - spotPrices: number; - historicalPrices: number; - }; - constructor({ logger, assetsRepository, - state, trongridApiClient, tronHttpClient, - priceApiClient, - tokenApiClient, - snapClient, - configProvider, }: { - logger: Logger; + logger: ILogger; assetsRepository: AssetsRepository; - state: State; trongridApiClient: TrongridApiClient; tronHttpClient: TronHttpClient; - priceApiClient: PriceApiClient; - tokenApiClient: TokenApiClient; - snapClient: SnapClient; - configProvider: ConfigProvider; }) { - this.#logger = logger.withPrefix('[🪙 SnapAssetsAdapter]'); + this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); this.#assetsRepository = assetsRepository; - this.#state = state; this.#trongridApiClient = trongridApiClient; this.#tronHttpClient = tronHttpClient; - this.#priceApiClient = priceApiClient; - this.#tokenApiClient = tokenApiClient; - this.#snapClient = snapClient; - this.#configProvider = configProvider; - - const { cacheTtlsMilliseconds } = this.#configProvider.get().priceApi; - this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; - } - - static isFiat(caipAssetId: CaipAssetType): boolean { - return caipAssetId.includes('swift:0/iso4217:'); } async getAccountAssetsByIDs( @@ -184,30 +88,22 @@ export class SnapAssetsAdapter { } /** - * Fetches all assets and balances for an account. + * Fetches snap-owned assets and balances for an account. * * Data Sources: - * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) + * - `getAccountInfoByAddress`: Staking data (active accounts only) * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) - * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) - * - * Logic Flow: - * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) - * 2. Normalize data into consistent shape via `#buildAccountData` - * 3. Extract all assets via `#extractAssets` - * 4. Fetch metadata and prices in parallel - * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` - * 6. Filter spam tokens via `#filterTokensWithoutPriceData` + * - `getReward`: Unclaimed staking rewards * * @param scope - The network to query. * @param account - The keyring account. - * @returns Promise - Array of assets with balances. + * @returns Promise - Array of snap-owned assets with balances. */ - async fetchAssetsAndBalancesForAccount( + async fetchSnapOwnedAssetsForAccount( scope: Network, account: KeyringAccount, ): Promise { - this.#logger.info('Fetching assets and balances by account', { + this.#logger.info('Fetching snap-owned assets and balances by account', { account, scope, }); @@ -222,106 +118,45 @@ export class SnapAssetsAdapter { this.#tronHttpClient.getReward(scope, account.address), ]); - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - if (isInactiveAccount) { + if (tronAccountInfoRequest.status === 'rejected') { this.#logger.info( 'Account info request failed, treating as inactive account', { account, scope }, ); } - const trc20BalancesFallback = isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; - - const accountData = this.#buildAccountData({ + const accountData = this.#buildSnapOwnedAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback, stakingRewardsRequest, }); - const rawAssets = this.#extractAssets(account, scope, accountData); - - const assetTypes = rawAssets.map((asset) => asset.assetType); - const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); - - const [assetsMetadata, spotPrices] = await Promise.all([ - this.getAssetsMetadata(assetTypes), - this.#priceApiClient - .getMultipleSpotPrices(priceableAssetTypes, 'usd') - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - return {}; - }), - ]); - - const enrichedAssets = this.#enrichAssetsWithMetadata( - rawAssets, - assetsMetadata, - ); - return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); - } - - /** - * Filters out spam tokens (those without price data). - * Essential assets are always kept. Tokens need price data to be included. - * - * @param assets - The assets to filter. - * @param spotPrices - Pre-fetched USD prices for assets. - * @returns The filtered assets. - */ - #filterTokensWithoutPriceData( - assets: AssetEntity[], - spotPrices: SpotPrices | Record, - ): AssetEntity[] { - const filtered = assets.filter((asset) => { - // Essential assets (TRX, staked, energy, bandwidth) are always kept - if (ESSENTIAL_ASSETS.includes(asset.assetType)) { - return true; - } - // Tokens: keep only if they have price data - const spotPrice = (spotPrices as SpotPrices)[asset.assetType]; - return typeof spotPrice?.price === 'number'; - }); - - return filtered; + return this.#extractSnapOwnedAssets(account, scope, accountData); } /** - * Normalizes raw API responses into a consistent shape for both active and inactive accounts. - * This allows extraction functions to work without needing to know the account's activation state. + * Normalizes raw API responses into a slim shape for snap-owned asset extraction. * * @param params - The raw API responses to normalize. * @param params.tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. * @param params.tronAccountResourcesRequest - The settled promise result from getAccountResources. - * @param params.trc20BalancesFallback - TRC20 balances from fallback endpoint (empty for active accounts). * @param params.stakingRewardsRequest - The settled promise result from getReward. - * @returns NormalizedAccountData - Consistent data shape for extraction. + * @returns SnapOwnedAccountData - Consistent data shape for snap-owned extraction. */ - #buildAccountData({ + #buildSnapOwnedAccountData({ tronAccountInfoRequest, tronAccountResourcesRequest, - trc20BalancesFallback, stakingRewardsRequest, }: { tronAccountInfoRequest: PromiseSettledResult; tronAccountResourcesRequest: PromiseSettledResult; - trc20BalancesFallback: Trc20Balance[]; stakingRewardsRequest: PromiseSettledResult; - }): NormalizedAccountData { + }): SnapOwnedAccountData { const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - const resources = buildAccountResources(tronAccountResourcesRequest); - const stakedData = buildStakedData(tronAccountInfoRequest); + const resources = + tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; const stakingRewards = stakingRewardsRequest.status === 'fulfilled' ? Math.max(0, stakingRewardsRequest.value) @@ -329,10 +164,11 @@ export class SnapAssetsAdapter { if (isInactiveAccount) { return { - nativeBalance: 0, - trc10Balances: [], - trc20Balances: trc20BalancesFallback, - stakedData, + stakedData: { + frozenV2: [], + unfrozenV2: [], + accountResource: undefined, + }, resources, stakingRewards, }; @@ -340,43 +176,32 @@ export class SnapAssetsAdapter { const tronAccountInfo = tronAccountInfoRequest.value; return { - nativeBalance: tronAccountInfo.balance ?? 0, - trc10Balances: tronAccountInfo.assetV2 ?? [], - trc20Balances: tronAccountInfo.trc20 ?? [], - stakedData, + stakedData: { + frozenV2: tronAccountInfo.frozenV2 ?? [], + unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }, resources, stakingRewards, }; } - /** - * Extracts all assets from normalized account data. - * Coordinates calls to individual extraction functions. - * - * @param account - The keyring account. - * @param scope - The network. - * @param data - Normalized account data. - * @returns AssetEntity[] - Array of all extracted assets. - */ - #extractAssets( + #extractSnapOwnedAssets( account: KeyringAccount, scope: Network, - data: NormalizedAccountData, + data: SnapOwnedAccountData, ): AssetEntity[] { return [ - this.#extractNativeAsset(account, scope, data.nativeBalance), - ...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), - ...extractBandwidth({ + ...this.#extractStakedNativeAssets(account, scope, data.stakedData), + this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), + this.#extractInLockPeriodAsset(account, scope, data.stakedData), + this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), + ...this.#extractBandwidth({ account, scope, tronAccountResources: data.resources, }), - ...extractEnergy({ + ...this.#extractEnergy({ account, scope, tronAccountResources: data.resources, @@ -385,581 +210,282 @@ export class SnapAssetsAdapter { } /** - * Returns the asset types that can be priced (native, TRC10, TRC20). - * Staked, energy, and bandwidth assets have non-compliant CAIP IDs that would fail the Price API. + * Extracts staked TRX assets (for bandwidth and energy). * - * @param assets - Array of assets to filter. - * @returns CaipAssetType[] - Array of priceable asset types. - */ - #getPriceableAssetTypes(assets: AssetEntity[]): CaipAssetType[] { - return assets - .filter( - (asset) => - asset.assetType.includes('/slip44:') || - asset.assetType.includes('/trc10:') || - asset.assetType.includes('/trc20:'), - ) - .map((asset) => asset.assetType); - } - - /** - * Enriches assets with metadata (symbol, decimals, iconUrl) and calculates uiAmount. - * - * @param assets - Raw assets to enrich. - * @param assetsMetadata - Metadata lookup by asset type. - * @returns AssetEntity[] - Enriched assets. + * @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). */ - #enrichAssetsWithMetadata( - assets: AssetEntity[], - assetsMetadata: Record, + #extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: SnapOwnedAccountData['stakedData'], ): AssetEntity[] { - return assets.map((asset) => { - const metadata = assetsMetadata[ - asset.assetType - ] as FungibleAssetMetadata | null; - - const { - symbol: initialSymbol, - decimals: initialDecimals = 0, - iconUrl: initialIconUrl, - } = asset; - let symbol = initialSymbol; - let decimals = initialDecimals; - let iconUrl = initialIconUrl; - - if (metadata?.fungible) { - const unit = metadata.units?.[0]; - if (unit) { - symbol = unit.symbol ?? metadata.symbol ?? symbol; - decimals = unit.decimals ?? decimals; - } else { - symbol = metadata?.symbol ?? symbol; - } - iconUrl = metadata.iconUrl ?? iconUrl; + const 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 uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); + const delegatedBandwidth = + stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; + const delegatedEnergy = + stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; - return { - ...asset, - symbol, - decimals, - uiAmount, - iconUrl, - }; + 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 the native TRX asset from the balance. + * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). * * @param account - The keyring account. * @param scope - The network. - * @param balance - The native balance in sun. - * @returns AssetEntity - The native TRX asset. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). */ - #extractNativeAsset( + #extractReadyForWithdrawalAsset( account: KeyringAccount, scope: Network, - balance: number, + stakedData: SnapOwnedAccountData['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: Networks[scope].nativeToken.id, + assetType: id, keyringAccountId: account.id, network: scope, - symbol: Networks[scope].nativeToken.symbol, - decimals: Networks[scope].nativeToken.decimals, - rawAmount: balance.toString(), - uiAmount: toUiAmount( - balance, - Networks[scope].nativeToken.decimals, - ).toString(), - iconUrl: Networks[scope].nativeToken.iconUrl, + symbol, + decimals, + rawAmount: readyForWithdrawalAmount.toString(), + uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), + iconUrl, }; } /** - * Extracts TRC10 assets from the balances array. + * Extracts staking rewards asset (unclaimed voting rewards). * * @param account - The keyring account. * @param scope - The network. - * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. - * @returns AssetEntity[] - Array of TRC10 asset entities. + * @param stakingRewards - Unclaimed staking rewards in sun. + * @returns AssetEntity - The staking rewards asset. */ - #extractTrc10Assets( + #extractStakingRewardsAsset( account: KeyringAccount, scope: Network, - trc10Balances: TronAccount['assetV2'], - ): AssetEntity[] { - return ( - trc10Balances?.flatMap((tokenObject) => { - // assetV2 has structure: { "key": "token_id", "value": "balance" } - return { - assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: tokenObject.value?.toString() ?? '0', - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }) ?? [] - ); + 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 TRC20 assets from a balances array. - * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). + * 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 trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). - * @returns AssetEntity[] - Array of TRC20 asset entities. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The in-lock-period asset (amount may be 0). */ - #extractTrc20Assets( + #extractInLockPeriodAsset( account: KeyringAccount, scope: Network, - trc20Balances: Trc20Balance[], - ): AssetEntity[] { - return trc20Balances.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }); - } - - async getAssetsMetadata( - assetTypes: CaipAssetType[], - ): Promise> { - this.#logger.info('Fetching metadata for assets', assetTypes); - - const { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - } = this.#splitAssetsByType(assetTypes); - - const nativeTokensMetadata = - this.#getNativeTokensMetadata(nativeAssetTypes); - const stakedTokensMetadata = this.#getStakedTokensMetadata( - stakedNativeAssetTypes, - ); - const readyForWithdrawalTokensMetadata = - this.#getReadyForWithdrawalTokensMetadata(readyForWithdrawalAssetTypes); - const inLockPeriodTokensMetadata = this.#getInLockPeriodMetadata( - inLockPeriodAssetTypes, - ); - const stakingRewardsMetadata = this.#getStakingRewardsMetadata( - stakingRewardsAssetTypes, - ); - const energyTokensMetadata = this.#getEnergyMetadata(energyAssetTypes); - const maximunEnergyTokensMetadata = this.#getMaximunEnergyMetadata( - maximunEnergyAssetTypes, - ); - const bandwidthTokensMetadata = - this.#getBandwidthMetadata(bandwidthAssetTypes); - const maximunBandwidthTokensMetadata = this.#getMaximunBandwidthMetadata( - maximunBandwidthAssetTypes, - ); - const tokensMetadata = await this.#getTokensMetadata([ - ...tokenTrc10AssetTypes, - ...tokenTrc20AssetTypes, - ]); - - const result = { - ...nativeTokensMetadata, - ...stakedTokensMetadata, - ...readyForWithdrawalTokensMetadata, - ...inLockPeriodTokensMetadata, - ...stakingRewardsMetadata, - ...energyTokensMetadata, - ...maximunEnergyTokensMetadata, - ...bandwidthTokensMetadata, - ...maximunBandwidthTokensMetadata, - ...tokensMetadata, - }; + stakedData: SnapOwnedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let inLockPeriodAmount = 0; - this.#logger.info('Resolved assets metadata', { assetTypes, result }); + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; - return result; - } + if (expireTime > currentTimestamp && amount > 0) { + inLockPeriodAmount += amount; + } + }); - #splitAssetsByType(assetTypes: CaipAssetType[]): { - nativeAssetTypes: NativeCaipAssetType[]; - stakedNativeAssetTypes: StakedCaipAssetType[]; - readyForWithdrawalAssetTypes: ReadyForWithdrawalCaipAssetType[]; - inLockPeriodAssetTypes: InLockPeriodCaipAssetType[]; - stakingRewardsAssetTypes: StakingRewardsCaipAssetType[]; - energyAssetTypes: ResourceCaipAssetType[]; - maximunEnergyAssetTypes: ResourceCaipAssetType[]; - bandwidthAssetTypes: ResourceCaipAssetType[]; - maximunBandwidthAssetTypes: ResourceCaipAssetType[]; - tokenTrc10AssetTypes: TokenCaipAssetType[]; - tokenTrc20AssetTypes: TokenCaipAssetType[]; - nftAssetTypes: NftCaipAssetType[]; - } { - const nativeAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195'), - ) as NativeCaipAssetType[]; - const stakedNativeAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/slip44:195-staked-for-'), - ) as StakedCaipAssetType[]; - const readyForWithdrawalAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-ready-for-withdrawal'), - ) as ReadyForWithdrawalCaipAssetType[]; - const inLockPeriodAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-in-lock-period'), - ) as InLockPeriodCaipAssetType[]; - const stakingRewardsAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:195-staking-rewards'), - ) as StakingRewardsCaipAssetType[]; - const energyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:energy'), - ) as ResourceCaipAssetType[]; - const maximunEnergyAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-energy'), - ) as ResourceCaipAssetType[]; - const bandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:bandwidth'), - ) as ResourceCaipAssetType[]; - const maximunBandwidthAssetTypes = assetTypes.filter((assetType) => - assetType.endsWith('/slip44:maximum-bandwidth'), - ) as ResourceCaipAssetType[]; - const tokenTrc10AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc10:'), - ) as TokenCaipAssetType[]; - const tokenTrc20AssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc20:'), - ) as TokenCaipAssetType[]; - const nftAssetTypes = assetTypes.filter((assetType) => - assetType.includes('/trc721:'), - ) as NftCaipAssetType[]; + const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; return { - nativeAssetTypes, - stakedNativeAssetTypes, - readyForWithdrawalAssetTypes, - inLockPeriodAssetTypes, - stakingRewardsAssetTypes, - energyAssetTypes, - maximunEnergyAssetTypes, - bandwidthAssetTypes, - maximunBandwidthAssetTypes, - tokenTrc10AssetTypes, - tokenTrc20AssetTypes, - nftAssetTypes, + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: inLockPeriodAmount.toString(), + uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), + iconUrl, }; } - #getNativeTokensMetadata( - assetTypes: NativeCaipAssetType[], - ): Record { - const nativeTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - nativeTokensMetadata[assetType] = { - fungible: TRX_METADATA.fungible, - name: TRX_METADATA.name, - symbol: TRX_METADATA.symbol, - iconUrl: TRX_METADATA.iconUrl, - units: [ - { - decimals: TRX_METADATA.decimals, - symbol: TRX_METADATA.symbol, - name: TRX_METADATA.name, - }, - ], - }; - } - - return nativeTokensMetadata; - } - - #getStakedTokensMetadata( - assetTypes: StakedCaipAssetType[], - ): Record { - // Can either be Staked for Bandwidth or Staked for Energy - const stakedTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - const isForBandwdidth = assetType.endsWith('staked-for-bandwidth'); - - if (isForBandwdidth) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_BANDWIDTH_METADATA.fungible, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_BANDWIDTH_METADATA.decimals, - symbol: TRX_STAKED_FOR_BANDWIDTH_METADATA.symbol, - name: TRX_STAKED_FOR_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - const isForEnergy = assetType.endsWith('staked-for-energy'); - - if (isForEnergy) { - stakedTokensMetadata[assetType] = { - fungible: TRX_STAKED_FOR_ENERGY_METADATA.fungible, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - iconUrl: TRX_STAKED_FOR_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKED_FOR_ENERGY_METADATA.decimals, - symbol: TRX_STAKED_FOR_ENERGY_METADATA.symbol, - name: TRX_STAKED_FOR_ENERGY_METADATA.name, - }, - ], - }; - } - } - - return stakedTokensMetadata; - } - - #getReadyForWithdrawalTokensMetadata( - assetTypes: ReadyForWithdrawalCaipAssetType[], - ): Record { - const readyForWithdrawalTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - readyForWithdrawalTokensMetadata[assetType] = { - fungible: TRX_READY_FOR_WITHDRAWAL_METADATA.fungible, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - iconUrl: TRX_READY_FOR_WITHDRAWAL_METADATA.iconUrl, - units: [ - { - decimals: TRX_READY_FOR_WITHDRAWAL_METADATA.decimals, - symbol: TRX_READY_FOR_WITHDRAWAL_METADATA.symbol, - name: TRX_READY_FOR_WITHDRAWAL_METADATA.name, - }, - ], - }; - } - - return readyForWithdrawalTokensMetadata; - } - - #getStakingRewardsMetadata( - assetTypes: StakingRewardsCaipAssetType[], - ): Record { - const stakingRewardsMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - stakingRewardsMetadata[assetType] = { - fungible: TRX_STAKING_REWARDS_METADATA.fungible, - name: TRX_STAKING_REWARDS_METADATA.name, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - iconUrl: TRX_STAKING_REWARDS_METADATA.iconUrl, - units: [ - { - decimals: TRX_STAKING_REWARDS_METADATA.decimals, - symbol: TRX_STAKING_REWARDS_METADATA.symbol, - name: TRX_STAKING_REWARDS_METADATA.name, - }, - ], - }; - } - - return stakingRewardsMetadata; - } - - #getInLockPeriodMetadata( - assetTypes: InLockPeriodCaipAssetType[], - ): Record { - const inLockPeriodTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - inLockPeriodTokensMetadata[assetType] = { - fungible: TRX_IN_LOCK_PERIOD_METADATA.fungible, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - iconUrl: TRX_IN_LOCK_PERIOD_METADATA.iconUrl, - units: [ - { - decimals: TRX_IN_LOCK_PERIOD_METADATA.decimals, - symbol: TRX_IN_LOCK_PERIOD_METADATA.symbol, - name: TRX_IN_LOCK_PERIOD_METADATA.name, - }, - ], - }; - } - - return inLockPeriodTokensMetadata; - } - - #getBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const bandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - bandwidthTokensMetadata[assetType] = { - fungible: BANDWIDTH_METADATA.fungible, - name: BANDWIDTH_METADATA.name, - symbol: BANDWIDTH_METADATA.symbol, - iconUrl: BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: BANDWIDTH_METADATA.decimals, - symbol: BANDWIDTH_METADATA.symbol, - name: BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return bandwidthTokensMetadata; - } - - #getMaximunBandwidthMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunBandwidthTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunBandwidthTokensMetadata[assetType] = { - fungible: MAX_BANDWIDTH_METADATA.fungible, - name: MAX_BANDWIDTH_METADATA.name, - symbol: MAX_BANDWIDTH_METADATA.symbol, - iconUrl: MAX_BANDWIDTH_METADATA.iconUrl, - units: [ - { - decimals: MAX_BANDWIDTH_METADATA.decimals, - symbol: MAX_BANDWIDTH_METADATA.symbol, - name: MAX_BANDWIDTH_METADATA.name, - }, - ], - }; - } - - return maximunBandwidthTokensMetadata; - } - - #getEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const energyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - energyTokensMetadata[assetType] = { - fungible: ENERGY_METADATA.fungible, - name: ENERGY_METADATA.name, - symbol: ENERGY_METADATA.symbol, - iconUrl: ENERGY_METADATA.iconUrl, - units: [ - { - decimals: ENERGY_METADATA.decimals, - symbol: ENERGY_METADATA.symbol, - name: ENERGY_METADATA.name, - }, - ], - }; - } - - return energyTokensMetadata; - } + /** + * 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; - #getMaximunEnergyMetadata( - assetTypes: ResourceCaipAssetType[], - ): Record { - const maximunEnergyTokensMetadata: Record< - CaipAssetType, - FungibleAssetMetadata | null - > = {}; - - for (const assetType of assetTypes) { - maximunEnergyTokensMetadata[assetType] = { - fungible: MAX_ENERGY_METADATA.fungible, - name: MAX_ENERGY_METADATA.name, - symbol: MAX_ENERGY_METADATA.symbol, - iconUrl: MAX_ENERGY_METADATA.iconUrl, - units: [ - { - decimals: MAX_ENERGY_METADATA.decimals, - symbol: MAX_ENERGY_METADATA.symbol, - name: MAX_ENERGY_METADATA.name, - }, - ], - }; - } + const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; + const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; + const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; - return maximunEnergyTokensMetadata; - } + const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); - async #getTokensMetadata( - assetTypes: TokenCaipAssetType[], - ): Promise> { - return this.#tokenApiClient.getTokensMetadata(assetTypes); + 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, + }, + ]; } /** - * Checks if the asset has changed compared to passed assets lookup. + * Extracts current and maximum energy from the account resources. * - * @param asset - The asset to check. - * @param assetsLookup - The lookup table to check against. - * @returns True if the asset has changed, false otherwise. + * @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. */ - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); + #extractEnergy({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; + const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; - if (!savedAsset) { - return true; - } + /** + * We might have used more Energy than the maximum allocated + */ + const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); - return savedAsset.rawAmount !== asset.rawAmount; + 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, + }, + ]; } /** @@ -976,12 +502,13 @@ export class SnapAssetsAdapter { async saveMany(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); + const shouldEmitAsset = (asset: AssetEntity): boolean => + isSnapOwnedAsset(asset.assetType); + const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; - const savedAssets = await this.getAll(); - const isEssentialAsset = (asset: AssetEntity): boolean => - ESSENTIAL_ASSETS.includes(asset.assetType); + const savedAssets = await this.#assetsRepository.getAll(); // Track only the account/network pairs refreshed in this run. // That prevents us from treating assets from untouched networks as disappeared. @@ -998,17 +525,17 @@ export class SnapAssetsAdapter { assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), ); - // A saved asset is considered disappeared only if its network was part of - // this sync, it is not essential, and it is missing from the latest - // snapshot for that account. + // A saved snap-owned asset is considered disappeared only if its network was + // part of this sync and it is missing from the latest snapshot for that account. const disappearedAssets = savedAssets.filter((savedAsset) => { const syncedNetworks = syncedNetworksByAccount[savedAsset.keyringAccountId]; - if ( - !syncedNetworks?.has(savedAsset.network) || - isEssentialAsset(savedAsset) - ) { + if (!syncedNetworks?.has(savedAsset.network)) { + return false; + } + + if (!isSnapOwnedAsset(savedAsset.assetType)) { return false; } @@ -1017,43 +544,31 @@ export class SnapAssetsAdapter { ); }); - // A token should be removed from the visible asset list only when the latest - // snapshot says its balance is zero. Essential assets stay visible even at - // zero because they are part of the permanent Tron account model. + // Snap-owned assets stay visible even at zero because they are part of the + // permanent Tron account model managed by the Snap. const shouldBeInRemovedList = (asset: AssetEntity): boolean => - hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list + hasZeroAmount(asset) && !isSnapOwnedAsset(asset.assetType); - // Assets are added to the visible list when they are non-zero and either: - // - we are doing a full non-incremental broadcast, or - // - they are brand new, or - // - they existed before with zero balance and now became non-zero. const shouldBeInAddedList = (asset: AssetEntity): boolean => !shouldBeInRemovedList(asset); - // Build the asset-list payload in two stages: - // 1. seed the removed list with assets that vanished from the latest - // snapshot entirely - // 2. fold in the current assets to report additions and explicit zero-balance - // removals in the same event - const assetListUpdatedPayload = disappearedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); + const assetListUpdatedPayload = disappearedAssets + .filter(shouldEmitAsset) + .reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + added: [...(acc[asset.keyringAccountId]?.added ?? [])], + removed: [ + ...(acc[asset.keyringAccountId]?.removed ?? []), + asset.assetType, + ], + }, + }), + {}, + ); - for (const asset of assets) { - // Merge the current snapshot into the pre-seeded payload so each account - // ends up with one consolidated added/removed diff. + for (const asset of assets.filter(shouldEmitAsset)) { assetListUpdatedPayload[asset.keyringAccountId] = { added: [ ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), @@ -1066,7 +581,6 @@ export class SnapAssetsAdapter { }; } - // If no assets were added or removed, don't emit the event. const isEmptyAccountAssetListUpdatedPayload = Object.values( assetListUpdatedPayload, ) @@ -1079,24 +593,24 @@ export class SnapAssetsAdapter { }); } - // Emit synthetic zero-balance entries for disappeared assets so clients can - // clear cached balances even when the backend omits zero-balance tokens - // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); + const removedAssetsWithZeroBalance = disappearedAssets + .filter(shouldEmitAsset) + .map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); - const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; - // Save assets using repository + const assetsToSave = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ]; await this.#assetsRepository.saveMany(assetsToSave); - // Broadcast the current snapshot plus synthetic zero-balance removals so the - // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = assetsToSave.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( + const balancesUpdatedPayload = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ].reduce( (acc, asset) => ({ ...acc, [asset.keyringAccountId]: { @@ -1110,12 +624,10 @@ export class SnapAssetsAdapter { {}, ); - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .map((accountAssets) => Object.keys(accountAssets).length) .some((count) => count > 0); - // Only emit the event if some balance was changed. if (isSomeBalanceChanged) { await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { balances: balancesUpdatedPayload, @@ -1123,14 +635,6 @@ export class SnapAssetsAdapter { } } - async getAll(): Promise { - const assetsByAccount = - (await this.#state.getKey('assets')) ?? - {}; - - return Object.values(assetsByAccount).flat(); - } - /** * Creates an asset entity with zero balance from a known CAIP-19 asset ID. * Uses pre-calculated metadata from TokenMetadata. @@ -1157,16 +661,26 @@ export class SnapAssetsAdapter { } as AssetEntity; } - async getAccountAssets(accountId: string): Promise { - const savedAssets = await this.#assetsRepository.getByAccountId(accountId); + async getAccountAssetsByScope( + scope: Network, + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); + + const visibleSavedAssets = savedAssets.filter( + (asset) => asset.network === scope, + ); - /** - * Ensure the special assets are always present whether they have been synced or not. - * These are assets that should be visible to the user even with zero balance. - */ const missingEssentialAssets: AssetEntity[] = []; for (const essentialAssetId of ESSENTIAL_ASSETS) { + const { chainId } = parseCaipAssetType(essentialAssetId as CaipAssetType); + + if ((chainId as Network) !== scope) { + continue; + } + const savedAsset = savedAssets.find( (asset) => (asset.assetType as string) === essentialAssetId, ); @@ -1174,384 +688,41 @@ export class SnapAssetsAdapter { if (!savedAsset) { const zeroBalanceAsset = this.#createZeroBalanceAsset( essentialAssetId as KnownCaip19Id, - accountId, + keyringAccountId, ); missingEssentialAssets.push(zeroBalanceAsset); } } - return [...savedAssets, ...missingEssentialAssets]; - } - - /** - * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. - * - * @param caipAssetType - The CAIP-19 asset type. - * @returns The fiat ticker. - */ - #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { - if (!SnapAssetsAdapter.isFiat(caipAssetType)) { - throw new Error('Passed caipAssetType is not a fiat asset'); - } - - const fiatTicker = - parseCaipAssetType(caipAssetType).assetReference.toLowerCase(); - - return fiatTicker as FiatTicker; + return [...visibleSavedAssets, ...missingEssentialAssets]; } - /** - * Fetches fiat exchange rates and crypto prices for the given assets. - * This is shared logic between getMultipleTokenConversions and getMultipleTokensMarketData. - * - * @param allAssets - Array of all CAIP asset types (both fiat and crypto). - * @returns Promise resolving to fiat exchange rates and crypto prices. - */ - async #fetchPriceData(allAssets: CaipAssetType[]): Promise<{ - fiatExchangeRates: Record; - cryptoPrices: Record; - }> { - const cryptoAssets = allAssets.filter( - (asset) => !SnapAssetsAdapter.isFiat(asset), - ); - - const [fiatExchangeRates, cryptoPrices] = await Promise.all([ - this.#priceApiClient.getFiatExchangeRates(), - this.#priceApiClient.getMultipleSpotPrices(cryptoAssets, 'usd'), - ]); - - return { fiatExchangeRates, cryptoPrices }; - } - - /** - * Get the token conversions for a list of asset pairs. - * It caches the results for 1 hour. - * - * Beware: Inside we are using the Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. This is not entirely accurate but it's the - * best we can do with the current API. - * - * @param conversions - The asset pairs to get the conversions for. - * @returns The token conversions. - */ - async getMultipleTokenConversions( - conversions: { from: CaipAssetType; to: CaipAssetType }[], - ): Promise< - Record> - > { - if (conversions.length === 0) { - return {}; - } - - /** - * `from` and `to` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = conversions.flatMap((conversion) => [ - conversion.from, - conversion.to, - ]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); /** - * Now that we have the data, convert the `from`s to `to`s. - * - * We need to handle the following cases: - * 1. `from` and `to` are both fiat - * 2. `from` and `to` are both crypto - * 3. `from` is fiat and `to` is crypto - * 4. `from` is crypto and `to` is fiat - * - * We also need to keep in mind that although `cryptoPrices` are indexed - * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. - * To convert fiat currency symbols to CAIP 19 IDs, we can use the - * `this.#fiatSymbolToCaip19Id` method. + * Ensure the special assets are always present whether they have been synced or not. + * These are assets that should be visible to the user even with zero balance. */ + const missingEssentialAssets: AssetEntity[] = []; - const result: Record< - CaipAssetType, - Record - > = {}; - - conversions.forEach((conversion) => { - const { from, to } = conversion; - - result[from] ??= {}; - - let fromUsdRate: BigNumber; - let toUsdRate: BigNumber; - - if (SnapAssetsAdapter.isFiat(from)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(from)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - fromUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - fromUsdRate = new BigNumber(cryptoPrices[from]?.price ?? 0); - } - - if (SnapAssetsAdapter.isFiat(to)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(to)]?.value; - - if (!fiatExchangeRate) { - result[from][to] = null; - return; - } - - toUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - toUsdRate = new BigNumber(cryptoPrices[to]?.price ?? 0); - } + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); - if (fromUsdRate.isZero() || toUsdRate.isZero()) { - result[from][to] = null; - return; + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); } - - const rate = fromUsdRate.dividedBy(toUsdRate).toString(); - - const now = Date.now(); - - result[from][to] = { - rate, - conversionTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - }); - - return result; - } - - /** - * Computes the market data object in the target currency. - * - * @param spotPrice - The spot price of the asset in source currency. - * @param rate - The rate to convert the market data to from source currency to target currency. - * @returns The market data in the target currency. - */ - #computeMarketData( - spotPrice: SpotPrice, - rate: BigNumber, - ): FungibleAssetMarketData { - const marketDataInUsd = pick(spotPrice, [ - 'marketCap', - 'totalVolume', - 'circulatingSupply', - 'allTimeHigh', - 'allTimeLow', - 'pricePercentChange1h', - 'pricePercentChange1d', - 'pricePercentChange7d', - 'pricePercentChange14d', - 'pricePercentChange30d', - 'pricePercentChange200d', - 'pricePercentChange1y', - ]); - - const toCurrency = (value: number | null | undefined): string => { - return value === null || value === undefined - ? '' - : new BigNumber(value).dividedBy(rate).toString(); - }; - - const includeIfDefined = ( - key: string, - value: number | null | undefined, - ): Record => { - return value === null || value === undefined ? {} : { [key]: value }; - }; - - // Variations in percent don't need to be converted, they are independent of the currency - const pricePercentChange = { - ...includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), - ...includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), - ...includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), - ...includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), - ...includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), - ...includeIfDefined('P200D', marketDataInUsd.pricePercentChange200d), - ...includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), - }; - - const marketDataInToCurrency = { - fungible: true, - marketCap: toCurrency(marketDataInUsd.marketCap), - totalVolume: toCurrency(marketDataInUsd.totalVolume), - circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert - allTimeHigh: toCurrency(marketDataInUsd.allTimeHigh), - allTimeLow: toCurrency(marketDataInUsd.allTimeLow), - // Add pricePercentChange field only if it has values - ...(Object.keys(pricePercentChange).length > 0 - ? { pricePercentChange } - : {}), - } as FungibleAssetMarketData; - - return marketDataInToCurrency; - } - - async getMultipleTokensMarketData( - assets: { - asset: CaipAssetType; - unit: CaipAssetType; - }[], - ): Promise< - Record> - > { - if (assets.length === 0) { - return {}; } - /** - * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values - * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, - * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency - * to convert the prices to the correct currency. - */ - const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); - - const { fiatExchangeRates, cryptoPrices } = - await this.#fetchPriceData(allAssets); - - const result: Record< - CaipAssetType, - Record - > = {}; - - assets.forEach((asset) => { - const { asset: assetType, unit } = asset; - - // Skip if we don't have price data for the asset - if (!cryptoPrices[assetType]) { - return; - } - - let unitUsdRate: BigNumber; - - if (SnapAssetsAdapter.isFiat(unit)) { - /** - * Beware: - * We need to invert the fiat exchange rate because exchange rate != spot price - */ - const fiatExchangeRate = - fiatExchangeRates[this.#extractFiatTicker(unit)]?.value; - - if (!fiatExchangeRate) { - return; - } - - unitUsdRate = new BigNumber(1).dividedBy(fiatExchangeRate); - } else { - unitUsdRate = new BigNumber(cryptoPrices[unit]?.price ?? 0); - } - - if (unitUsdRate.isZero()) { - return; - } - - // Initialize the nested structure for the asset if it doesn't exist - result[assetType] ??= {}; - - // Store the market data with the unit as the key - result[assetType][unit] = this.#computeMarketData( - cryptoPrices[assetType], - unitUsdRate, - ); - }); - - return result; - } - - /** - * Get historical prices for a token pair by calling the Price API. - * Similar to the Solana snap implementation. - * - * @param from - The asset to get historical prices for. - * @param to - The currency to convert prices to. - * @returns Historical price data with intervals. - */ - async getHistoricalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise<{ - intervals: HistoricalPriceIntervals; - updateTime: number; - expirationTime?: number; - }> { - assert(from, CaipAssetTypeStruct); - assert(to, CaipAssetTypeStruct); - - const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); - assert(toTicker, VsCurrencyParamStruct); - - const timePeriodsToFetch = ['1d', '7d', '1m', '3m', '1y', '1000y']; - - // For each time period, call the Price API to fetch the historical prices - const promises = timePeriodsToFetch.map(async (timePeriod) => - this.#priceApiClient - .getHistoricalPrices({ - assetType: from, - timePeriod, - vsCurrency: toTicker, - }) - // Wrap the response in an object with the time period and the response for easier reducing - .then((response) => ({ - timePeriod, - response, - })) - // Gracefully handle individual errors to avoid breaking the entire operation - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, - error, - ); - return { - timePeriod, - response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - }; - }), - ); - - const wrappedHistoricalPrices = await Promise.all(promises); - - const intervals = wrappedHistoricalPrices.reduce( - (acc, { timePeriod, response }) => { - const iso8601Interval = `P${timePeriod.toUpperCase()}`; - acc[iso8601Interval] = response.prices.map((price) => [ - price[0], - price[1].toString(), - ]); - return acc; - }, - {}, - ); - - const now = Date.now(); - - const result = { - intervals, - updateTime: now, - expirationTime: now + this.cacheTtlsMilliseconds.historicalPrices, - }; - - return result; + return [...savedAssets, ...missingEssentialAssets]; } } diff --git a/packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts b/packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts index 83f3bbdb5..857810d25 100644 --- a/packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts +++ b/packages/tron-wallet-snap/src/services/confirmation/ConfirmationHandler.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import { InternalError } from '@metamask/snaps-sdk'; import { assert } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; @@ -8,7 +8,6 @@ import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import { Networks, ZERO } from '../../constants'; import type { Network } from '../../constants'; -import type { AssetEntity } from '../../entities/assets'; import type { TronKeyringAccount } from '../../entities/keyring-account'; import { TronMultichainMethod } from '../../handlers/keyring/keyring-types'; import { TRX_IMAGE_SVG } from '../../static/tron-logo'; @@ -25,7 +24,6 @@ import { formatOrigin } from '../../utils/formatOrigin'; import { SignTransactionRequestStruct } from '../../validation/structs'; import type { TronWalletKeyringRequest } from '../../validation/structs'; import { assertTransactionStructure } from '../../validation/transaction'; -import type { AssetsService } from '../assets/AssetsService'; import type { FeeCalculatorService } from '../send/FeeCalculatorService'; import type { ComputeFeeResult } from '../send/types'; import type { State, UnencryptedStateValue } from '../state/State'; @@ -39,7 +37,7 @@ export class ConfirmationHandler { readonly #tronWebFactory: TronWebFactory; - readonly #assetsService: AssetsService; + readonly #assetsProvider: AssetsProvider; readonly #feeCalculatorService: FeeCalculatorService; @@ -47,14 +45,14 @@ export class ConfirmationHandler { snapClient, state, tronWebFactory, - assetsService, + assetsProvider, feeCalculatorService, logger, }: { snapClient: SnapClient; state: State; tronWebFactory: TronWebFactory; - assetsService: AssetsService; + assetsProvider: AssetsProvider; feeCalculatorService: FeeCalculatorService; logger: Logger; }) { @@ -62,7 +60,7 @@ export class ConfirmationHandler { this.#snapClient = snapClient; this.#state = state; this.#tronWebFactory = tronWebFactory; - this.#assetsService = assetsService; + this.#assetsProvider = assetsProvider; this.#feeCalculatorService = feeCalculatorService; } @@ -163,7 +161,7 @@ export class ConfirmationHandler { toAddress: string; amount: string; fees: ComputeFeeResult; - asset: AssetEntity; + asset: NonNullable>>; accountType: string; origin: string; transactionRawData: TronwebTypes.Transaction['raw_data']; @@ -236,16 +234,16 @@ export class ConfirmationHandler { ); const [bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(account.id, [ + await this.#assetsProvider.getAccountAssetsByIDs(account.id, [ Networks[scope].bandwidth.id, Networks[scope].energy.id, ]); const availableEnergy = energyAsset - ? new BigNumber(energyAsset.rawAmount) + ? new BigNumber(energyAsset.balance.amount) : ZERO; const availableBandwidth = bandwidthAsset - ? new BigNumber(bandwidthAsset.rawAmount) + ? new BigNumber(bandwidthAsset.balance.amount) : ZERO; const fees = await this.#feeCalculatorService.computeFee({ diff --git a/packages/tron-wallet-snap/src/services/send/SendService.ts b/packages/tron-wallet-snap/src/services/send/SendService.ts index 3eb6c53af..92161cd5d 100644 --- a/packages/tron-wallet-snap/src/services/send/SendService.ts +++ b/packages/tron-wallet-snap/src/services/send/SendService.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@metamask/snap-networks-utils'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { TronWeb, Types as TronwebTypes } from 'tronweb'; @@ -7,13 +7,11 @@ import type { SnapClient } from '../../clients/snap/SnapClient'; import type { TronWebFactory } from '../../clients/tronweb/TronWebFactory'; import type { Network } from '../../constants'; import { Networks, TRACK_TX_INTERVAL, ZERO } from '../../constants'; -import type { AssetEntity } from '../../entities/assets'; import { SendErrorCodes } from '../../handlers/clientRequest/types'; import { BackgroundEventMethod } from '../../handlers/cronjob/cronjob'; -import { toRawAmount, trxToSun } from '../../utils/conversion'; +import { trxToSun } from '../../utils/conversion'; import { assertTransactionSignerConsistency } from '../../validation/transaction'; import type { AccountsService } from '../accounts/AccountsService'; -import type { AssetsService } from '../assets/AssetsService'; import type { TransactionExpirationRefresherService } from '../transaction-expiration-refresher/TransactionExpirationRefresherService'; import type { FeeCalculatorService } from './FeeCalculatorService'; import type { SendValidationResult } from './types'; @@ -21,7 +19,7 @@ import type { SendValidationResult } from './types'; export class SendService { readonly #accountsService: AccountsService; - readonly #assetsService: AssetsService; + readonly #assetsProvider: AssetsProvider; readonly #tronWebFactory: TronWebFactory; @@ -35,7 +33,7 @@ export class SendService { constructor({ accountsService, - assetsService, + assetsProvider, tronWebFactory, feeCalculatorService, logger, @@ -43,7 +41,7 @@ export class SendService { transactionExpirationRefresherService, }: { accountsService: AccountsService; - assetsService: AssetsService; + assetsProvider: AssetsProvider; tronWebFactory: TronWebFactory; feeCalculatorService: FeeCalculatorService; logger: Logger; @@ -51,7 +49,7 @@ export class SendService { transactionExpirationRefresherService: TransactionExpirationRefresherService; }) { this.#accountsService = accountsService; - this.#assetsService = assetsService; + this.#assetsProvider = assetsProvider; this.#tronWebFactory = tronWebFactory; this.#feeCalculatorService = feeCalculatorService; this.#logger = logger.withPrefix('[💸 SendService]'); @@ -85,7 +83,7 @@ export class SendService { scope: Network; fromAccountId: string; toAddress: string; - asset: AssetEntity; + asset: NonNullable>>; amount: BigNumber; feeLimit?: number; }): Promise { @@ -104,7 +102,7 @@ export class SendService { * Get the user's current balances for the asset being sent and TRX (for fees). */ const [assetBalance, nativeTokenAsset, bandwidthAsset, energyAsset] = - await this.#assetsService.getAccountAssetsByIDs(fromAccountId, [ + await this.#assetsProvider.getAccountAssetsByIDs(fromAccountId, [ asset.assetType, nativeTokenId, Networks[scope].bandwidth.id, @@ -112,16 +110,16 @@ export class SendService { ]); const assetToSendBalance = assetBalance - ? new BigNumber(assetBalance.uiAmount) + ? new BigNumber(assetBalance.balance.amount) : ZERO; const nativeTokenBalance = nativeTokenAsset - ? new BigNumber(nativeTokenAsset.uiAmount) + ? new BigNumber(nativeTokenAsset.balance.amount) : ZERO; const availableBandwidth = bandwidthAsset - ? new BigNumber(bandwidthAsset.rawAmount) + ? new BigNumber(bandwidthAsset.balance.amount) : ZERO; const availableEnergy = energyAsset - ? new BigNumber(energyAsset.rawAmount) + ? new BigNumber(energyAsset.balance.amount) : ZERO; /** @@ -214,7 +212,7 @@ export class SendService { }: { fromAccountId: string; toAddress: string; - asset: AssetEntity; + asset: NonNullable>>; amount: BigNumber; feeLimit?: number; }): Promise< @@ -246,7 +244,7 @@ export class SendService { toAddress, amount, tokenId: assetReference, - decimals: asset.decimals, + decimals: asset.metadata.decimals, feeLimit, }); @@ -258,7 +256,7 @@ export class SendService { toAddress, contractAddress: assetReference, amount, - decimals: asset.decimals, + decimals: asset.metadata.decimals, feeLimit, }); diff --git a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx index c877771e7..257d19d47 100644 --- a/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx +++ b/packages/tron-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -88,12 +88,12 @@ export async function render( feesFetchStatus: FetchStatus.Initial, }; - const { assetsService, feeCalculatorService, priceApiClient } = snapContext; + const { assetsProvider, feeCalculatorService, priceApiClient } = snapContext; // Parallelize: Get preferences + Fetch account assets const [preferences, accountAssets] = await Promise.all([ snapClient.getPreferences().catch(() => DEFAULT_CONTEXT.preferences), - assetsService.getAccountAssetsByIDs(account.id, [ + assetsProvider.getAccountAssetsByIDs(account.id, [ Networks[scope as Network].nativeToken.id, Networks[scope as Network].bandwidth.id, Networks[scope as Network].energy.id, @@ -110,10 +110,10 @@ export async function render( const [nativeTokenAsset, bandwidthAsset, energyAsset] = accountAssets; const availableEnergy = energyAsset - ? new BigNumber(energyAsset.rawAmount) + ? new BigNumber(energyAsset.balance.amount) : ZERO; const availableBandwidth = bandwidthAsset - ? new BigNumber(bandwidthAsset.rawAmount) + ? new BigNumber(bandwidthAsset.balance.amount) : ZERO; // Build transaction object from raw data @@ -162,7 +162,7 @@ export async function render( )?.asset.amount ?? '0', ); const trxBalance = nativeTokenAsset - ? sunToTrx(nativeTokenAsset.rawAmount) + ? new BigNumber(nativeTokenAsset.balance.amount) : ZERO; context.isInsufficientBalance = getTransactionTrxValue(rawData) .plus(trxFee)