diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0b9a6b87..176edbf2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -269,6 +269,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/explicit-function-return-type": { "count": 3 @@ -277,7 +287,7 @@ "count": 1 } }, - "packages/tron-wallet-snap/src/services/assets/AssetsService.ts": { + "packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts": { "import-x/no-extraneous-dependencies": { "count": 1 } diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 6b3e76de..c8d1f0a9 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -12,10 +12,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. -### Fixed +### Changed + +- 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)) + +### Removed -- Fix `bip44:discover` always failing due to `Network` enum being compiled bidirectionally by TypeScript when initialised from another enum's members, causing `Object.values(Network)` to include human-readable names (`"Mainnet"` etc.) alongside scope IDs; replaced enum initialisers with string literals so TypeScript emits a one-way mapping. ([#101](https://github.com/MetaMask/internal-snaps/pull/101)) +- 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] diff --git a/packages/tron-wallet-snap/package.json b/packages/tron-wallet-snap/package.json index f9891e88..94e1120b 100644 --- a/packages/tron-wallet-snap/package.json +++ b/packages/tron-wallet-snap/package.json @@ -47,11 +47,16 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/snap-networks-utils": "workspace:^" + }, "devDependencies": { + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^23.7.0", "@metamask/keyring-snap-sdk": "^9.2.1", + "@metamask/messenger": "^2.0.0", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.0", "@metamask/snaps-sdk": "^11.2.0", diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 470b80d4..b3528eca 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "kpXPl/gHuE6MQIAy2ZxMvI3XByIC/Gj7xss6WPFWCe4=", + "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": [ { @@ -63,6 +48,13 @@ }, "endowment:assets": { "scopes": ["tron:728126428"] + }, + "endowment:messenger": { + "actions": [ + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope" + ] } }, "platformVersion": "11.2.0", diff --git a/packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts b/packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts index 4f42f0b8..4f7a92d0 100644 --- a/packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts +++ b/packages/tron-wallet-snap/src/clients/price-api/PriceApiClient.ts @@ -7,7 +7,7 @@ import { mapKeys } from 'lodash'; import type { ICache } from '../../caching/ICache'; import { useCache } from '../../caching/useCache'; -import { SPECIAL_ASSETS } from '../../constants'; +import { SNAP_OWNED_ASSETS } from '../../constants'; import type { ConfigProvider } from '../../services/config'; import { buildUrl } from '../../utils/buildUrl'; import type { ILogger } from '../../utils/logger'; @@ -257,7 +257,7 @@ export class PriceApiClient { assert(vsCurrency, VsCurrencyParamStruct); const filteredTokens = tokenCaip19Types.filter( - (tokenCaip19Type) => !SPECIAL_ASSETS.includes(tokenCaip19Type), + (tokenCaip19Type) => !SNAP_OWNED_ASSETS.includes(tokenCaip19Type), ); return this.#getMultipleSpotPrices_CACHE(filteredTokens, vsCurrency); diff --git a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts index 0d3c1c93..b40710fc 100644 --- a/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts +++ b/packages/tron-wallet-snap/src/clients/token-api/TokenApiClient.ts @@ -3,7 +3,7 @@ import { array, assert } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; -import { Network, SPECIAL_ASSETS } from '../../constants'; +import { Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { TokenCaipAssetType } from '../../services/assets/types'; import { TokenCaipAssetTypeStruct } from '../../services/assets/types'; import type { ConfigProvider } from '../../services/config'; @@ -94,7 +94,7 @@ export class TokenApiClient { * Exclude TRON resource tokens (energy and bandwidth), staked tokens, and tokens not from supported networks. */ const supportedAssetTypes = assetTypes.filter((assetType) => { - if (SPECIAL_ASSETS.includes(assetType)) { + if (SNAP_OWNED_ASSETS.includes(assetType)) { return false; } const { chainId } = parseCaipAssetType(assetType); diff --git a/packages/tron-wallet-snap/src/constants/index.ts b/packages/tron-wallet-snap/src/constants/index.ts index 9efe87a6..c6339e1f 100644 --- a/packages/tron-wallet-snap/src/constants/index.ts +++ b/packages/tron-wallet-snap/src/constants/index.ts @@ -388,7 +388,7 @@ export const Networks = { }, } as const; -export const SPECIAL_ASSETS: string[] = [ +export const SNAP_OWNED_ASSETS: string[] = [ KnownCaip19Id.TrxStakedForBandwidthMainnet, KnownCaip19Id.TrxStakedForBandwidthNile, KnownCaip19Id.TrxStakedForBandwidthShasta, @@ -418,9 +418,12 @@ export const SPECIAL_ASSETS: string[] = [ KnownCaip19Id.MaximumEnergyShasta, ]; +/** @deprecated Use {@link SNAP_OWNED_ASSETS} instead. */ +export const SPECIAL_ASSETS = SNAP_OWNED_ASSETS; + export const ESSENTIAL_ASSETS: string[] = [ KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxNile, KnownCaip19Id.TrxShasta, - ...SPECIAL_ASSETS, + ...SNAP_OWNED_ASSETS, ]; diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index db5c9641..b0c25170 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -1,3 +1,11 @@ +import { getMessenger } from '@metamask/snaps-sdk'; +import { + AssetsProvider, + RemoteFeatureFlagsProvider, + type AssetsProviderMessenger, + type RemoteFeatureFlagsProviderMessenger, +} from '@metamask/snap-networks-utils'; + import { InMemoryCache } from './caching/InMemoryCache'; import { StateCache } from './caching/StateCache'; import { PriceApiClient } from './clients/price-api/PriceApiClient'; @@ -29,6 +37,7 @@ import { TransactionScanService } from './services/transaction-scan/TransactionS import { TransactionsRepository } from './services/transactions/TransactionsRepository'; import { TransactionsService } from './services/transactions/TransactionsService'; import { WalletService } from './services/wallet/WalletService'; +import type { CoreMessenger, CoreMessengerMessenger } from './types/core-messenger'; import logger, { noOpLogger } from './utils/logger'; /** @@ -82,22 +91,32 @@ const priceApiClient = new PriceApiClient(configProvider, priceCache); // Token API client const tokenApiClient = new TokenApiClient(configProvider); +const coreMessenger = getMessenger(); + +const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, +}); + +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); + // Security Alerts API client const securityAlertsApiClient = new SecurityAlertsApiClient( configProvider, logger, ); -// Business Services - depend on Repositories, State and other Services +// Business Services const assetsService = new AssetsService({ logger, - state, assetsRepository, trongridApiClient, tronHttpClient, priceApiClient, tokenApiClient, snapClient, + assetsProvider, }); const transactionsService = new TransactionsService({ @@ -235,6 +254,12 @@ export type SnapExecutionContext = { confirmationHandler: ConfirmationHandler; transactionScanService: TransactionScanService; transactionExpirationRefresherService: TransactionExpirationRefresherService; + /** + * Core messenger plumbing (routing wired in a follow-up PR). + */ + coreMessenger: CoreMessenger; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + assetsProvider: AssetsProvider; /** * Handlers */ @@ -267,6 +292,9 @@ const snapContext: SnapExecutionContext = { confirmationHandler, transactionScanService, transactionExpirationRefresherService, + coreMessenger, + remoteFeatureFlagsProvider, + assetsProvider, /** * Handlers */ 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 e8bdd0a7..967b12cb 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -21,7 +21,6 @@ import { 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'; @@ -119,9 +118,7 @@ type WithAccountsServiceCallback = (payload: { >; mockConfigProvider: jest.Mocked>; mockLogger: ILogger; - mockAssetsService: jest.Mocked< - Pick - >; + mockAssetsService: jest.Mocked>; mockSnapClient: jest.Mocked< Pick >; @@ -268,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< @@ -1194,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', @@ -1206,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 }) => { @@ -1225,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], ); }, ); @@ -1266,10 +1242,10 @@ describe('AccountsService', () => { await accountsService.synchronizeAssets([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).not.toHaveBeenCalled(); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + expect(mockAssetsService.syncSnapOwnedAssets).toHaveBeenCalledWith( + [account], + [], + ); }, ); }); @@ -1358,9 +1334,10 @@ 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); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 3d9bd1b7..711fe1d3 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -522,24 +522,7 @@ export class AccountsService { */ 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); + await this.#assetsService.syncSnapOwnedAssets(accounts, scopes); } async synchronizeTransactions(accounts: TronKeyringAccount[]): Promise { diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts index b41e8c7d..7272e6b8 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -1,3 +1,4 @@ +import type { Asset, Caip19AssetId } from '@metamask/assets-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; @@ -9,21 +10,13 @@ import type { TokenApiClient } from '../../clients/token-api/TokenApiClient'; import type { AccountResources, TronHttpClient } from '../../clients/tron-http'; import { TrongridAccountNotFoundError } from '../../clients/trongrid/errors'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { Trc20Balance, TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network } from '../../constants'; +import type { TronAccount } from '../../clients/trongrid/types'; +import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; +import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; import type { 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: { @@ -51,6 +44,51 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const { AssetsService } = require('./AssetsService'); +function createMessengerCallMock( + getAccountAssetByID: jest.Mock, + getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), + getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), +): CoreMessengerCaller['call'] { + return async (actionType, ...args) => { + switch (actionType) { + case 'AssetsController:getAccountAssetByID': + return getAccountAssetByID(...args); + case 'AssetsController:getAccountAssetsByIDs': + return getAccountAssetsByIDs(...args); + case 'AssetsController:getAccountAssetsByScope': + return getAccountAssetsByScope(...args); + default: + return undefined; + } + }; +} + +function buildControllerAsset( + assetId: string, + amount: string, + metadata: { + symbol: string; + name: string; + decimals: number; + image?: string; + }, +): Asset { + return { + id: assetId as Asset['id'], + chainId: Network.Mainnet as Asset['chainId'], + balance: { amount }, + metadata: { + type: 'fungible', + symbol: metadata.symbol, + name: metadata.name, + decimals: metadata.decimals, + image: metadata.image, + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as Asset; +} + const mockAccount: KeyringAccount = { id: 'test-account-id', address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', @@ -164,12 +202,12 @@ type WithAssetsServiceCallback = (payload: { Pick< AssetsRepository, | 'saveMany' + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' > >; - mockState: MockState; mockTrongridApiClient: jest.Mocked< Pick< TrongridApiClient, @@ -187,6 +225,7 @@ type WithAssetsServiceCallback = (payload: { >; mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; + mockCoreMessenger: jest.Mocked; }) => Promise | ReturnValue; /** @@ -203,6 +242,7 @@ async function withAssetsService( const mockAssetsRepository: jest.Mocked< Pick< AssetsRepository, + | 'getAll' | 'getByAccountId' | 'getByAccountIdAndAssetType' | 'getByAccountIdAndAssetTypes' @@ -210,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, @@ -259,92 +294,98 @@ async function withAssetsService( trackError: jest.fn().mockResolvedValue(undefined), }; + const mockGetAccountAssetByID = jest.fn(); + const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); + const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); + const mockCoreMessenger: jest.Mocked = { + call: jest + .fn() + .mockImplementation( + createMessengerCallMock( + mockGetAccountAssetByID, + mockGetAccountAssetsByIDs, + mockGetAccountAssetsByScope, + ), + ), + }; + + 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 assetsService = new AssetsService({ logger: mockLogger, assetsRepository: mockAssetsRepository, - state: mockState, trongridApiClient: mockTrongridApiClient, tronHttpClient: mockTronHttpClient, priceApiClient: mockPriceApiClient, tokenApiClient: mockTokenApiClient, snapClient: mockSnapClient, + assetsProvider, }); return await testFunction({ assetsService, mockAssetsRepository, - mockState, mockTrongridApiClient, mockTronHttpClient, mockPriceApiClient, mockTokenApiClient, mockSnapClient, + mockCoreMessenger, }); } +/** + * 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 () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const expectedTrc20AssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const trc20Asset = assets.find( - (asset: AssetEntity) => - asset.assetType === expectedTrc20AssetType, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); - }, - ); - }); - - it('returns zero TRX and resources when fallback also returns empty', async () => { + it('returns zero snap-owned resources when account info fails (inactive account)', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -354,215 +395,39 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue( emptyAccountResources, ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - 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'); + ).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('gracefully handles fallback endpoint failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - new Error('Network error'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('tracks fallback endpoint errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const error = new Error('Network error'); - - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new Error('Account not found or no data returned'), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockRejectedValue( - error, - ); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); - - it('filters out TRC20 tokens without price data from inactive account', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const trc20BalancesWithSpam: Trc20Balance[] = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, // USDT - has price - { TSpamToken123456789: '1000000000' }, // Spam token - no price - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20BalancesWithSpam, - ); - - const usdtAssetId = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const usdtAssetType = `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - const usdtAsset = assets.find( - (asset: AssetEntity) => asset.assetType === usdtAssetType, - ); - expect(usdtAsset).toBeDefined(); - - const spamAssetType = `${String(Network.Mainnet)}/trc20:TSpamToken123456789`; - const spamAsset = assets.find( - (asset: AssetEntity) => asset.assetType === spamAssetType, - ); - expect(spamAsset).toBeUndefined(); + expect( + assets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(false); }, ); }); }); describe('partial failure handling', () => { - it('uses fallback when account info fails even if resources succeed (inactive account)', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new 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`; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('100000'); - }, - ); - }); - it('continues with zero resources when only resources request fails', async () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -577,59 +442,26 @@ describe('AssetsService', () => { new Error('Resources endpoint unavailable'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('1000000'); + expect( + assets.some( + (asset) => asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).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'); }, ); }); - - it('tracks spot price errors', async () => { - await withAssetsService( - async ({ - assetsService, - mockSnapClient, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - const error = new Error('Spot price endpoint unavailable'); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockPriceApiClient.getMultipleSpotPrices.mockRejectedValue(error); - - await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledWith(error); - }, - ); - }); }); describe('bandwidth', () => { @@ -637,6 +469,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -645,9 +478,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -661,6 +494,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -671,9 +505,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 200 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -687,6 +521,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -697,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( @@ -713,6 +548,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -727,9 +563,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -745,6 +581,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -753,9 +590,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -770,6 +607,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -780,9 +618,9 @@ describe('AssetsService', () => { getMockAccountResources({}), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -797,6 +635,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -807,9 +646,9 @@ describe('AssetsService', () => { getMockAccountResources({ NetLimit: 48 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -826,6 +665,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -837,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( @@ -856,6 +696,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -870,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( @@ -889,6 +730,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -906,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( @@ -925,6 +767,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -941,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( @@ -960,6 +803,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -979,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( @@ -1000,6 +844,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1011,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( @@ -1030,6 +875,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1047,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( @@ -1066,6 +912,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1083,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( @@ -1102,6 +949,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1124,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( @@ -1143,6 +991,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1162,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( @@ -1181,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( @@ -1213,6 +1060,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1221,9 +1069,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1237,6 +1085,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1247,9 +1096,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1263,6 +1112,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1273,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( @@ -1289,6 +1139,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1299,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( @@ -1317,6 +1168,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1325,9 +1177,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1341,6 +1193,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1351,9 +1204,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1369,6 +1222,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1378,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( @@ -1395,6 +1249,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1404,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( @@ -1424,6 +1279,7 @@ describe('AssetsService', () => { await withAssetsService( async ({ assetsService, + mockAssetsRepository, mockTrongridApiClient, mockTronHttpClient, }) => { @@ -1435,9 +1291,9 @@ describe('AssetsService', () => { new Error('API Error'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, + const assets = await syncAndGetSavedAssets( + assetsService, + mockAssetsRepository, ); expect( @@ -1448,930 +1304,55 @@ 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.TrxMainnet, - 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`; - 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(await assetsService.getAll()).toStrictEqual(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`; - 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, - }); - - // If an asset is missing from the received list - // - emits the event 'notify:accountAssetListUpdated' with the asset in the 'removed' property - // - emits the event 'notify:accountBalancesUpdated' with the balance for the removed asset sets to 0 - await assetsService.saveMany(updatedAssets); - - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - 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.TrxMainnet, - 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.TrxMainnet, - 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.TrxMainnet, - 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.TrxMainnet, - 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.TrxMainnet, - 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`; - - 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`; - - 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.TrxMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.BandwidthMainnet, - trc20AssetId, - ]), - 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.TrxMainnet, - 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', - }, - }, + 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: [], + }, }, }, ); @@ -2379,9 +1360,15 @@ describe('AssetsService', () => { ); }); - it('updates bandwidth 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 }) => { + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; const savedAssets: AssetEntity[] = [ { assetType: KnownCaip19Id.TrxMainnet, @@ -2394,285 +1381,167 @@ describe('AssetsService', () => { 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, + assetType: trc20AssetId, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'TRX', + symbol: 'USDT', decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '4700', - uiAmount: '4700', + 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, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, - }, - }, + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 329 }), ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - expect.anything(), - KeyringEvent.AccountBalancesUpdated, - { - balances: { - [mockAccount.id]: { - [KnownCaip19Id.TrxMainnet]: { - unit: 'TRX', - amount: '1', - }, - [KnownCaip19Id.BandwidthMainnet]: { - unit: 'BANDWIDTH', - amount: '4700', - }, - }, - }, - }, + 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('updates TRC20 token balance when it decreases but remains >0', async () => { + it('keeps maximum energy and bandwidth assets even with zero amounts', async () => { await withAssetsService( - async ({ assetsService, mockState, mockAssetsRepository }) => { - const trc20AssetId = `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`; - - const savedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '1000000', - uiAmount: '1', - iconUrl: '', - }, - { - assetType: trc20AssetId, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'USDT', - decimals: 6, - rawAmount: '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', - 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, - trc20AssetId, - ]), - 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 energy 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.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: '', - }, - ]; + 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.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.TrxReadyForWithdrawalMainnet, + ), + ).toBe(true); }, ); }); - it('keeps bandwidth in the list when it drops 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: '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: '', - }, + it('emits balance updates when snap-owned energy increases', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + const savedAssets: AssetEntity[] = [ { - assetType: KnownCaip19Id.BandwidthMainnet, + assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, - symbol: 'BANDWIDTH', + symbol: 'ENERGY', decimals: 0, rawAmount: '0', uiAmount: '0', @@ -2680,27 +1549,30 @@ describe('AssetsService', () => { }, ]; - mockState.getKey.mockResolvedValue({ - [mockAccount.id]: savedAssets, - }); - - await assetsService.saveMany(updatedAssets); + mockAssetsRepository.getAll.mockResolvedValue(savedAssets); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 50000 }), + ); - expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( - updatedAssets, + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), - KeyringEvent.AccountAssetListUpdated, + KeyringEvent.AccountBalancesUpdated, { - assets: { - [mockAccount.id]: { - added: expect.arrayContaining([ - KnownCaip19Id.TrxMainnet, - KnownCaip19Id.BandwidthMainnet, - ]), - removed: [], - }, + balances: { + [mockAccount.id]: expect.objectContaining({ + [KnownCaip19Id.EnergyMainnet]: { + unit: 'ENERGY', + amount: '50000', + }, + }), }, }, ); @@ -2708,99 +1580,41 @@ describe('AssetsService', () => { ); }); - it('handles both energy and bandwidth fluctuating in a transaction', async () => { + it('emits balance updates when snap-owned energy decreases but remains >0', 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', - iconUrl: '', - }, - ]; - - const updatedAssets: AssetEntity[] = [ - { - assetType: KnownCaip19Id.TrxMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'TRX', - decimals: 6, - rawAmount: '2000000', - uiAmount: '2', - iconUrl: '', - }, { assetType: KnownCaip19Id.EnergyMainnet, keyringAccountId: mockAccount.id, network: Network.Mainnet, symbol: 'ENERGY', decimals: 0, - rawAmount: '45000', - uiAmount: '45000', - iconUrl: '', - }, - { - assetType: KnownCaip19Id.BandwidthMainnet, - keyringAccountId: mockAccount.id, - network: Network.Mainnet, - symbol: 'BANDWIDTH', - decimals: 0, - rawAmount: '1235', - uiAmount: '1235', + 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( @@ -2808,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', }, - }, + }), }, }, ); @@ -2830,4 +1636,458 @@ describe('AssetsService', () => { }); }); }); + + describe('getAssetsMetadata', () => { + it('resolves metadata for native, protocol, and token asset types', async () => { + await withAssetsService(async ({ assetsService, mockTokenApiClient }) => { + const trc20 = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as TokenCaipAssetType; + const trc10 = `${Network.Mainnet}/trc10:1002000` as TokenCaipAssetType; + + mockTokenApiClient.getTokensMetadata.mockResolvedValue({ + [trc20]: { + fungible: { symbol: 'USDT', name: 'Tether', decimals: 6 }, + }, + [trc10]: { + fungible: { symbol: 'T', name: 'Token', decimals: 0 }, + }, + } as never); + + const assetTypes = [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxStakedForBandwidthMainnet, + KnownCaip19Id.TrxStakedForEnergyMainnet, + KnownCaip19Id.TrxReadyForWithdrawalMainnet, + KnownCaip19Id.TrxInLockPeriodMainnet, + KnownCaip19Id.TrxStakingRewardsMainnet, + KnownCaip19Id.EnergyMainnet, + KnownCaip19Id.MaximumEnergyMainnet, + KnownCaip19Id.BandwidthMainnet, + KnownCaip19Id.MaximumBandwidthMainnet, + trc10, + trc20, + ]; + + const metadata = await assetsService.getAssetsMetadata(assetTypes); + + expect(metadata[KnownCaip19Id.TrxMainnet]?.symbol).toBe('TRX'); + expect(metadata[KnownCaip19Id.EnergyMainnet]?.symbol).toBe('ENERGY'); + expect(metadata[trc20]?.fungible?.symbol).toBe('USDT'); + expect(mockTokenApiClient.getTokensMetadata).toHaveBeenCalledWith([ + trc10, + trc20, + ]); + }); + }); + }); + + describe('AssetsController routing', () => { + const accountId = mockAccount.id; + const fungibleAssetId = KnownCaip19Id.TrxMainnet; + 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, + ); + + 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( + jest.fn().mockResolvedValue( + buildControllerAsset(fungibleAssetId, '2000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + ), + ), + ); + + const asset = await assetsService.getAccountAssetByID( + accountId, + fungibleAssetId, + ); + + expect(asset).toMatchObject({ + assetType: fungibleAssetId, + rawAmount: '2000000', + uiAmount: '2', + }); + }); + }); + + it('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( + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [trx as Caip19AssetId]: buildControllerAsset(trx, '1000000', { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }), + [usdt as Caip19AssetId]: buildControllerAsset(usdt, '500000', { + symbol: 'USDT', + name: 'Tether', + decimals: 6, + }), + }; + }), + ), + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + trx, + usdt, + ]); + + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [trx, usdt], + ); + expect(results[0]?.rawAmount).toBe('1000000'); + expect(results[1]?.rawAmount).toBe('500000'); + }); + }); + + it('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, 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( + jest.fn(), + jest.fn().mockImplementation(async () => { + return { + [fungibleAssetId as Caip19AssetId]: buildControllerAsset( + fungibleAssetId, + '3000000', + { + symbol: 'TRX', + name: 'TRON', + decimals: 6, + }, + ), + }; + }), + ), + ); + + const results = await assetsService.getAccountAssetsByIDs(accountId, [ + fungibleAssetId, + snapAssetId, + ]); + + expect(results[0]?.rawAmount).toBe('3000000'); + expect(results[1]).toStrictEqual(snapAsset); + expect(mockCoreMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAccountAssetsByIDs', + accountId, + [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('syncSnapOwnedAssets emits only snap-owned assets', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockAssetsRepository.getAll.mockResolvedValue([]); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + minimalTronAccount, + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + getMockAccountResources({ EnergyLimit: 100 }), + ); + + await assetsService.syncSnapOwnedAssets( + [mockAccount], + [Network.Mainnet], + ); + + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + expect.objectContaining({ + assets: expect.objectContaining({ + [accountId]: expect.objectContaining({ + added: expect.arrayContaining([snapAssetId]), + }), + }), + }), + ); + }, + ); + }); + }); + + 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('facade delegation', () => { + it('routes fungible reads through AssetsProvider and keeps handler logic in AssetsService', async () => { + await withAssetsService( + async ({ + assetsService, + mockAssetsRepository, + mockCoreMessenger, + mockPriceApiClient, + }) => { + const snapAsset: AssetEntity = { + assetType: KnownCaip19Id.EnergyMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'ENERGY', + decimals: 0, + rawAmount: '1', + uiAmount: '1', + }; + + 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]: { + id: KnownCaip19Id.TrxMainnet, + price: 1, + }, + }), + ); + + expect(AssetsService.isFiat('eip155:1/erc20:0x0')).toBe(false); + expect(AssetsService.isFiat('swift:0/iso4217:usd')).toBe(true); + + expect( + await assetsService.getAccountAssetByID( + mockAccount.id, + KnownCaip19Id.TrxMainnet, + ), + ).toMatchObject({ + assetType: KnownCaip19Id.TrxMainnet, + rawAmount: '1', + }); + expect( + 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 a33f16e5..3fb0c3b6 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -1,10 +1,6 @@ -import { KeyringEvent } from '@metamask/keyring-api'; -import type { - AccountAssetListUpdatedEvent, - AccountBalancesUpdatedEvent, - KeyringAccount, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { Caip19AssetId } from '@metamask/assets-controller'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; import type { AssetConversion, AssetMetadata, @@ -19,34 +15,21 @@ import { BigNumber } from 'bignumber.js'; import { pick } from 'lodash'; import type { PriceApiClient } from '../../clients/price-api/PriceApiClient'; -import type { - FiatTicker, - SpotPrice, - SpotPrices, -} from '../../clients/price-api/types'; +import 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 { AccountResources } from '../../clients/tron-http'; import type { TronHttpClient } from '../../clients/tron-http/TronHttpClient'; import type { TrongridApiClient } from '../../clients/trongrid/TrongridApiClient'; -import type { - RawTronUnfrozenV2, - Trc20Balance, - TronAccount, -} from '../../clients/trongrid/types'; -import type { KnownCaip19Id, Network } from '../../constants'; +import { 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, @@ -56,11 +39,12 @@ import { } from '../../constants'; import { configProvider } from '../../context'; import type { AssetEntity } from '../../entities/assets'; -import { toUiAmount } from '../../utils/conversion'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; -import type { State, UnencryptedStateValue } from '../state/State'; +import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; import type { AssetsRepository } from './AssetsRepository'; +import { mapControllerAsset } from './mapControllerAsset'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; import type { InLockPeriodCaipAssetType, NativeCaipAssetType, @@ -72,47 +56,19 @@ import type { TokenCaipAssetType, } from './types'; -/** - * Normalized account data structure that provides a consistent shape for both - * active and inactive accounts. This allows extraction functions to work - * without needing to know the account's activation state. - */ -type NormalizedAccountData = { - /** Native TRX balance in sun (0 for inactive accounts). */ - nativeBalance: number; - /** TRC10 token balances as `{ key: tokenId, value: balance }[]` (empty for inactive accounts). */ - trc10Balances: TronAccount['assetV2']; - /** TRC20 token balances from either account info or fallback endpoint. */ - trc20Balances: Trc20Balance[]; - /** Staking data including frozen balances and delegated resources. */ - stakedData: { - frozenV2: TronAccount['frozenV2']; - unfrozenV2: TronAccount['unfrozenV2']; - accountResource: TronAccount['account_resource'] | undefined; - }; - /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ - resources: AccountResources | Record; - /** Unclaimed staking rewards in sun (0 if no rewards). */ - stakingRewards: number; -}; - export class AssetsService { 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 #snapAdapter: SnapAssetsAdapter; + + readonly #assetsProvider: AssetsProvider; + readonly cacheTtlsMilliseconds: { fiatExchangeRates: number; spotPrices: number; @@ -122,705 +78,197 @@ export class AssetsService { constructor({ logger, assetsRepository, - state, trongridApiClient, tronHttpClient, priceApiClient, tokenApiClient, snapClient, + assetsProvider, }: { logger: ILogger; assetsRepository: AssetsRepository; - state: State; trongridApiClient: TrongridApiClient; tronHttpClient: TronHttpClient; priceApiClient: PriceApiClient; tokenApiClient: TokenApiClient; snapClient: SnapClient; + assetsProvider: AssetsProvider; }) { this.#logger = createPrefixedLogger(logger, '[🪙 AssetsService]'); - this.#assetsRepository = assetsRepository; - this.#state = state; - this.#trongridApiClient = trongridApiClient; - this.#tronHttpClient = tronHttpClient; this.#priceApiClient = priceApiClient; this.#tokenApiClient = tokenApiClient; this.#snapClient = snapClient; + this.#assetsProvider = assetsProvider; + this.#snapAdapter = new SnapAssetsAdapter({ + logger, + assetsRepository, + trongridApiClient, + tronHttpClient, + }); const { cacheTtlsMilliseconds } = configProvider.get().priceApi; this.cacheTtlsMilliseconds = cacheTtlsMilliseconds; } - static isFiat(caipAssetId: CaipAssetType): boolean { - return caipAssetId.includes('swift:0/iso4217:'); - } - - async getAccountAssets(accountId: string): Promise { - return this.#assetsRepository.getByAccountId(accountId); - } - - async getAccountAssetsByIDs( + async #getProviderAccountAssetByID( accountId: string, - assetTypes: string[], - ): Promise<(AssetEntity | null)[]> { - return this.#assetsRepository.getByAccountIdAndAssetTypes( + assetId: string, + ): Promise { + const asset = await this.#assetsProvider.getAccountAssetByID( accountId, - assetTypes, + assetId as Caip19AssetId, ); + + if (!asset) { + return null; + } + + return mapControllerAsset(accountId, asset); } - async getAccountAssetByID( + async #getProviderAccountAssetsByIDs( accountId: string, - assetType: string, - ): Promise { - return this.#assetsRepository.getByAccountIdAndAssetType( + assetIds: string[], + ): Promise> { + const controllerAssets = await this.#assetsProvider.getAccountAssetsByIDs( accountId, - assetType, + assetIds as Caip19AssetId[], + ); + + return Object.fromEntries( + assetIds.map((assetId) => { + const controllerAsset = controllerAssets[assetId as Caip19AssetId]; + return [ + assetId, + controllerAsset + ? mapControllerAsset(accountId, controllerAsset) + : null, + ]; + }), ); } - /** - * Fetches all assets and balances for an account. - * - * Data Sources: - * - `getAccountInfoByAddress`: TRX balance, TRC10 tokens, TRC20 tokens (active accounts only) - * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) - * - `getTrc20BalancesByAddress`: TRC20 balances fallback (works for inactive accounts) - * - * Logic Flow: - * 1. Fetch account info, resources, and TRC20 fallback (for inactive accounts) - * 2. Normalize data into consistent shape via `#buildAccountData` - * 3. Extract all assets via `#extractAssets` - * 4. Fetch metadata and prices in parallel - * 5. Enrich assets with metadata via `#enrichAssetsWithMetadata` - * 6. Filter spam tokens via `#filterTokensWithoutPriceData` - * - * @param scope - The network to query. - * @param account - The keyring account. - * @returns Promise - Array of assets with balances. - */ - async fetchAssetsAndBalancesForAccount( + async #getProviderAccountAssetsByScope( scope: Network, - account: KeyringAccount, + accountId: string, ): Promise { - this.#logger.info('Fetching assets and balances by account', { - account, + const controllerAssets = await this.#assetsProvider.getAccountAssetsByScope( scope, - }); - - const [ - tronAccountInfoRequest, - tronAccountResourcesRequest, - stakingRewardsRequest, - ] = await Promise.allSettled([ - this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), - this.#tronHttpClient.getAccountResources(scope, account.address), - this.#tronHttpClient.getReward(scope, account.address), - ]); - - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - if (isInactiveAccount) { - this.#logger.info( - 'Account info request failed, treating as inactive account', - { account, scope }, - ); - } - - const trc20BalancesFallback = isInactiveAccount - ? await this.#trongridApiClient - .getTrc20BalancesByAddress(scope, account.address) - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - this.#logger.warn( - 'Failed to fetch TRC20 balances for inactive account', - { error, account, scope }, - ); - return []; - }) - : []; - - const accountData = this.#buildAccountData({ - tronAccountInfoRequest, - tronAccountResourcesRequest, - trc20BalancesFallback, - stakingRewardsRequest, - }); - - const rawAssets = this.#extractAssets(account, scope, accountData); - - const assetTypes = rawAssets.map((asset) => asset.assetType); - const priceableAssetTypes = this.#getPriceableAssetTypes(rawAssets); - - const [assetsMetadata, spotPrices] = await Promise.all([ - this.getAssetsMetadata(assetTypes), - this.#priceApiClient - .getMultipleSpotPrices(priceableAssetTypes, 'usd') - .catch(async (error) => { - await this.#snapClient.trackError(error as Error); - return {}; - }), - ]); + accountId, + ); - const enrichedAssets = this.#enrichAssetsWithMetadata( - rawAssets, - assetsMetadata, + return Object.values(controllerAssets).map((asset) => + mapControllerAsset(accountId, asset), ); - 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; + static isFiat(caipAssetId: CaipAssetType): boolean { + return caipAssetId.includes('swift:0/iso4217:'); } - /** - * Normalizes raw API responses into a consistent shape for both active and inactive accounts. - * This allows extraction functions to work without needing to know the account's activation state. - * - * @param params - The raw API responses to normalize. - * @param params.tronAccountInfoRequest - The settled promise result from getAccountInfoByAddress. - * @param params.tronAccountResourcesRequest - The settled promise result from getAccountResources. - * @param params.trc20BalancesFallback - TRC20 balances from fallback endpoint (empty for active accounts). - * @param params.stakingRewardsRequest - The settled promise result from getReward. - * @returns NormalizedAccountData - Consistent data shape for extraction. - */ - #buildAccountData({ - tronAccountInfoRequest, - tronAccountResourcesRequest, - trc20BalancesFallback, - stakingRewardsRequest, - }: { - tronAccountInfoRequest: PromiseSettledResult; - tronAccountResourcesRequest: PromiseSettledResult; - trc20BalancesFallback: Trc20Balance[]; - stakingRewardsRequest: PromiseSettledResult; - }): NormalizedAccountData { - const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; - const resources = - tronAccountResourcesRequest.status === 'fulfilled' - ? tronAccountResourcesRequest.value - : {}; - const stakingRewards = - stakingRewardsRequest.status === 'fulfilled' - ? Math.max(0, stakingRewardsRequest.value) - : 0; - - if (isInactiveAccount) { - return { - nativeBalance: 0, - trc10Balances: [], - trc20Balances: trc20BalancesFallback, - stakedData: { - frozenV2: [], - unfrozenV2: [], - accountResource: undefined, - }, - resources, - stakingRewards, - }; + async getAccountAssetByID( + accountId: string, + assetId: string, + ): Promise { + if (isSnapOwnedAsset(assetId)) { + return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } - const tronAccountInfo = tronAccountInfoRequest.value; - return { - nativeBalance: tronAccountInfo.balance ?? 0, - trc10Balances: tronAccountInfo.assetV2 ?? [], - trc20Balances: tronAccountInfo.trc20 ?? [], - stakedData: { - frozenV2: tronAccountInfo.frozenV2 ?? [], - unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], - accountResource: tronAccountInfo.account_resource, - }, - resources, - stakingRewards, - }; - } - - /** - * Extracts all assets from normalized account data. - * Coordinates calls to individual extraction functions. - * - * @param account - The keyring account. - * @param scope - The network. - * @param data - Normalized account data. - * @returns AssetEntity[] - Array of all extracted assets. - */ - #extractAssets( - account: KeyringAccount, - scope: Network, - data: NormalizedAccountData, - ): AssetEntity[] { - return [ - this.#extractNativeAsset(account, scope, data.nativeBalance), - ...this.#extractStakedNativeAssets(account, scope, data.stakedData), - this.#extractReadyForWithdrawalAsset(account, scope, data.stakedData), - this.#extractInLockPeriodAsset(account, scope, data.stakedData), - this.#extractStakingRewardsAsset(account, scope, data.stakingRewards), - ...this.#extractTrc10Assets(account, scope, data.trc10Balances), - ...this.#extractTrc20Assets(account, scope, data.trc20Balances), - ...this.#extractBandwidth({ - account, - scope, - tronAccountResources: data.resources, - }), - ...this.#extractEnergy({ - account, - scope, - tronAccountResources: data.resources, - }), - ]; - } - - /** - * Returns the asset types that can be priced (native, TRC10, TRC20). - * Staked, energy, and bandwidth assets have non-compliant CAIP IDs that would fail the Price API. - * - * @param assets - Array of assets to filter. - * @returns CaipAssetType[] - Array of priceable asset types. - */ - #getPriceableAssetTypes(assets: AssetEntity[]): CaipAssetType[] { - return assets - .filter( - (asset) => - asset.assetType.includes('/slip44:') || - asset.assetType.includes('/trc10:') || - asset.assetType.includes('/trc20:'), - ) - .map((asset) => asset.assetType); + return this.#getProviderAccountAssetByID(accountId, assetId); } - /** - * Enriches assets with metadata (symbol, decimals, iconUrl) and calculates uiAmount. - * - * @param assets - Raw assets to enrich. - * @param assetsMetadata - Metadata lookup by asset type. - * @returns AssetEntity[] - Enriched assets. - */ - #enrichAssetsWithMetadata( - assets: AssetEntity[], - assetsMetadata: Record, - ): AssetEntity[] { - return assets.map((asset) => { - const metadata = assetsMetadata[ - asset.assetType - ] as FungibleAssetMetadata | null; - - const { - symbol: initialSymbol, - decimals: initialDecimals = 0, - iconUrl: initialIconUrl, - } = asset; - let symbol = initialSymbol; - let decimals = initialDecimals; - let iconUrl = initialIconUrl; - - if (metadata?.fungible) { - const unit = metadata.units?.[0]; - if (unit) { - symbol = unit.symbol ?? metadata.symbol ?? symbol; - decimals = unit.decimals ?? decimals; - } else { - symbol = metadata?.symbol ?? symbol; - } - iconUrl = metadata.iconUrl ?? iconUrl; - } - - const uiAmount = toUiAmount(asset.rawAmount, decimals).toString(); - - return { - ...asset, - symbol, - decimals, - uiAmount, - iconUrl, - }; - }); - } - - /** - * Extracts the native TRX asset from the balance. - * - * @param account - The keyring account. - * @param scope - The network. - * @param balance - The native balance in sun. - * @returns AssetEntity - The native TRX asset. - */ - #extractNativeAsset( - account: KeyringAccount, - scope: Network, - balance: number, - ): AssetEntity { - return { - assetType: Networks[scope].nativeToken.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].nativeToken.symbol, - decimals: Networks[scope].nativeToken.decimals, - rawAmount: balance.toString(), - uiAmount: toUiAmount( - balance, - Networks[scope].nativeToken.decimals, - ).toString(), - iconUrl: Networks[scope].nativeToken.iconUrl, - }; - } - - /** - * Extracts staked TRX assets (for bandwidth and energy). - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakedData - Staking data including frozen balances and delegated resources. - * @returns AssetEntity[] - Array of staked assets (always 2: bandwidth and energy, amounts may be 0). - */ - #extractStakedNativeAssets( - account: KeyringAccount, - scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity[] { - const assets: AssetEntity[] = []; - - let stakedBandwidthAmount = 0; - let stakedEnergyAmount = 0; - - stakedData.frozenV2?.forEach((frozen) => { - const amount = frozen.amount ?? 0; + async getAccountAssetsByIDs( + accountId: string, + assetIds: string[], + ): Promise<(AssetEntity | null)[]> { + if (assetIds.length === 0) { + return []; + } - if (frozen.type === 'ENERGY') { - stakedEnergyAmount += amount; - } else if (!frozen.type) { - // Item without type is for bandwidth - stakedBandwidthAmount += amount; + const result: (AssetEntity | null)[] = new Array(assetIds.length).fill( + null, + ); + const fungibleIds: string[] = []; + const fungibleIndices: number[] = []; + + 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); } - }); - - const delegatedBandwidth = - stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; - const delegatedEnergy = - stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; - - stakedBandwidthAmount += delegatedBandwidth; - stakedEnergyAmount += delegatedEnergy; - - assets.push({ - assetType: Networks[scope].stakedForBandwidth.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakedForBandwidth.symbol, - decimals: Networks[scope].stakedForBandwidth.decimals, - rawAmount: stakedBandwidthAmount.toString(), - uiAmount: toUiAmount( - stakedBandwidthAmount, - Networks[scope].stakedForBandwidth.decimals, - ).toString(), - iconUrl: Networks[scope].stakedForBandwidth.iconUrl, - }); - - assets.push({ - assetType: Networks[scope].stakedForEnergy.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakedForEnergy.symbol, - decimals: Networks[scope].stakedForEnergy.decimals, - rawAmount: stakedEnergyAmount.toString(), - uiAmount: toUiAmount( - stakedEnergyAmount, - Networks[scope].stakedForEnergy.decimals, - ).toString(), - iconUrl: Networks[scope].stakedForEnergy.iconUrl, - }); - - return assets; - } + } - /** - * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakedData - Staking data including unfrozen balances. - * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). - */ - #extractReadyForWithdrawalAsset( - account: KeyringAccount, - scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity { - const currentTimestamp = Date.now(); - let readyForWithdrawalAmount = 0; + if (fungibleIds.length === 0) { + return result; + } - stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { - const expireTime = unfrozen.unfreeze_expire_time ?? 0; - const amount = unfrozen.unfreeze_amount ?? 0; + const fungibleResults = await this.#getProviderAccountAssetsByIDs( + accountId, + fungibleIds, + ); - if (expireTime <= currentTimestamp && amount > 0) { - readyForWithdrawalAmount += amount; + fungibleIds.forEach((assetId, fungibleIndex) => { + const resultIndex = fungibleIndices[fungibleIndex]; + if (resultIndex !== undefined) { + result[resultIndex] = fungibleResults[assetId] ?? null; } }); - const { id, symbol, decimals, iconUrl } = - Networks[scope].readyForWithdrawal; - - return { - assetType: id, - keyringAccountId: account.id, - network: scope, - symbol, - decimals, - rawAmount: readyForWithdrawalAmount.toString(), - uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), - iconUrl, - }; - } - - /** - * Extracts staking rewards asset (unclaimed voting rewards). - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakingRewards - Unclaimed staking rewards in sun. - * @returns AssetEntity - The staking rewards asset. - */ - #extractStakingRewardsAsset( - account: KeyringAccount, - scope: Network, - stakingRewards: number, - ): AssetEntity { - return { - assetType: Networks[scope].stakingRewards.id, - keyringAccountId: account.id, - network: scope, - symbol: Networks[scope].stakingRewards.symbol, - decimals: Networks[scope].stakingRewards.decimals, - rawAmount: stakingRewards.toString(), - uiAmount: toUiAmount( - stakingRewards, - Networks[scope].stakingRewards.decimals, - ).toString(), - iconUrl: Networks[scope].stakingRewards.iconUrl, - }; + return result; } - /** - * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). - * This represents TRX that the user has initiated unstaking for but must wait - * the 14-day lock period before they can withdraw. - * - * @param account - The keyring account. - * @param scope - The network. - * @param stakedData - Staking data including unfrozen balances. - * @returns AssetEntity - The in-lock-period asset (amount may be 0). - */ - #extractInLockPeriodAsset( - account: KeyringAccount, + async getAccountAssetsByScope( scope: Network, - stakedData: NormalizedAccountData['stakedData'], - ): AssetEntity { - const currentTimestamp = Date.now(); - let inLockPeriodAmount = 0; - - stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { - const expireTime = unfrozen.unfreeze_expire_time ?? 0; - const amount = unfrozen.unfreeze_amount ?? 0; - - if (expireTime > currentTimestamp && amount > 0) { - inLockPeriodAmount += amount; - } - }); - - const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; - - return { - assetType: id, - keyringAccountId: account.id, - network: scope, - symbol, - decimals, - rawAmount: inLockPeriodAmount.toString(), - uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), - iconUrl, - }; - } - - /** - * Extracts current and maximum bandwidth from the account resources. - * - * @param options - Options object. - * @param options.account - The account to extract bandwidth for. - * @param options.scope - The network to extract bandwidth for. - * @param options.tronAccountResources - The account resources to extract bandwidth for. - * @returns The bandwidth assets. - */ - #extractBandwidth({ - account, - scope, - tronAccountResources, - }: { - account: KeyringAccount; - scope: Network; - tronAccountResources: AccountResources | Record; - }): AssetEntity[] { - const freeBandwidth = tronAccountResources?.freeNetLimit ?? 0; - const stakingBandwidth = tronAccountResources?.NetLimit ?? 0; - const maximumBandwidth = freeBandwidth + stakingBandwidth; - - const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; - const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; - const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; - - const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); + accountId: string, + ): Promise { + const snapAssets = await this.#snapAdapter.getAccountAssetsByScope( + scope, + accountId, + ); + const snapOwnedAssets = snapAssets.filter((asset) => + isSnapOwnedAsset(asset.assetType), + ); + const coreAssets = await this.#getProviderAccountAssetsByScope( + scope, + accountId, + ); 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, - }, + ...coreAssets.filter((asset) => !isSnapOwnedAsset(asset.assetType)), + ...snapOwnedAssets, ]; } - /** - * Extracts current and maximum energy from the account resources. - * - * @param options - Options object. - * @param options.account - The keyring account. - * @param options.scope - The network. - * @param options.tronAccountResources - Account resources (energy, bandwidth). - * @returns AssetEntity[] - Array containing energy and maximum energy assets. - */ - #extractEnergy({ - account, - scope, - tronAccountResources, - }: { - account: KeyringAccount; - scope: Network; - tronAccountResources: AccountResources | Record; - }): AssetEntity[] { - const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; - const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; - - /** - * We might have used more Energy than the maximum allocated - */ - const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); + async getByKeyringAccountId(accountId: string): Promise { + const assets = await this.#snapAdapter.getAccountAssetsByScope( + Network.Mainnet, + accountId, + ); - 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, - }, - ]; + return assets.filter((asset) => isSnapOwnedAsset(asset.assetType)); } - /** - * Extracts TRC10 assets from the balances array. - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc10Balances - TRC10 token balances as `{ key: tokenId, value: balance }[]`. - * @returns AssetEntity[] - Array of TRC10 asset entities. - */ - #extractTrc10Assets( - account: KeyringAccount, - scope: Network, - trc10Balances: TronAccount['assetV2'], - ): AssetEntity[] { - return ( - trc10Balances?.flatMap((tokenObject) => { - // assetV2 has structure: { "key": "token_id", "value": "balance" } - return { - assetType: `${scope}/trc10:${tokenObject.key}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: tokenObject.value?.toString() ?? '0', - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }) ?? [] + async syncSnapOwnedAssets( + accounts: KeyringAccount[], + scopes: Network[], + ): Promise { + const combinations = accounts.flatMap((account) => + scopes.map((scope) => ({ account, scope })), ); - } - - /** - * Extracts TRC20 assets from a balances array. - * Works with both active accounts (tronAccountInfo.trc20) and inactive accounts (getTrc20BalancesByAddress). - * - * @param account - The keyring account. - * @param scope - The network. - * @param trc20Balances - Array of `Record` objects (e.g., `[{ "TContractAddr": "1000" }]`). - * @returns AssetEntity[] - Array of TRC20 asset entities. - */ - #extractTrc20Assets( - account: KeyringAccount, - scope: Network, - trc20Balances: Trc20Balance[], - ): AssetEntity[] { - return trc20Balances.flatMap((tokenObject) => { - return Object.entries(tokenObject).map(([address, balance]) => { - return { - assetType: `${scope}/trc20:${address}` as TokenCaipAssetType, - keyringAccountId: account.id, - network: scope, - symbol: '', - decimals: 0, - rawAmount: balance, - uiAmount: '0', - iconUrl: '', // Will be enriched with metadata later - }; - }); - }); + 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( @@ -1226,257 +674,6 @@ export class AssetsService { return this.#tokenApiClient.getTokensMetadata(assetTypes); } - /** - * Checks if the asset has changed compared to passed assets lookup. - * - * @param asset - The asset to check. - * @param assetsLookup - The lookup table to check against. - * @returns True if the asset has changed, false otherwise. - */ - static hasChanged(asset: AssetEntity, assetsLookup: AssetEntity[]): boolean { - const savedAsset = assetsLookup.find( - (item) => - item.keyringAccountId === asset.keyringAccountId && - item.assetType === asset.assetType, - ); - - if (!savedAsset) { - return true; - } - - return savedAsset.rawAmount !== asset.rawAmount; - } - - /** - * Persist the latest fetched assets and emit the corresponding keyring events. - * - * The input is treated as the latest snapshot for the account/network pairs - * included in this sync. The method compares that snapshot with the - * previously saved state to detect disappeared assets, emits asset-list - * updates, and emits balance updates including synthetic zero balances for - * assets that vanished from the latest response. - * - * @param assets - The latest asset snapshot returned by the refresh flow. - */ - async saveMany(assets: AssetEntity[]): Promise { - this.#logger.info('Saving assets', assets); - - const hasZeroAmount = (asset: AssetEntity): boolean => - asset.rawAmount === '0' || asset.uiAmount === '0'; - - const savedAssets = await this.getAll(); - const isEssentialAsset = (asset: AssetEntity): boolean => - ESSENTIAL_ASSETS.includes(asset.assetType); - - // Track only the account/network pairs refreshed in this run. - // That prevents us from treating assets from untouched networks as disappeared. - const syncedNetworksByAccount = assets.reduce>>( - (acc, asset) => { - acc[asset.keyringAccountId] ??= new Set(); - acc[asset.keyringAccountId]?.add(asset.network); - return acc; - }, - {}, - ); - - const incomingAssetKeys = new Set( - assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), - ); - - // A saved asset is considered disappeared only if its network was part of - // this sync, it is not essential, and it is missing from the latest - // snapshot for that account. - const disappearedAssets = savedAssets.filter((savedAsset) => { - const syncedNetworks = - syncedNetworksByAccount[savedAsset.keyringAccountId]; - - if ( - !syncedNetworks?.has(savedAsset.network) || - isEssentialAsset(savedAsset) - ) { - return false; - } - - return !incomingAssetKeys.has( - `${savedAsset.keyringAccountId}:${savedAsset.assetType}`, - ); - }); - - // A token should be removed from the visible asset list only when the latest - // snapshot says its balance is zero. Essential assets stay visible even at - // zero because they are part of the permanent Tron account model. - const shouldBeInRemovedList = (asset: AssetEntity): boolean => - hasZeroAmount(asset) && !isEssentialAsset(asset); // Never remove essential assets (including energy & bandwidth) from the account asset list - - // Assets are added to the visible list when they are non-zero and either: - // - we are doing a full non-incremental broadcast, or - // - they are brand new, or - // - they existed before with zero balance and now became non-zero. - const shouldBeInAddedList = (asset: AssetEntity): boolean => - !shouldBeInRemovedList(asset); - - // Build the asset-list payload in two stages: - // 1. seed the removed list with assets that vanished from the latest - // snapshot entirely - // 2. fold in the current assets to report additions and explicit zero-balance - // removals in the same event - const assetListUpdatedPayload = disappearedAssets.reduce< - AccountAssetListUpdatedEvent['params']['assets'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - added: [...(acc[asset.keyringAccountId]?.added ?? [])], - removed: [ - ...(acc[asset.keyringAccountId]?.removed ?? []), - asset.assetType, - ], - }, - }), - {}, - ); - - for (const asset of assets) { - // Merge the current snapshot into the pre-seeded payload so each account - // ends up with one consolidated added/removed diff. - assetListUpdatedPayload[asset.keyringAccountId] = { - added: [ - ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), - ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), - ], - removed: [ - ...(assetListUpdatedPayload[asset.keyringAccountId]?.removed ?? []), - ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), - ], - }; - } - - // If no assets were added or removed, don't emit the event. - const isEmptyAccountAssetListUpdatedPayload = Object.values( - assetListUpdatedPayload, - ) - .map((item) => item.added.length + item.removed.length) - .every((item) => item === 0); - - if (!isEmptyAccountAssetListUpdatedPayload) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - } - - // Emit synthetic zero-balance entries for disappeared assets so clients can - // clear cached balances even when the backend omits zero-balance tokens - // instead of returning them explicitly. - const removedAssetsWithZeroBalance = disappearedAssets.map((asset) => ({ - ...asset, - rawAmount: '0', - uiAmount: '0', - })); - - const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; - // Save assets using repository - await this.#assetsRepository.saveMany(assetsToSave); - - // Broadcast the current snapshot plus synthetic zero-balance removals so the - // client can reconcile both visible assets and cached balances in one pass. - const balancesUpdatedPayload = assetsToSave.reduce< - AccountBalancesUpdatedEvent['params']['balances'] - >( - (acc, asset) => ({ - ...acc, - [asset.keyringAccountId]: { - ...(acc[asset.keyringAccountId] ?? {}), - [asset.assetType]: { - unit: asset.symbol, - amount: asset.uiAmount, - }, - }, - }), - {}, - ); - - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. - const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes - .some((count) => count > 0); - - // Only emit the event if some balance was changed. - if (isSomeBalanceChanged) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } - } - - async getAll(): Promise { - const assetsByAccount = - (await this.#state.getKey('assets')) ?? - {}; - - return Object.values(assetsByAccount).flat(); - } - - /** - * Creates an asset entity with zero balance from a known CAIP-19 asset ID. - * Uses pre-calculated metadata from TokenMetadata. - * - * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). - * @param keyringAccountId - The keyring account ID. - * @returns The asset entity with zero balance. - */ - #createZeroBalanceAsset( - assetId: KnownCaip19Id, - keyringAccountId: string, - ): AssetEntity { - const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; - const { chainId } = parseCaipAssetType(assetId); - - return { - assetType: metadata.id, - keyringAccountId, - network: chainId as Network, - symbol: metadata.symbol, - decimals: metadata.decimals, - rawAmount: '0', - uiAmount: '0', - } as AssetEntity; - } - - async getByKeyringAccountId( - keyringAccountId: string, - ): Promise { - const savedAssets = - await this.#assetsRepository.getByAccountId(keyringAccountId); - - /** - * Ensure the special assets are always present whether they have been synced or not. - * These are assets that should be visible to the user even with zero balance. - */ - const missingEssentialAssets: AssetEntity[] = []; - - for (const essentialAssetId of ESSENTIAL_ASSETS) { - const savedAsset = savedAssets.find( - (asset) => (asset.assetType as string) === essentialAssetId, - ); - - if (!savedAsset) { - const zeroBalanceAsset = this.#createZeroBalanceAsset( - essentialAssetId as KnownCaip19Id, - keyringAccountId, - ); - missingEssentialAssets.push(zeroBalanceAsset); - } - } - - return [...savedAssets, ...missingEssentialAssets]; - } - - /** - * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset type. - * - * @param caipAssetType - The CAIP-19 asset type. - * @returns The fiat ticker. - */ #extractFiatTicker(caipAssetType: CaipAssetType): FiatTicker { if (!AssetsService.isFiat(caipAssetType)) { throw new Error('Passed caipAssetType is not a fiat asset'); diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts new file mode 100644 index 00000000..b273636f --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -0,0 +1,728 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEvent, + AccountBalancesUpdatedEvent, + KeyringAccount, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { AccountResources } from '../../../clients/tron-http'; +import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; +import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; +import type { + RawTronUnfrozenV2, + TronAccount, +} from '../../../clients/trongrid/types'; +import type { KnownCaip19Id, Network } from '../../../constants'; +import { ESSENTIAL_ASSETS, Networks, TokenMetadata } from '../../../constants'; +import type { AssetEntity } from '../../../entities/assets'; +import { toUiAmount } from '../../../utils/conversion'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { ILogger } from '../../../utils/logger'; +import type { AssetsRepository } from '../AssetsRepository'; +import { isSnapOwnedAsset } from '../snapOwnedAssets'; + +/** + * Slim account data shape for snap-owned asset extraction. + * Provides a consistent shape for both active and inactive accounts. + */ +type SnapOwnedAccountData = { + /** Staking data including frozen balances and delegated resources. */ + stakedData: { + frozenV2: TronAccount['frozenV2']; + unfrozenV2: TronAccount['unfrozenV2']; + accountResource: TronAccount['account_resource'] | undefined; + }; + /** Account resources (energy, bandwidth). Empty object for inactive accounts. */ + resources: AccountResources | Record; + /** Unclaimed staking rewards in sun (0 if no rewards). */ + stakingRewards: number; +}; + +export class SnapAssetsAdapter { + readonly #logger: ILogger; + + readonly #assetsRepository: AssetsRepository; + + readonly #trongridApiClient: TrongridApiClient; + + readonly #tronHttpClient: TronHttpClient; + + constructor({ + logger, + assetsRepository, + trongridApiClient, + tronHttpClient, + }: { + logger: ILogger; + assetsRepository: AssetsRepository; + trongridApiClient: TrongridApiClient; + tronHttpClient: TronHttpClient; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 SnapAssetsAdapter]'); + this.#assetsRepository = assetsRepository; + this.#trongridApiClient = trongridApiClient; + this.#tronHttpClient = tronHttpClient; + } + + async getAccountAssetsByIDs( + accountId: string, + assetTypes: string[], + ): Promise<(AssetEntity | null)[]> { + return this.#assetsRepository.getByAccountIdAndAssetTypes( + accountId, + assetTypes, + ); + } + + async getAccountAssetByID( + accountId: string, + assetType: string, + ): Promise { + return this.#assetsRepository.getByAccountIdAndAssetType( + accountId, + assetType, + ); + } + + /** + * Fetches snap-owned assets and balances for an account. + * + * Data Sources: + * - `getAccountInfoByAddress`: Staking data (active accounts only) + * - `getAccountResources`: Energy and Bandwidth (returns {} for inactive accounts) + * - `getReward`: Unclaimed staking rewards + * + * @param scope - The network to query. + * @param account - The keyring account. + * @returns Promise - Array of snap-owned assets with balances. + */ + async fetchSnapOwnedAssetsForAccount( + scope: Network, + account: KeyringAccount, + ): Promise { + this.#logger.info('Fetching snap-owned assets and balances by account', { + account, + scope, + }); + + const [ + tronAccountInfoRequest, + tronAccountResourcesRequest, + stakingRewardsRequest, + ] = await Promise.allSettled([ + this.#trongridApiClient.getAccountInfoByAddress(scope, account.address), + this.#tronHttpClient.getAccountResources(scope, account.address), + this.#tronHttpClient.getReward(scope, account.address), + ]); + + if (tronAccountInfoRequest.status === 'rejected') { + this.#logger.info( + 'Account info request failed, treating as inactive account', + { account, scope }, + ); + } + + const accountData = this.#buildSnapOwnedAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + stakingRewardsRequest, + }); + + return this.#extractSnapOwnedAssets(account, scope, accountData); + } + + /** + * 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.stakingRewardsRequest - The settled promise result from getReward. + * @returns SnapOwnedAccountData - Consistent data shape for snap-owned extraction. + */ + #buildSnapOwnedAccountData({ + tronAccountInfoRequest, + tronAccountResourcesRequest, + stakingRewardsRequest, + }: { + tronAccountInfoRequest: PromiseSettledResult; + tronAccountResourcesRequest: PromiseSettledResult; + stakingRewardsRequest: PromiseSettledResult; + }): SnapOwnedAccountData { + const isInactiveAccount = tronAccountInfoRequest.status === 'rejected'; + const resources = + tronAccountResourcesRequest.status === 'fulfilled' + ? tronAccountResourcesRequest.value + : {}; + const stakingRewards = + stakingRewardsRequest.status === 'fulfilled' + ? Math.max(0, stakingRewardsRequest.value) + : 0; + + if (isInactiveAccount) { + return { + stakedData: { + frozenV2: [], + unfrozenV2: [], + accountResource: undefined, + }, + resources, + stakingRewards, + }; + } + + const tronAccountInfo = tronAccountInfoRequest.value; + return { + stakedData: { + frozenV2: tronAccountInfo.frozenV2 ?? [], + unfrozenV2: tronAccountInfo.unfrozenV2 ?? [], + accountResource: tronAccountInfo.account_resource, + }, + resources, + stakingRewards, + }; + } + + #extractSnapOwnedAssets( + account: KeyringAccount, + scope: Network, + data: SnapOwnedAccountData, + ): AssetEntity[] { + return [ + ...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, + }), + ...this.#extractEnergy({ + account, + scope, + tronAccountResources: data.resources, + }), + ]; + } + + /** + * Extracts staked TRX assets (for bandwidth and energy). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including frozen balances and delegated resources. + * @returns AssetEntity[] - Array of staked assets (always 2: bandwidth and energy, amounts may be 0). + */ + #extractStakedNativeAssets( + account: KeyringAccount, + scope: Network, + stakedData: SnapOwnedAccountData['stakedData'], + ): AssetEntity[] { + const assets: AssetEntity[] = []; + + let stakedBandwidthAmount = 0; + let stakedEnergyAmount = 0; + + stakedData.frozenV2?.forEach((frozen) => { + const amount = frozen.amount ?? 0; + + if (frozen.type === 'ENERGY') { + stakedEnergyAmount += amount; + } else if (!frozen.type) { + // Item without type is for bandwidth + stakedBandwidthAmount += amount; + } + }); + + const delegatedBandwidth = + stakedData.accountResource?.delegated_frozenV2_balance_for_bandwidth ?? 0; + const delegatedEnergy = + stakedData.accountResource?.delegated_frozenV2_balance_for_energy ?? 0; + + stakedBandwidthAmount += delegatedBandwidth; + stakedEnergyAmount += delegatedEnergy; + + assets.push({ + assetType: Networks[scope].stakedForBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForBandwidth.symbol, + decimals: Networks[scope].stakedForBandwidth.decimals, + rawAmount: stakedBandwidthAmount.toString(), + uiAmount: toUiAmount( + stakedBandwidthAmount, + Networks[scope].stakedForBandwidth.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForBandwidth.iconUrl, + }); + + assets.push({ + assetType: Networks[scope].stakedForEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakedForEnergy.symbol, + decimals: Networks[scope].stakedForEnergy.decimals, + rawAmount: stakedEnergyAmount.toString(), + uiAmount: toUiAmount( + stakedEnergyAmount, + Networks[scope].stakedForEnergy.decimals, + ).toString(), + iconUrl: Networks[scope].stakedForEnergy.iconUrl, + }); + + return assets; + } + + /** + * Extracts TRX ready for withdrawal (unstaked TRX that has completed the withdrawal period). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The ready-for-withdrawal asset (amount may be 0). + */ + #extractReadyForWithdrawalAsset( + account: KeyringAccount, + scope: Network, + stakedData: 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: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: readyForWithdrawalAmount.toString(), + uiAmount: toUiAmount(readyForWithdrawalAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts staking rewards asset (unclaimed voting rewards). + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakingRewards - Unclaimed staking rewards in sun. + * @returns AssetEntity - The staking rewards asset. + */ + #extractStakingRewardsAsset( + account: KeyringAccount, + scope: Network, + stakingRewards: number, + ): AssetEntity { + return { + assetType: Networks[scope].stakingRewards.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].stakingRewards.symbol, + decimals: Networks[scope].stakingRewards.decimals, + rawAmount: stakingRewards.toString(), + uiAmount: toUiAmount( + stakingRewards, + Networks[scope].stakingRewards.decimals, + ).toString(), + iconUrl: Networks[scope].stakingRewards.iconUrl, + }; + } + + /** + * Extracts TRX that is in the lock period (unstaked but lock period not yet ended). + * This represents TRX that the user has initiated unstaking for but must wait + * the 14-day lock period before they can withdraw. + * + * @param account - The keyring account. + * @param scope - The network. + * @param stakedData - Staking data including unfrozen balances. + * @returns AssetEntity - The in-lock-period asset (amount may be 0). + */ + #extractInLockPeriodAsset( + account: KeyringAccount, + scope: Network, + stakedData: SnapOwnedAccountData['stakedData'], + ): AssetEntity { + const currentTimestamp = Date.now(); + let inLockPeriodAmount = 0; + + stakedData.unfrozenV2?.forEach((unfrozen: RawTronUnfrozenV2) => { + const expireTime = unfrozen.unfreeze_expire_time ?? 0; + const amount = unfrozen.unfreeze_amount ?? 0; + + if (expireTime > currentTimestamp && amount > 0) { + inLockPeriodAmount += amount; + } + }); + + const { id, symbol, decimals, iconUrl } = Networks[scope].inLockPeriod; + + return { + assetType: id, + keyringAccountId: account.id, + network: scope, + symbol, + decimals, + rawAmount: inLockPeriodAmount.toString(), + uiAmount: toUiAmount(inLockPeriodAmount, decimals).toString(), + iconUrl, + }; + } + + /** + * Extracts current and maximum bandwidth from the account resources. + * + * @param options - Options object. + * @param options.account - The account to extract bandwidth for. + * @param options.scope - The network to extract bandwidth for. + * @param options.tronAccountResources - The account resources to extract bandwidth for. + * @returns The bandwidth assets. + */ + #extractBandwidth({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const freeBandwidth = tronAccountResources?.freeNetLimit ?? 0; + const stakingBandwidth = tronAccountResources?.NetLimit ?? 0; + const maximumBandwidth = freeBandwidth + stakingBandwidth; + + const usedFreeBandwidth = tronAccountResources?.freeNetUsed ?? 0; + const usedStakingBandwidth = tronAccountResources?.NetUsed ?? 0; + const usedBandwidth = usedFreeBandwidth + usedStakingBandwidth; + + const availableBandwidth = Math.max(0, maximumBandwidth - usedBandwidth); + + return [ + { + assetType: Networks[scope].bandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].bandwidth.symbol, + decimals: Networks[scope].bandwidth.decimals, + rawAmount: availableBandwidth.toString(), + uiAmount: availableBandwidth.toString(), + iconUrl: Networks[scope].bandwidth.iconUrl, + }, + { + assetType: Networks[scope].maximumBandwidth.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumBandwidth.symbol, + decimals: Networks[scope].maximumBandwidth.decimals, + rawAmount: maximumBandwidth.toString(), + uiAmount: maximumBandwidth.toString(), + iconUrl: Networks[scope].maximumBandwidth.iconUrl, + }, + ]; + } + + /** + * Extracts current and maximum energy from the account resources. + * + * @param options - Options object. + * @param options.account - The keyring account. + * @param options.scope - The network. + * @param options.tronAccountResources - Account resources (energy, bandwidth). + * @returns AssetEntity[] - Array containing energy and maximum energy assets. + */ + #extractEnergy({ + account, + scope, + tronAccountResources, + }: { + account: KeyringAccount; + scope: Network; + tronAccountResources: AccountResources | Record; + }): AssetEntity[] { + const maximumEnergy = tronAccountResources?.EnergyLimit ?? 0; + const usedEnergy = tronAccountResources?.EnergyUsed ?? 0; + + /** + * We might have used more Energy than the maximum allocated + */ + const availableEnergy = Math.max(0, maximumEnergy - usedEnergy); + + return [ + { + assetType: Networks[scope].energy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].energy.symbol, + decimals: Networks[scope].energy.decimals, + rawAmount: availableEnergy.toString(), + uiAmount: availableEnergy.toString(), + iconUrl: Networks[scope].energy.iconUrl, + }, + { + assetType: Networks[scope].maximumEnergy.id, + keyringAccountId: account.id, + network: scope, + symbol: Networks[scope].maximumEnergy.symbol, + decimals: Networks[scope].maximumEnergy.decimals, + rawAmount: maximumEnergy.toString(), + uiAmount: maximumEnergy.toString(), + iconUrl: Networks[scope].maximumEnergy.iconUrl, + }, + ]; + } + + /** + * Persist the latest fetched assets and emit the corresponding keyring events. + * + * The input is treated as the latest snapshot for the account/network pairs + * included in this sync. The method compares that snapshot with the + * previously saved state to detect disappeared assets, emits asset-list + * updates, and emits balance updates including synthetic zero balances for + * assets that vanished from the latest response. + * + * @param assets - The latest asset snapshot returned by the refresh flow. + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets', assets); + + const shouldEmitAsset = (asset: AssetEntity): boolean => + isSnapOwnedAsset(asset.assetType); + + const hasZeroAmount = (asset: AssetEntity): boolean => + asset.rawAmount === '0' || asset.uiAmount === '0'; + + 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. + const syncedNetworksByAccount = assets.reduce>>( + (acc, asset) => { + acc[asset.keyringAccountId] ??= new Set(); + acc[asset.keyringAccountId]?.add(asset.network); + return acc; + }, + {}, + ); + + const incomingAssetKeys = new Set( + assets.map((asset) => `${asset.keyringAccountId}:${asset.assetType}`), + ); + + // A saved 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)) { + return false; + } + + if (!isSnapOwnedAsset(savedAsset.assetType)) { + return false; + } + + return !incomingAssetKeys.has( + `${savedAsset.keyringAccountId}:${savedAsset.assetType}`, + ); + }); + + // 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) && !isSnapOwnedAsset(asset.assetType); + + const shouldBeInAddedList = (asset: AssetEntity): boolean => + !shouldBeInRemovedList(asset); + + 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.filter(shouldEmitAsset)) { + assetListUpdatedPayload[asset.keyringAccountId] = { + added: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.added ?? []), + ...(shouldBeInAddedList(asset) ? [asset.assetType] : []), + ], + removed: [ + ...(assetListUpdatedPayload[asset.keyringAccountId]?.removed ?? []), + ...(shouldBeInRemovedList(asset) ? [asset.assetType] : []), + ], + }; + } + + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + const removedAssetsWithZeroBalance = disappearedAssets + .filter(shouldEmitAsset) + .map((asset) => ({ + ...asset, + rawAmount: '0', + uiAmount: '0', + })); + + const assetsToSave = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ]; + await this.#assetsRepository.saveMany(assetsToSave); + + const balancesUpdatedPayload = [ + ...assets.filter(shouldEmitAsset), + ...removedAssetsWithZeroBalance, + ].reduce( + (acc, asset) => ({ + ...acc, + [asset.keyringAccountId]: { + ...(acc[asset.keyringAccountId] ?? {}), + [asset.assetType]: { + unit: asset.symbol, + amount: asset.uiAmount, + }, + }, + }), + {}, + ); + + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) + .some((count) => count > 0); + + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + /** + * Creates an asset entity with zero balance from a known CAIP-19 asset ID. + * Uses pre-calculated metadata from TokenMetadata. + * + * @param assetId - The CAIP-19 asset ID (e.g., KnownCaip19Id.TrxMainnet). + * @param keyringAccountId - The keyring account ID. + * @returns The asset entity with zero balance. + */ + #createZeroBalanceAsset( + assetId: KnownCaip19Id, + keyringAccountId: string, + ): AssetEntity { + const metadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const { chainId } = parseCaipAssetType(assetId); + + return { + assetType: metadata.id, + keyringAccountId, + network: chainId as Network, + symbol: metadata.symbol, + decimals: metadata.decimals, + rawAmount: '0', + uiAmount: '0', + } as AssetEntity; + } + + async getAccountAssetsByScope( + scope: Network, + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); + + const visibleSavedAssets = savedAssets.filter( + (asset) => asset.network === scope, + ); + + 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, + ); + + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); + } + } + + return [...visibleSavedAssets, ...missingEssentialAssets]; + } + + async getByKeyringAccountId( + keyringAccountId: string, + ): Promise { + const savedAssets = + await this.#assetsRepository.getByAccountId(keyringAccountId); + + /** + * Ensure the special assets are always present whether they have been synced or not. + * These are assets that should be visible to the user even with zero balance. + */ + const missingEssentialAssets: AssetEntity[] = []; + + for (const essentialAssetId of ESSENTIAL_ASSETS) { + const savedAsset = savedAssets.find( + (asset) => (asset.assetType as string) === essentialAssetId, + ); + + if (!savedAsset) { + const zeroBalanceAsset = this.#createZeroBalanceAsset( + essentialAssetId as KnownCaip19Id, + keyringAccountId, + ); + missingEssentialAssets.push(zeroBalanceAsset); + } + } + + return [...savedAssets, ...missingEssentialAssets]; + } +} diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts new file mode 100644 index 00000000..852b6534 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.test.ts @@ -0,0 +1,84 @@ +import type { Asset } from '@metamask/assets-controller'; +import type { CaipAssetType } from '@metamask/utils'; + +import { KnownCaip19Id, Network, TokenMetadata } from '../../constants'; +import { mapControllerAsset } from './mapControllerAsset'; + +describe('mapControllerAsset', () => { + const accountId = 'account-id'; + const knownAssetId = KnownCaip19Id.TrxMainnet; + const unknownAssetId = 'tron:728126428/trc20:unknown'; + + it('maps controller metadata when present', () => { + const asset = { + id: unknownAssetId, + chainId: Network.Mainnet, + balance: { amount: '1234567' }, + metadata: { + type: 'fungible', + symbol: 'TKN', + name: 'Token', + decimals: 6, + image: 'https://example.com/token.png', + }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: unknownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: 'TKN', + decimals: 6, + rawAmount: '1234567', + uiAmount: '1.234567', + iconUrl: 'https://example.com/token.png', + }); + }); + + it('falls back to TokenMetadata when controller metadata is missing', () => { + const asset = { + id: knownAssetId, + chainId: Network.Mainnet, + balance: { amount: '2000000' }, + metadata: { type: 'fungible', name: 'TRON' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: knownAssetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: TokenMetadata[knownAssetId].symbol, + decimals: TokenMetadata[knownAssetId].decimals, + rawAmount: '2000000', + uiAmount: '2', + iconUrl: TokenMetadata[knownAssetId].iconUrl, + }); + }); + + it('uses empty defaults when metadata is missing everywhere', () => { + const assetId = 'tron:728126428/trc20:missing' as CaipAssetType; + const asset = { + id: assetId, + chainId: Network.Mainnet, + balance: { amount: '42' }, + metadata: { type: 'fungible', name: 'Missing' }, + price: { price: 0, lastUpdated: 0 }, + fiatValue: 0, + } as unknown as Asset; + + expect(mapControllerAsset(accountId, asset)).toStrictEqual({ + assetType: assetId, + keyringAccountId: accountId, + network: Network.Mainnet, + symbol: '', + decimals: 0, + rawAmount: '42', + uiAmount: '42', + iconUrl: '', + }); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts new file mode 100644 index 00000000..6b09e488 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/mapControllerAsset.ts @@ -0,0 +1,35 @@ +import type { Asset } from '@metamask/assets-controller'; + +import { Network, TokenMetadata } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; +import { toUiAmount } from '../../utils/conversion'; + +/** + * Maps an AssetsController asset to the Snap's {@link AssetEntity} shape. + * + * @param accountId - Keyring account ID. + * @param asset - Asset returned by AssetsController. + * @returns Mapped asset entity. + */ +export function mapControllerAsset( + accountId: string, + asset: Asset, +): AssetEntity { + const assetId = asset.id; + const knownMetadata = TokenMetadata[assetId as keyof typeof TokenMetadata]; + const decimals = asset.metadata.decimals ?? knownMetadata?.decimals ?? 0; + const symbol = asset.metadata.symbol ?? knownMetadata?.symbol ?? ''; + const iconUrl = asset.metadata.image ?? knownMetadata?.iconUrl ?? ''; + const { amount } = asset.balance; + + return { + assetType: assetId, + keyringAccountId: accountId, + network: asset.chainId as Network, + symbol, + decimals, + rawAmount: amount, + uiAmount: toUiAmount(amount, decimals).toString(), + iconUrl, + } as AssetEntity; +} diff --git a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts b/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts new file mode 100644 index 00000000..84a8b4ee --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.test.ts @@ -0,0 +1,34 @@ +import { KnownCaip19Id, SNAP_OWNED_ASSETS } from '../../constants'; +import { isSnapOwnedAsset } from './snapOwnedAssets'; + +describe('isSnapOwnedAsset', () => { + it.each(SNAP_OWNED_ASSETS)( + 'returns true for snap-owned asset %s', + (assetId) => { + expect(isSnapOwnedAsset(assetId)).toBe(true); + }, + ); + + it('returns false for native TRX', () => { + expect(isSnapOwnedAsset(KnownCaip19Id.TrxMainnet)).toBe(false); + expect(isSnapOwnedAsset(KnownCaip19Id.TrxNile)).toBe(false); + expect(isSnapOwnedAsset(KnownCaip19Id.TrxShasta)).toBe(false); + }); + + it('returns false for TRC20 tokens', () => { + expect(isSnapOwnedAsset(KnownCaip19Id.UsdtMainnet)).toBe(false); + expect( + isSnapOwnedAsset( + `${KnownCaip19Id.TrxMainnet.split('/')[0]}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, + ), + ).toBe(false); + }); + + it('returns false for TRC10 tokens', () => { + expect( + isSnapOwnedAsset( + `${KnownCaip19Id.TrxMainnet.split('/')[0]}/trc10:1002000`, + ), + ).toBe(false); + }); +}); diff --git a/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts b/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts new file mode 100644 index 00000000..c8d44a17 --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/snapOwnedAssets.ts @@ -0,0 +1,18 @@ +import { SNAP_OWNED_ASSETS } from '../../constants'; + +const SNAP_OWNED_ASSET_IDS = new Set(SNAP_OWNED_ASSETS); + +/** + * Returns whether an asset remains exclusively managed by the Snap. + * + * AssetsController does not persist certain Tron protocol assets, including + * staking positions and account resources. These assets must always be read, + * synchronized, persisted, and published by the Snap, regardless of the + * assets migration stage. + * + * @param assetId - CAIP-19 asset ID. + * @returns Whether the asset is exclusively managed by the Snap. + */ +export function isSnapOwnedAsset(assetId: string): boolean { + return SNAP_OWNED_ASSET_IDS.has(assetId); +} diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts new file mode 100644 index 00000000..f13df8b4 --- /dev/null +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -0,0 +1,38 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const TRON_WALLET_SNAP_MESSENGER_NAMESPACE = + 'TronWalletSnap' as const; + +export type CoreMessengerActions = + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessengerMessenger = Messenger< + typeof TRON_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; + +/** + * Typed async messenger for Core controller actions available to this Snap via + * `endowment:messenger` / `getMessenger`. + */ +export type CoreMessenger = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; diff --git a/yarn.lock b/yarn.lock index d12f3131..c1c50df9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2266,7 +2266,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.3.0": +"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.1.0, @metamask/controller-utils@npm:^12.3.0": version: 12.3.0 resolution: "@metamask/controller-utils@npm:12.3.0" dependencies: @@ -3208,6 +3208,19 @@ __metadata: languageName: node linkType: hard +"@metamask/remote-feature-flag-controller@npm:4.2.2": + version: 4.2.2 + resolution: "@metamask/remote-feature-flag-controller@npm:4.2.2" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.9.0" + uuid: "npm:^8.3.2" + checksum: 10/ed03ff1ba63c7a0f2b221c3514eb71a8be4200ec543b90676a44930dc731920ac2c92dcc2b13a32ed4e4e8e7e7b28a26db443af9f4bf30f5e2b5785580b286ab + languageName: node + linkType: hard + "@metamask/remote-feature-flag-controller@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/remote-feature-flag-controller@npm:5.0.0" @@ -3292,7 +3305,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snap-networks-utils@workspace:packages/snap-networks-utils": +"@metamask/snap-networks-utils@workspace:^, @metamask/snap-networks-utils@workspace:packages/snap-networks-utils": version: 0.0.0-use.local resolution: "@metamask/snap-networks-utils@workspace:packages/snap-networks-utils" dependencies: @@ -3717,10 +3730,14 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/tron-wallet-snap@workspace:packages/tron-wallet-snap" dependencies: + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^23.7.0" "@metamask/keyring-snap-sdk": "npm:^9.2.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/remote-feature-flag-controller": "npm:4.2.2" + "@metamask/snap-networks-utils": "workspace:^" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.0" "@metamask/snaps-sdk": "npm:^11.2.0"