diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 9fdf473d5..7ec13d897 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,12 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#396](https://github.com/MetaMask/internal-snaps/pull/396)) - Estimated changes from the previous scan now stay visible while a confirmation re-scans, instead of showing a loading skeleton +- `listAccountAssets` and `getAccountBalances` now fetch live assets and balances from the chain through the Snap's TronGrid clients instead of returning persisted state. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) ### Fixed - Show "Estimated changes are not available" instead of "No estimated changes" when the transaction scan returns an error result, such as for a malformed transaction ([#396](https://github.com/MetaMask/internal-snaps/pull/396)) - Report the MetaMask origin as lowercase `metamask` instead of `MetaMask` for MetaMask-initiated operations, so the origin matches the value used by the other non-EVM snaps and granted to the keyring methods, and so transaction scan requests are attributed to `https://metamask.io`. The confirmation UI keeps displaying `MetaMask`. ([#392](https://github.com/MetaMask/internal-snaps/pull/392)) -- Show "Estimated changes are not available" instead of "No estimated changes" when the transaction scan returns an error result, such as for a malformed transaction ([#396](https://github.com/MetaMask/internal-snaps/pull/396)) ## [4.0.0] diff --git a/packages/tron-wallet-snap/jest.config.js b/packages/tron-wallet-snap/jest.config.js index 947d0368e..ad466bc3d 100644 --- a/packages/tron-wallet-snap/jest.config.js +++ b/packages/tron-wallet-snap/jest.config.js @@ -19,10 +19,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 72.49, - functions: 79.91, - lines: 85.79, - statements: 85.79, + branches: 71.4, + functions: 81, + lines: 85.9, + statements: 85.9, }, }, }; diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index f247c497b..cda8f4bab 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "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/MetaMask/internal-snaps.git" }, "source": { - "shasum": "XvgNUT01gJK88k3Ffk8xYCldFKl+BKPLCaEAoAUzRHQ=", + "shasum": "lgMdbXW2azl1A32pRmed4B/Te5j8mQs5uim9HXUUSWs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/tron-wallet-snap/src/context.ts b/packages/tron-wallet-snap/src/context.ts index 7c465fae6..d115ec7c5 100644 --- a/packages/tron-wallet-snap/src/context.ts +++ b/packages/tron-wallet-snap/src/context.ts @@ -134,10 +134,7 @@ const coreAssetsAdapter = new CoreAssetsAdapter({ assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), getAccountAssetsByScope: assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - getAddressInfo: - trongridApiClient.getAccountInfoByAddress.bind(trongridApiClient), - getAddressResources: tronHttpClient.getAccountResources.bind(tronHttpClient), - getAddressStakingRewards: tronHttpClient.getReward.bind(tronHttpClient), + getAssets: assetsProvider.getAssets.bind(assetsProvider), }); // Business Services diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts index ba66a5481..2ba64e921 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -11,7 +11,8 @@ import { } from '@metamask/snaps-sdk'; import type { SnapClient } from '../../clients/snap/SnapClient'; -import { Network } from '../../constants'; +import { KnownCaip19Id, Network } from '../../constants'; +import type { AssetEntity } from '../../entities/assets'; import type { AccountsService } from '../../services/accounts/AccountsService'; import type { AssetsService } from '../../services/assets/AssetsService'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; @@ -65,6 +66,23 @@ describe('KeyringHandler', () => { let mockWalletService: jest.Mocked; let mockConfirmationHandler: jest.Mocked; + const mockLiveAsset: AssetEntity = { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }; + + const mockShastaAsset: AssetEntity = { + ...mockLiveAsset, + assetType: KnownCaip19Id.TrxShasta, + network: Network.Shasta, + }; + beforeEach(() => { mockSnapClient = { scheduleBackgroundEvent: jest.fn().mockResolvedValue(undefined), @@ -84,7 +102,9 @@ describe('KeyringHandler', () => { }), } as unknown as jest.Mocked; mockAssetsService = { - getAccountAssets: jest.fn().mockResolvedValue([]), + fetchAccountAssetsFromTrongrid: jest + .fn() + .mockResolvedValue([mockLiveAsset, mockShastaAsset]), } as unknown as jest.Mocked; mockTransactionsService = { checkAddressActivity: jest.fn(), @@ -670,13 +690,26 @@ describe('KeyringHandler', () => { }); describe('getAccountAssets', () => { - it('returns asset types for an account', async () => { + it('fetches live assets for all account scopes and returns their asset types', async () => { const result = await keyringHandler.getAccountAssets(mockAccount.id); - expect(result).toStrictEqual([]); - expect(mockAssetsService.getAccountAssets).toHaveBeenCalledWith( - mockAccount.id, + expect(result).toStrictEqual([ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.TrxShasta, + ]); + expect( + mockAssetsService.fetchAccountAssetsFromTrongrid, + ).toHaveBeenCalledWith(mockAccount); + }); + + it('propagates fetch failures', async () => { + mockAssetsService.fetchAccountAssetsFromTrongrid.mockRejectedValue( + new Error('network error'), ); + + await expect( + keyringHandler.getAccountAssets(mockAccount.id), + ).rejects.toThrow('network error'); }); it('throws when the account is not found', async () => { @@ -688,6 +721,72 @@ describe('KeyringHandler', () => { }); }); + describe('getAccountBalances', () => { + it('fetches live balances for the account and returns the balance record', async () => { + const result = await keyringHandler.getAccountBalances(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]); + + expect(result).toStrictEqual({ + [KnownCaip19Id.TrxMainnet]: { + amount: '1', + unit: 'TRX', + }, + }); + expect( + mockAssetsService.fetchAccountAssetsFromTrongrid, + ).toHaveBeenCalledWith(mockAccount); + }); + + it('excludes token assets with zero balance', async () => { + const zeroBalanceToken: AssetEntity = { + ...mockLiveAsset, + assetType: KnownCaip19Id.UsdtMainnet, + rawAmount: '0', + uiAmount: '0', + }; + + mockAssetsService.fetchAccountAssetsFromTrongrid.mockResolvedValue([ + mockLiveAsset, + zeroBalanceToken, + ]); + + const result = await keyringHandler.getAccountBalances(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + KnownCaip19Id.UsdtMainnet, + ]); + + expect(result).toStrictEqual({ + [KnownCaip19Id.TrxMainnet]: { + amount: '1', + unit: 'TRX', + }, + }); + }); + + it('propagates fetch failures', async () => { + mockAssetsService.fetchAccountAssetsFromTrongrid.mockRejectedValue( + new Error('network error'), + ); + + await expect( + keyringHandler.getAccountBalances(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]), + ).rejects.toThrow('network error'); + }); + + it('throws when the account is not found', async () => { + mockAccountsService.findById.mockResolvedValue(null); + + await expect( + keyringHandler.getAccountBalances(mockAccount.id, [ + KnownCaip19Id.TrxMainnet, + ]), + ).rejects.toThrow('not found'); + }); + }); + describe('getAccountTransactions', () => { it('returns paginated transactions for an account', async () => { const result = await keyringHandler.getAccountTransactions( diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 01507b548..8d73c6c25 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -1,4 +1,3 @@ -import { ListAccountAssetsResponseStruct } from '@metamask/keyring-api'; import type { Balance, CreateAccountOptions as KeyringBatchCreateAccountOptions, @@ -53,11 +52,12 @@ import { GetAccounBalancesResponseStruct, GetAccountBalancesStruct, GetAccountStruct, - ListAccountAssetsStruct, - ListAccountTransactionsStruct, + GetAccountAssetsStruct, + GetAccountAssetsResponseStruct, PrivateKeyHexStruct, SignTransactionRequestStruct, TronKeyringRequestStruct, + GetAccountTransactionsStruct, } from '../../validation/structs'; import type { TronWalletKeyringRequest } from '../../validation/structs'; import { validateRequest, validateResponse } from '../../validation/validators'; @@ -181,14 +181,14 @@ export class KeyringHandler implements KeyringSnapRpc { async getAccountAssets(accountId: string): Promise { try { - validateRequest({ accountId }, ListAccountAssetsStruct); + validateRequest({ accountId }, GetAccountAssetsStruct); - await this.#getAccountOrThrow(accountId); + const account = await this.#getAccountOrThrow(accountId); - this.#logger.info('Listing account assets', { accountId }); + this.#logger.info('Getting account assets', { accountId }); const assetEntities = - await this.#assetsService.getAccountAssets(accountId); + await this.#assetsService.fetchAccountAssetsFromTrongrid(account); const result = assetEntities .filter( (asset) => @@ -199,7 +199,7 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.info('Account assets', { accountId, result }); - validateResponse(result, ListAccountAssetsResponseStruct); + validateResponse(result, GetAccountAssetsResponseStruct); return result; } catch (error: unknown) { this.#logger.error({ error }, 'Error listing account assets'); @@ -224,9 +224,9 @@ export class KeyringHandler implements KeyringSnapRpc { next: string | null; }> { try { - validateRequest({ accountId, pagination }, ListAccountTransactionsStruct); + validateRequest({ accountId, pagination }, GetAccountTransactionsStruct); - this.#logger.info('Listing account transactions...'); + this.#logger.info('Getting account transactions...'); const { limit, next } = pagination; const keyringAccount = await this.#getAccount(accountId); @@ -275,9 +275,10 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.info('Getting account balances', { accountId, assets }); - await this.#getAccountOrThrow(accountId); + const account = await this.#getAccountOrThrow(accountId); - const assetsList = await this.#assetsService.getAccountAssets(accountId); + const assetsList = + await this.#assetsService.fetchAccountAssetsFromTrongrid(account); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) 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 8c7b4c15f..e73902dc7 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -111,7 +111,7 @@ type WithAccountsServiceCallback = (payload: { mockConfigProvider: { config: Config }; mockLogger: Logger; mockAssetsService: jest.Mocked< - Pick + Pick >; mockSnapClient: jest.Mocked< Pick @@ -268,9 +268,9 @@ async function withAccountsService( }; const mockAssetsService: jest.Mocked< - Pick + Pick > = { - fetchAssetsAndBalancesForAccount: jest.fn().mockResolvedValue([]), + fetchAccountAssets: jest.fn().mockResolvedValue([]), saveMany: jest.fn().mockResolvedValue(undefined), }; @@ -1022,7 +1022,7 @@ describe('AccountsService', () => { }); describe('synchronize (assets)', () => { - it('fetches assets for each account and scope, then saves', async () => { + it('fetches assets for each account, then saves', async () => { const account: ExtendedKeyringAccount = { id: 'sync-asset-id', address: 'TSyncAsset12345678901234567', @@ -1048,26 +1048,15 @@ describe('AccountsService', () => { ]; await withAccountsService( - async ({ accountsService, mockConfigProvider, mockAssetsService }) => { - mockConfigProvider.config = { - ...MOCK_CONFIG, - activeNetworks: [Network.Mainnet, Network.Shasta], - }; - mockAssetsService.fetchAssetsAndBalancesForAccount.mockResolvedValue( - mockAssets, - ); + async ({ accountsService, mockAssetsService }) => { + mockAssetsService.fetchAccountAssets.mockResolvedValue(mockAssets); await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Shasta, account); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledTimes(1); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + account, + ); expect(mockAssetsService.saveMany).toHaveBeenCalledWith( expect.arrayContaining(mockAssets), ); @@ -1154,7 +1143,7 @@ describe('AccountsService', () => { ...MOCK_CONFIG, activeNetworks: [Network.Mainnet], }; - mockAssetsService.fetchAssetsAndBalancesForAccount + mockAssetsService.fetchAccountAssets .mockRejectedValueOnce(new Error('grpc unavailable')) .mockResolvedValueOnce(healthyAssets); @@ -1199,9 +1188,7 @@ describe('AccountsService', () => { throw new Error('toString boom'); }, }; - mockAssetsService.fetchAssetsAndBalancesForAccount.mockRejectedValue( - hostileReason, - ); + mockAssetsService.fetchAccountAssets.mockRejectedValue(hostileReason); // The hostile reason is defused to a placeholder and the failure is // still reported; the sync completes and the save still runs. @@ -1218,7 +1205,7 @@ describe('AccountsService', () => { ); }); - it('handles empty activeNetworks', async () => { + it('fetches per account regardless of activeNetworks', async () => { await withAccountsService( async ({ accountsService, mockConfigProvider, mockAssetsService }) => { mockConfigProvider.config = MOCK_CONFIG; @@ -1237,9 +1224,9 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).not.toHaveBeenCalled(); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + account, + ); expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); }, ); @@ -1416,9 +1403,9 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + account, + ); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledWith(Network.Mainnet, account); @@ -1462,9 +1449,7 @@ describe('AccountsService', () => { accountsService.synchronize([account]), ]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(1); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledTimes(1); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledTimes(1); @@ -1487,9 +1472,7 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledTimes(2); }, ); }); @@ -1510,15 +1493,13 @@ describe('AccountsService', () => { accountsService.synchronize([accountB]), ]); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountA); - expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountB); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledTimes(2); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + accountA, + ); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + accountB, + ); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index d5bcfe83a..8a3bfd821 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -515,18 +515,10 @@ export class AccountsService { * @param accounts - The accounts to synchronize assets for. */ async synchronizeAssets(accounts: ExtendedKeyringAccount[]): Promise { - const scopes = this.#configProvider.config.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, - ); - }), + accounts.map((account) => + this.#assetsService.fetchAccountAssets(account), + ), ); const assets = assetResponses.flatMap((response) => @@ -536,7 +528,7 @@ export class AccountsService { await this.#reportSyncFailures( 'Failed to fetch assets', assetResponses, - combinations.map(({ account }) => account.id), + accounts.map(({ id }) => id), ); try { 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 756208616..9a809cd69 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -16,10 +16,9 @@ import type { SpotPrices } from '../../clients/price-api/types'; import type { SnapClient } from '../../clients/snap/SnapClient'; 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 { TronAccount } from '../../clients/trongrid/types'; -import { KnownCaip19Id, Network, SNAP_OWNED_ASSETS } from '../../constants'; +import { KnownCaip19Id, Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import type { CoreMessengerCaller } from '../../types/core-messenger'; import { mockLogger } from '../../utils/mockLogger'; @@ -53,6 +52,7 @@ function createMessengerCallMock( getAccountAssetByID: jest.Mock, getAccountAssetsByIDs: jest.Mock = jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.Mock = jest.fn().mockResolvedValue({}), + getAssets: jest.Mock = jest.fn().mockResolvedValue({}), ): CoreMessengerCaller['call'] { return async (actionType, ...args) => { switch (actionType) { @@ -64,6 +64,8 @@ function createMessengerCallMock( return getAccountAssetsByIDs(...args); case 'AssetsController:getAccountAssetsByScope': return getAccountAssetsByScope(...args); + case 'AssetsController:getAssets': + return getAssets(...args); default: return undefined; } @@ -164,36 +166,6 @@ const createMockTronAccount = ( ...overrides, }); -// Convenience alias used by bandwidth/energy tests -const minimalTronAccount = createMockTronAccount({ - address: 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx', -}); - -/** - * Builds a mock AccountResources object matching the shape returned by - * POST https://api.trongrid.io/wallet/getaccountresource. - * - * The Tron full node omits fields with zero values, so all - * account-level fields are optional. Network-level totals use - * sensible mainnet defaults. - * - * @see https://developers.tron.network/reference/getaccountresource - * @param overrides - Account-specific fields to set. - * @returns A mock AccountResources object. - */ -function getMockAccountResources( - overrides: Record = {}, -): Record { - return { - freeNetLimit: 600, - TotalNetLimit: 0, - TotalNetWeight: 0, - TotalEnergyLimit: 0, - TotalEnergyWeight: 0, - ...overrides, - }; -} - /** * Finds an asset by its CAIP-19 asset type. * @@ -235,6 +207,7 @@ type WithAssetsServiceCallback = (payload: { mockTokenApiClient: jest.Mocked>; mockSnapClient: jest.Mocked>; mockCoreMessenger: jest.Mocked; + mockGetAssets: jest.Mock; setMigrationStage: (stage: SnapsAssetsMigrationStage) => void; }) => Promise | ReturnValue; @@ -306,6 +279,7 @@ async function withAssetsService( const mockGetAccountAssetByID = jest.fn(); const mockGetAccountAssetsByIDs = jest.fn().mockResolvedValue({}); const mockGetAccountAssetsByScope = jest.fn().mockResolvedValue({}); + const mockGetAssets = jest.fn().mockResolvedValue({}); let migrationStage = SnapsAssetsMigrationStage.Off; const mockCoreMessenger: jest.Mocked = { call: jest.fn().mockImplementation( @@ -318,6 +292,7 @@ async function withAssetsService( mockGetAccountAssetByID, mockGetAccountAssetsByIDs, mockGetAccountAssetsByScope, + mockGetAssets, ), ), }; @@ -350,9 +325,7 @@ async function withAssetsService( assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), getAccountAssetsByScope: assetsProvider.getAccountAssetsByScope.bind(assetsProvider), - getAddressInfo: mockTrongridApiClient.getAccountInfoByAddress, - getAddressResources: mockTronHttpClient.getAccountResources, - getAddressStakingRewards: mockTronHttpClient.getReward, + getAssets: assetsProvider.getAssets.bind(assetsProvider), }); const assetsService = new AssetsService({ @@ -371,1097 +344,168 @@ async function withAssetsService( mockTokenApiClient, mockSnapClient, mockCoreMessenger, + mockGetAssets, setMigrationStage, }); } describe('AssetsService', () => { - describe('fetchAssetsAndBalancesForAccount', () => { - describe('inactive account fallback', () => { - it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String( - Network.Mainnet, - )}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - - const trxAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ); - expect(trxAsset).toBeDefined(); - expect(trxAsset?.rawAmount).toBe('0'); - - const trc20Asset = assets.find( - (asset: AssetEntity) => asset.assetType === trc20AssetId, - ); - expect(trc20Asset).toBeDefined(); - expect(trc20Asset?.rawAmount).toBe('24249143'); - }, - ); - }); - - it('returns protocol resources when inactive account has empty resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - const energyAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.EnergyMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(energyAsset).toBeDefined(); - }, - ); - }); - - it('returns protocol assets when inactive account info fails', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(assets.length).toBeGreaterThan(0); - expect( - assets.some((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - }, - ); - }); - - it('tracks unexpected account info failures that are treated as inactive', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockSnapClient, - }) => { - // An HTTP failure is not an "account not found", but the flow - // still treats it as an inactive account; it must be tracked. - const fetchError = new Error('HTTP error! status: 500'); - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - fetchError, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - emptyAccountResources, - ); - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - [], - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect(mockSnapClient.trackError).toHaveBeenCalledTimes(1); - const tracked = (mockSnapClient.trackError as jest.Mock).mock - .calls[0][0] as Error; - expect(tracked.message).toBe( - 'Account info request failed; treating as inactive account', - ); - // The original error is preserved as the cause. - expect((tracked as Error & { cause?: unknown }).cause).toBe( - fetchError, - ); - // The inactive-account flow is unchanged. - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalledWith(Network.Mainnet, mockAccount.address); - expect(assets.length).toBeGreaterThan(0); - }, - ); - }); - }); - - describe('partial failure handling', () => { - it('returns protocol assets when account info fails even if resources succeed (inactive account)', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - mockPriceApiClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - NetLimit: 0, - EnergyLimit: 0, - }); - const trc20Balances = [ - { TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '100000' }, - ]; - mockTrongridApiClient.getTrc20BalancesByAddress.mockResolvedValue( - trc20Balances, - ); - - const trc20AssetId = `${String( - Network.Mainnet, - )}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; - mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( - createSpotPrices({ - [trc20AssetId]: { id: trc20AssetId, price: 1.0 }, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).toHaveBeenCalled(); - expect( - assets.some((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('600'); - }, - ); - }); - - it('continues with zero resources when only resources request fails', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - balance: 1000000, - trc20: [], - }), - ); - mockTronHttpClient.getAccountResources.mockRejectedValue( - new Error('Resources endpoint unavailable'), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(true); - - const bandwidthAsset = assets.find( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.BandwidthMainnet, - ); - expect(bandwidthAsset).toBeDefined(); - expect(bandwidthAsset?.rawAmount).toBe('0'); - }, - ); - }); - }); - - describe('bandwidth', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns remaining free bandwidth when no staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 200 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('400'); - }, - ); - }); - - it('returns combined remaining free + staked bandwidth', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('290'); - }, - ); - }); - - it('clamps to 0 when used exceeds maximum', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ - freeNetUsed: 600, - NetUsed: 50, - NetLimit: 16, - }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.BandwidthMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - }); - - describe('maximum bandwidth', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns only free bandwidth limit when no staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({}), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('600'); - }, - ); - }); - - it('returns free + staked bandwidth limit', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ NetLimit: 48 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumBandwidthMainnet) - ?.rawAmount, - ).toBe('648'); - }, - ); - }); - }); - - describe('TRX ready for withdrawal', () => { - it('returns zero balance when account has no unfrozenV2 data', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('returns ready for withdrawal amount when unfrozenV2 has expired entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - - it('returns zero balance when unfrozenV2 has not expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + describe('fetchAccountAssets', () => { + it('fetches live assets from the chain for all the account scopes', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + }) => { + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + mockTrongridApiClient.getAccountInfoByAddress + .mockResolvedValueOnce( createMockTronAccount({ address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime, - }, - ], + balance: 1_000_000, }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('sums multiple expired unfrozen entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime1 = Date.now() - 1000; - const pastTime2 = Date.now() - 2000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + ) + .mockResolvedValueOnce( createMockTronAccount({ address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime1 }, - { unfreeze_amount: 2000000, unfreeze_expire_time: pastTime2 }, - ], + balance: 2_000_000, }), ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('3000000'); - }, - ); - }); - - it('only includes expired entries when mixed with non-expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - { - unfreeze_amount: 5000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); + const account: KeyringAccount = { + ...mockAccount, + scopes: [Network.Mainnet, Network.Shasta], + }; - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + const assets = await assetsService.fetchAccountAssets(account); - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); - }, - ); - }); + expect( + mockTrongridApiClient.getAccountInfoByAddress, + ).toHaveBeenCalledTimes(2); + expect( + mockTrongridApiClient.getAccountInfoByAddress, + ).toHaveBeenNthCalledWith(1, Network.Mainnet, mockAccount.address); + expect( + mockTrongridApiClient.getAccountInfoByAddress, + ).toHaveBeenNthCalledWith(2, Network.Shasta, mockAccount.address); + expect(findAsset(assets, KnownCaip19Id.TrxMainnet)?.rawAmount).toBe( + '1000000', + ); + expect(findAsset(assets, KnownCaip19Id.TrxShasta)?.rawAmount).toBe( + '2000000', + ); + }, + ); }); - describe('TRX in lock period', () => { - it('returns zero balance when account has no unfrozenV2 data', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('returns in lock period amount when unfrozenV2 has future entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - - it('returns zero balance when unfrozenV2 has already expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: pastTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - - it('sums multiple non-expired unfrozen entries', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const futureTime1 = Date.now() + 1000000; - const futureTime2 = Date.now() + 2000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime1, - }, - { - unfreeze_amount: 2000000, - unfreeze_expire_time: futureTime2, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('3000000'); - }, - ); - }); + it('routes through the AssetsController fetch pipeline when migration is active', async () => { + await withAssetsService( + async ({ assetsService, mockGetAssets, setMigrationStage }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); - it('only includes non-expired entries when mixed with expired', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - const pastTime = Date.now() - 1000; - const futureTime = Date.now() + 1000000; - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime }, - { - unfreeze_amount: 5000000, - unfreeze_expire_time: futureTime, - }, - ], + const usdtAssetId = + `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; + mockGetAssets.mockResolvedValue({ + [mockAccount.id]: { + [KnownCaip19Id.TrxMainnet]: buildControllerAsset( + KnownCaip19Id.TrxMainnet, + '1', + { symbol: 'TRX', name: 'TRON', decimals: 6 }, + ), + [usdtAssetId]: buildControllerAsset(usdtAssetId, '0.5', { + symbol: 'USDT', + name: 'Tether', + decimals: 6, }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('5000000'); - }, - ); - }); - - it('returns zero balance for inactive accounts', async () => { - await withAssetsService( - async ({ - assetsService, - 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 inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('0'); - }, - ); - }); - }); - - describe('energy', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns full energy when none consumed', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('329'); - }, - ); - }); - - it('returns remaining energy after partial consumption', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('617'); - }, - ); - }); - - it('clamps to 0 when EnergyUsed exceeds EnergyLimit from leasing', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), - ); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.EnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - }); - - describe('maximum energy', () => { - it('returns 0 when account has no resources', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns EnergyLimit from staking', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue( - getMockAccountResources({ EnergyLimit: 329 }), - ); + }, + }); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + const assets = await assetsService.fetchAccountAssets(mockAccount); - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('329'); - }, - ); - }); + expect(mockGetAssets).toHaveBeenCalledWith( + [expect.objectContaining({ id: mockAccount.id })], + { + chainIds: [mockAccount.scopes[0]], + forceUpdate: true, + bypassServerCache: true, + }, + ); + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === usdtAssetId, + ), + ).toBe(true); + }, + ); }); + }); - describe('staking rewards', () => { - it('returns 0 when account has no staking rewards', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockResolvedValue(0); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); - - it('returns staking rewards when account has unclaimed rewards', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockResolvedValue(5000000); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + describe('fetchAccountAssetsFromTrongrid', () => { + it('always hits the chain through the Snap adapter regardless of the migration stage', async () => { + await withAssetsService( + async ({ + assetsService, + mockTrongridApiClient, + mockTronHttpClient, + mockPriceApiClient, + setMigrationStage, + }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); - const stakingRewardsAsset = findAsset( - assets, - KnownCaip19Id.TrxStakingRewardsMainnet, - ); - expect(stakingRewardsAsset?.rawAmount).toBe('5000000'); - expect(stakingRewardsAsset?.uiAmount).toBe('5'); - expect(stakingRewardsAsset?.symbol).toBe('trx-staking-rewards'); - }, - ); - }); + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + trc20: [{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); - it('gracefully handles staking rewards API failure', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - }) => { - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( - minimalTronAccount, - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - mockTronHttpClient.getReward.mockRejectedValue( - new Error('API Error'), - ); + const usdtAssetId = + `${String(Network.Mainnet)}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; + mockPriceApiClient.getMultipleSpotPrices.mockResolvedValue( + createSpotPrices({ + [KnownCaip19Id.TrxMainnet]: { + id: KnownCaip19Id.TrxMainnet, + price: 1.0, + }, + [usdtAssetId]: { id: usdtAssetId, price: 1.0 }, + }), + ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + const assets = + await assetsService.fetchAccountAssetsFromTrongrid(mockAccount); - expect( - findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); + // Fungible assets are returned even though the migration is + // active: the TronGrid fallback never depends on the flag state. + expect( + assets.some( + (asset: AssetEntity) => + asset.assetType === KnownCaip19Id.TrxMainnet, + ), + ).toBe(true); + expect( + assets.some( + (asset: AssetEntity) => asset.assetType === usdtAssetId, + ), + ).toBe(true); + }, + ); }); }); @@ -2962,59 +2006,6 @@ describe('AssetsService', () => { ); }); - it('fetches only snap-owned assets when migration is active', async () => { - await withAssetsService( - async ({ - assetsService, - mockTrongridApiClient, - mockTronHttpClient, - setMigrationStage, - }) => { - setMigrationStage(activeMigrationStage); - - mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue({ - address: mockAccount.address, - balance: 5_000_000, - trc20: [ - { - TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '1000000', - }, - ], - assetV2: [], - frozenV2: [], - unfrozenV2: [], - } as unknown as TronAccount); - mockTronHttpClient.getAccountResources.mockResolvedValue({ - ...emptyAccountResources, - freeNetLimit: 600, - EnergyLimit: 1000, - }); - mockTronHttpClient.getReward.mockResolvedValue(0); - - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); - expect(assets.length).toBeGreaterThan(0); - expect( - assets.every((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(false); - }, - ); - }); - it('emits only snap-owned assets and does not persist when migration is active', async () => { await withAssetsService( async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 181a0555e..0ca5cc3a1 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -7,7 +7,6 @@ import { import type { KeyringAccount } from '@metamask/keyring-api'; import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; -import type { Network } from '../../constants'; import type { AssetEntity } from '../../entities/assets'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { SnapAssetsAdapter } from './adapters/SnapAssetsAdapter'; @@ -83,30 +82,51 @@ export class AssetsService { return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } - async fetchAssetsAndBalancesForAccount( - scope: Network, - account: KeyringAccount, - ): Promise { + async getAccountAssets(accountId: string): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); + return this.#coreAdapter.getAccountAssets(accountId); } - return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); + return this.#snapAdapter.getAccountAssets(accountId); } - async saveMany(assets: AssetEntity[]): Promise { + /** + * Fetches live assets and balances for the given account across all its + * scopes from the chain. Migration-aware: when the migration is active the + * fetch goes through the AssetsController fetch pipeline, otherwise it + * hits TronGrid directly through the Snap adapter. + * + * @param account - The account to fetch live assets for. + * @returns The live assets. + */ + async fetchAccountAssets(account: KeyringAccount): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.saveMany(assets); + return this.#coreAdapter.fetchAccountAssets(account); } - return this.#snapAdapter.saveMany(assets); + return this.#snapAdapter.fetchAccountAssets(account); } - async getAccountAssets(accountId: string): Promise { + /** + * Fetches live assets and balances for the given account across all its + * scopes directly from TronGrid through the Snap adapter. The fallback + * path for live asset fetching: it never consults the migration feature + * flag. + * + * @param account - The account to fetch live assets for. + * @returns The live assets. + */ + async fetchAccountAssetsFromTrongrid( + account: KeyringAccount, + ): Promise { + return this.#snapAdapter.fetchAccountAssets(account); + } + + async saveMany(assets: AssetEntity[]): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.getAccountAssets(accountId); + return this.#coreAdapter.saveMany(assets); } - return this.#snapAdapter.getAccountAssets(accountId); + return this.#snapAdapter.saveMany(assets); } } diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts index e2b1b5165..0a81944df 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts @@ -4,11 +4,6 @@ import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { AssetsProvider } from '@metamask/snap-networks-utils'; -import type { AccountResources } from '../../../clients/tron-http'; -import { - TrongridAccountNotFoundError, - TrongridHttpError, -} from '../../../clients/trongrid/errors'; import { KnownCaip19Id, Network } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import { CoreAssetsAdapter } from './CoreAssetsAdapter'; @@ -33,18 +28,6 @@ const mockAccount: KeyringAccount = { scopes: [Network.Mainnet], }; -const emptyAccountResources: AccountResources = { - freeNetUsed: 0, - freeNetLimit: 0, - NetLimit: 0, - TotalNetLimit: 0, - TotalNetWeight: 0, - tronPowerUsed: 0, - tronPowerLimit: 0, - TotalEnergyLimit: 0, - TotalEnergyWeight: 0, -}; - /** * Builds a controller asset for adapter mapping tests. * @@ -129,41 +112,27 @@ function createCoreAssetsAdapterContext(): { | 'getAccountAssetByID' | 'getAccountAssetsByIDs' | 'getAccountAssetsByScope' + | 'getAssets' > >; - mockGetAddressInfo: jest.Mock; - mockGetAddressResources: jest.Mock; - mockGetAddressStakingRewards: jest.Mock; } { const mockAssetsProvider = { getAccountAssetByID: jest.fn().mockResolvedValue(undefined), getAccountAssetsByIDs: jest.fn().mockResolvedValue({}), getAccountAssetsByScope: jest.fn().mockResolvedValue({}), + getAssets: jest.fn().mockResolvedValue({}), }; - const mockGetAddressInfo = jest - .fn() - .mockRejectedValue(new TrongridAccountNotFoundError()); - const mockGetAddressResources = jest - .fn() - .mockResolvedValue(emptyAccountResources); - const mockGetAddressStakingRewards = jest.fn().mockResolvedValue(0); - const adapter = new CoreAssetsAdapter({ getAccountAssetByID: mockAssetsProvider.getAccountAssetByID, getAccountAssetsByIDs: mockAssetsProvider.getAccountAssetsByIDs, getAccountAssetsByScope: mockAssetsProvider.getAccountAssetsByScope, - getAddressInfo: mockGetAddressInfo, - getAddressResources: mockGetAddressResources, - getAddressStakingRewards: mockGetAddressStakingRewards, + getAssets: mockAssetsProvider.getAssets, }); return { adapter, mockAssetsProvider, - mockGetAddressInfo, - mockGetAddressResources, - mockGetAddressStakingRewards, }; } @@ -342,104 +311,65 @@ describe('CoreAssetsAdapter', () => { }); }); - describe('fetchAssetsAndBalancesForAccount', () => { - it('returns zero snap-owned assets when the account is inactive', async () => { - await withCoreAssetsAdapter(async ({ adapter, mockGetAddressInfo }) => { - mockGetAddressInfo.mockRejectedValue( - new TrongridAccountNotFoundError(), - ); - - const assets = await adapter.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); + describe('fetchAccountAssets', () => { + it('fetches through the controller with forced update and no server cache', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + await adapter.fetchAccountAssets(mockAccount); - const assetTypes = assets.map((asset) => asset.assetType); - const expectedAssetTypes = [ - KnownCaip19Id.TrxStakedForBandwidthMainnet, - KnownCaip19Id.TrxStakedForEnergyMainnet, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - KnownCaip19Id.TrxStakingRewardsMainnet, - KnownCaip19Id.TrxInLockPeriodMainnet, - KnownCaip19Id.BandwidthMainnet, - KnownCaip19Id.MaximumBandwidthMainnet, - KnownCaip19Id.EnergyMainnet, - KnownCaip19Id.MaximumEnergyMainnet, - ]; - expect(assetTypes).toHaveLength(9); - expect([...assetTypes].sort()).toStrictEqual( - [...expectedAssetTypes].sort(), + expect(mockAssetsProvider.getAssets).toHaveBeenCalledWith( + [expect.objectContaining({ id: ACCOUNT_ID })], + { + chainIds: [Network.Mainnet], + forceUpdate: true, + bypassServerCache: true, + }, ); - expect(assetTypes).not.toContain(KnownCaip19Id.TrxMainnet); - expect(assets.every((asset) => asset.rawAmount === '0')).toBe(true); }); }); - it('throws when account info fails with an HTTP error', async () => { - await withCoreAssetsAdapter(async ({ adapter, mockGetAddressInfo }) => { - mockGetAddressInfo.mockRejectedValue(new TrongridHttpError(500)); + it('maps the fetched controller assets for the account', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + const mainnetAsset = createControllerAsset({ id: MAINNET_ASSET_ID }); + const usdtAsset = createControllerAsset({ + id: USDT_ASSET_ID, + symbol: 'USDT', + }); + mockAssetsProvider.getAssets.mockResolvedValue({ + [ACCOUNT_ID]: { + [MAINNET_ASSET_ID]: mainnetAsset, + [USDT_ASSET_ID]: usdtAsset, + }, + } as never); + + const assets = await adapter.fetchAccountAssets(mockAccount); - await expect( - adapter.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ), - ).rejects.toThrow(TrongridHttpError); + expect(assets.map((asset) => asset.assetType).sort()).toStrictEqual( + [MAINNET_ASSET_ID, USDT_ASSET_ID].sort(), + ); + expect( + assets.every((asset) => asset.keyringAccountId === ACCOUNT_ID), + ).toBe(true); }); }); - it('throws when account resources request rejects', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockGetAddressResources }) => { - mockGetAddressResources.mockRejectedValue( - new Error('HTTP error! status: 500'), - ); - - await expect( - adapter.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ), - ).rejects.toThrow('HTTP error! status: 500'); - }, - ); - }); + it('returns an empty list when the controller returns nothing for the account', async () => { + await withCoreAssetsAdapter(async ({ adapter }) => { + const assets = await adapter.fetchAccountAssets(mockAccount); - it('throws when staking rewards request rejects', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockGetAddressStakingRewards }) => { - mockGetAddressStakingRewards.mockRejectedValue( - new Error('HTTP error! status: 503'), - ); - - await expect( - adapter.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ), - ).rejects.toThrow('HTTP error! status: 503'); - }, - ); + expect(assets).toStrictEqual([]); + }); }); - it('maps staking rewards from a successful reward request', async () => { - await withCoreAssetsAdapter( - async ({ adapter, mockGetAddressStakingRewards }) => { - mockGetAddressStakingRewards.mockResolvedValue(1_000_000); - - const assets = await adapter.fetchAssetsAndBalancesForAccount( - Network.Mainnet, - mockAccount, - ); - const stakingRewards = assets.find( - (asset) => - asset.assetType === KnownCaip19Id.TrxStakingRewardsMainnet, - ); - - expect(stakingRewards?.rawAmount).toBe('1000000'); - expect(stakingRewards?.uiAmount).toBe('1'); - }, - ); + it('rejects when the controller fetch fails', async () => { + await withCoreAssetsAdapter(async ({ adapter, mockAssetsProvider }) => { + mockAssetsProvider.getAssets.mockRejectedValue( + new Error('fetch failed'), + ); + + await expect(adapter.fetchAccountAssets(mockAccount)).rejects.toThrow( + 'fetch failed', + ); + }); }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts index 5a2382909..4970f91e7 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -9,29 +9,18 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AssetsProvider } from '@metamask/snap-networks-utils'; import { Logger } from '@metamask/snap-networks-utils'; -import type { TronHttpClient } from '../../../clients/tron-http/TronHttpClient'; -import { TrongridAccountNotFoundError } from '../../../clients/trongrid/errors'; -import type { TrongridApiClient } from '../../../clients/trongrid/TrongridApiClient'; import { Network } from '../../../constants'; import type { AssetEntity } from '../../../entities/assets'; import logger from '../../../utils/logger'; -import { buildStakedData } from '../utils/buildStakedData'; -import { extractBandwidth } from '../utils/extractBandwidth'; -import { extractEnergy } from '../utils/extractEnergy'; -import { extractInLockPeriodAsset } from '../utils/extractInLockPeriodAsset'; -import { extractReadyForWithdrawalAsset } from '../utils/extractReadyForWithdrawalAsset'; -import { extractStakedNativeAssets } from '../utils/extractStakedNativeAssets'; -import { extractStakingRewardsAsset } from '../utils/extractStakingRewardsAsset'; import { isSnapOwnedAsset } from '../utils/isSnapOwnedAsset'; import { mapControllerAsset } from '../utils/mapControllerAsset'; +import { toInternalAccount } from '../utils/toInternalAccount'; export type CoreAssetsAdapterOptions = { getAccountAssetByID: AssetsProvider['getAccountAssetByID']; getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; - getAddressInfo: TrongridApiClient['getAccountInfoByAddress']; - getAddressResources: TronHttpClient['getAccountResources']; - getAddressStakingRewards: TronHttpClient['getReward']; + getAssets: AssetsProvider['getAssets']; }; /** @@ -47,29 +36,21 @@ export class CoreAssetsAdapter { readonly #getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; - readonly #getAddressInfo: TrongridApiClient['getAccountInfoByAddress']; - - readonly #getAddressResources: TronHttpClient['getAccountResources']; - - readonly #getAddressStakingRewards: TronHttpClient['getReward']; + readonly #getAssets: AssetsProvider['getAssets']; constructor(options: CoreAssetsAdapterOptions) { const { getAccountAssetByID, getAccountAssetsByIDs, getAccountAssetsByScope, - getAddressInfo, - getAddressResources, - getAddressStakingRewards, + getAssets, } = options; this.#logger = logger.withPrefix('[CoreAssetsAdapter]'); this.#getAccountAssetByID = getAccountAssetByID; this.#getAccountAssetsByIDs = getAccountAssetsByIDs; this.#getAccountAssetsByScope = getAccountAssetsByScope; - this.#getAddressInfo = getAddressInfo; - this.#getAddressResources = getAddressResources; - this.#getAddressStakingRewards = getAddressStakingRewards; + this.#getAssets = getAssets; } async getAccountAssetByID( @@ -137,77 +118,31 @@ export class CoreAssetsAdapter { } /** - * We used to fetch all assets and balances but now the Snap is only responsible for fetching snap-owned assets. - * - Energy & Bandwidth - * - Staked TRX and full staking lifecycle (rewards, in lock period, ready for withdrawal) + * Fetches live assets and balances for the given account across all its + * scopes through the AssetsController's fetch pipeline (`getAssets` with + * `forceUpdate` and `bypassServerCache`, so neither client nor server + * caches are used). * - * @param scope - The network to query. * @param account - The keyring account. - * @returns Promise - Array of assets with balances. + * @returns The freshly fetched assets. */ - async fetchAssetsAndBalancesForAccount( - scope: Network, - account: KeyringAccount, - ): Promise { - this.#logger.info('Fetching assets and balances for account', { - scope, - account, + async fetchAccountAssets(account: KeyringAccount): Promise { + this.#logger.info('Fetching assets and balances via AssetsController', { + accountId: account.id, + scopes: account.scopes, }); - /** - * `getAccountInfoByAddress` rejects with `TrongridAccountNotFoundError` for - * inactive accounts. We still wait for all three requests, then rethrow - * unexpected failures (HTTP errors, timeouts) so they are not mistaken for - * an inactive account. - */ - const [ - addressInfoRequest, - addressResourcesRequest, - addressStakingRewardsRequest, - ] = await Promise.allSettled([ - this.#getAddressInfo(scope, account.address), - this.#getAddressResources(scope, account.address), - this.#getAddressStakingRewards(scope, account.address), - ]); - - /** - * If any of the requests fail let's treat it as a panic except for the inactive account case. - */ - if ( - addressInfoRequest.status === 'rejected' && - !(addressInfoRequest.reason instanceof TrongridAccountNotFoundError) - ) { - throw addressInfoRequest.reason; - } - - if (addressResourcesRequest.status === 'rejected') { - throw addressResourcesRequest.reason; - } - - if (addressStakingRewardsRequest.status === 'rejected') { - throw addressStakingRewardsRequest.reason; - } + const results = await this.#getAssets([toInternalAccount(account)], { + chainIds: account.scopes, + forceUpdate: true, + bypassServerCache: true, + }); - const stakedData = buildStakedData(addressInfoRequest); - const resources = addressResourcesRequest.value; - const stakingRewards = Math.max(0, addressStakingRewardsRequest.value); + const accountAssets = results[account.id] ?? {}; - return [ - ...extractStakedNativeAssets(account, scope, stakedData), - extractReadyForWithdrawalAsset(account, scope, stakedData), - extractInLockPeriodAsset(account, scope, stakedData), - extractStakingRewardsAsset(account, scope, stakingRewards), - ...extractBandwidth({ - account, - scope, - tronAccountResources: resources, - }), - ...extractEnergy({ - account, - scope, - tronAccountResources: resources, - }), - ]; + return Object.values(accountAssets).map((asset: Asset) => + mapControllerAsset(account.id, 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 index 56757ebc3..fc75b2ded 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -267,6 +267,16 @@ export class SnapAssetsAdapter { return this.#filterTokensWithoutPriceData(enrichedAssets, spotPrices); } + async fetchAccountAssets(account: KeyringAccount): Promise { + const results = await Promise.all( + account.scopes.map((scope) => + this.fetchAssetsAndBalancesForAccount(scope as Network, account), + ), + ); + + return results.flat(); + } + /** * Filters out spam tokens (those without price data). * Essential assets are always kept. Tokens need price data to be included. diff --git a/packages/tron-wallet-snap/src/services/assets/utils/toInternalAccount.ts b/packages/tron-wallet-snap/src/services/assets/utils/toInternalAccount.ts new file mode 100644 index 000000000..4344428fe --- /dev/null +++ b/packages/tron-wallet-snap/src/services/assets/utils/toInternalAccount.ts @@ -0,0 +1,34 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { AssetsProvider } from '@metamask/snap-networks-utils'; + +/** + * The account shape expected by `AssetsProvider.getAssets`. + */ +type GetAssetsAccount = Parameters[0][number]; + +/** + * Maps a keyring account to the internal account shape expected by the + * AssetsController's `getAssets` action. The controller only reads + * `id`, `scopes`, and `address`; the metadata is synthetic to satisfy + * the type. + * + * @param account - The keyring account to map. + * @returns The internal account for the controller fetch pipeline. + */ +export function toInternalAccount(account: KeyringAccount): GetAssetsAccount { + return { + id: account.id, + address: account.address, + type: account.type, + scopes: account.scopes, + options: account.options, + methods: account.methods, + metadata: { + name: account.id, + importTime: 0, + keyring: { + type: 'Snap Keyring', + }, + }, + } as GetAssetsAccount; +} diff --git a/packages/tron-wallet-snap/src/types/core-messenger.ts b/packages/tron-wallet-snap/src/types/core-messenger.ts index 0f326c235..fdb3a44c0 100644 --- a/packages/tron-wallet-snap/src/types/core-messenger.ts +++ b/packages/tron-wallet-snap/src/types/core-messenger.ts @@ -2,6 +2,7 @@ import type { AssetsControllerGetAccountAssetByIDAction, AssetsControllerGetAccountAssetsByIDsAction, AssetsControllerGetAccountAssetsByScopeAction, + AssetsControllerGetAssetsAction, } from '@metamask/assets-controller'; import type { Messenger } from '@metamask/messenger'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; @@ -16,7 +17,8 @@ export type CoreMessengerActions = | RemoteFeatureFlagControllerGetStateAction | AssetsControllerGetAccountAssetByIDAction | AssetsControllerGetAccountAssetsByIDsAction - | AssetsControllerGetAccountAssetsByScopeAction; + | AssetsControllerGetAccountAssetsByScopeAction + | AssetsControllerGetAssetsAction; /** * Messenger type passed to `getMessenger` for Core controller actions. diff --git a/packages/tron-wallet-snap/src/validation/structs.ts b/packages/tron-wallet-snap/src/validation/structs.ts index adb05c4ec..fa65b9b1a 100644 --- a/packages/tron-wallet-snap/src/validation/structs.ts +++ b/packages/tron-wallet-snap/src/validation/structs.ts @@ -50,14 +50,14 @@ export const GetAccountStruct = object({ export const DeleteAccountStruct = object({ accountId: UuidStruct, }); -export const ListAccountAssetsStruct = object({ +export const GetAccountAssetsStruct = object({ accountId: UuidStruct, }); export const GetAccountBalancesStruct = object({ accountId: UuidStruct, assets: array(CaipAssetTypeStruct), }); -export const ListAccountTransactionsStruct = object({ +export const GetAccountTransactionsStruct = object({ accountId: UuidStruct, pagination: object({ limit: integer(), @@ -99,7 +99,7 @@ export const GetAccounBalancesResponseStruct = record( }), ); -export const ListAccountAssetsResponseStruct = array(CaipAssetTypeStruct); +export const GetAccountAssetsResponseStruct = array(CaipAssetTypeStruct); export const SubmitRequestMethodStruct = enums(Object.values(SolMethod)); diff --git a/packages/tron-wallet-snap/src/validation/validators.test.ts b/packages/tron-wallet-snap/src/validation/validators.test.ts index 1c623942d..006bf398d 100644 --- a/packages/tron-wallet-snap/src/validation/validators.test.ts +++ b/packages/tron-wallet-snap/src/validation/validators.test.ts @@ -1,11 +1,11 @@ -import { ListAccountAssetsResponseStruct } from './structs'; +import { GetAccountAssetsResponseStruct } from './structs'; import { validateResponse } from './validators'; describe('Validators', () => { describe('validateResponse', () => { it('throws invalid response', () => { expect(() => - validateResponse({}, ListAccountAssetsResponseStruct), + validateResponse({}, GetAccountAssetsResponseStruct), ).toThrow(`Invalid Response: Expected an array value`); }); });