From f63d7878014e8b9a95a7dd347f18ab12f8fb6f83 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Thu, 1 Oct 2026 16:13:47 +0100 Subject: [PATCH 1/4] feat(tron-wallet-snap): return live data from listAccountAssets and getAccountBalances --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- packages/tron-wallet-snap/jest.config.js | 8 +- .../src/handlers/keyring/keyring.test.ts | 111 +++++++++++- .../src/handlers/keyring/keyring.ts | 8 +- .../services/accounts/AccountsService.test.ts | 42 ++--- .../src/services/accounts/AccountsService.ts | 5 +- .../src/services/assets/AssetsService.test.ts | 167 ++++++++++-------- .../src/services/assets/AssetsService.ts | 29 ++- 8 files changed, 261 insertions(+), 111 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index 9fdf473d5..ebe638829 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 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..1a54e0191 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: 72.95, + functions: 81.35, + lines: 86.34, + statements: 86.34, }, }, }; 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..386a55cfb 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([]), + fetchAccountAssets: jest + .fn() + .mockResolvedValue([mockLiveAsset, mockShastaAsset]), } as unknown as jest.Mocked; mockTransactionsService = { checkAddressActivity: jest.fn(), @@ -670,15 +690,28 @@ 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.fetchAccountAssets).toHaveBeenCalledWith( + mockAccount, ); }); + it('propagates fetch failures', async () => { + mockAssetsService.fetchAccountAssets.mockRejectedValue( + new Error('network error'), + ); + + await expect( + keyringHandler.getAccountAssets(mockAccount.id), + ).rejects.toThrow('network error'); + }); + it('throws when the account is not found', async () => { mockAccountsService.findById.mockResolvedValue(null); @@ -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.fetchAccountAssets).toHaveBeenCalledWith( + mockAccount, + ); + }); + + it('excludes token assets with zero balance', async () => { + const zeroBalanceToken: AssetEntity = { + ...mockLiveAsset, + assetType: KnownCaip19Id.UsdtMainnet, + rawAmount: '0', + uiAmount: '0', + }; + + mockAssetsService.fetchAccountAssets.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.fetchAccountAssets.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..1989b705d 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -183,12 +183,12 @@ export class KeyringHandler implements KeyringSnapRpc { try { validateRequest({ accountId }, ListAccountAssetsStruct); - await this.#getAccountOrThrow(accountId); + const account = await this.#getAccountOrThrow(accountId); this.#logger.info('Listing account assets', { accountId }); const assetEntities = - await this.#assetsService.getAccountAssets(accountId); + await this.#assetsService.fetchAccountAssets(account); const result = assetEntities .filter( (asset) => @@ -275,9 +275,9 @@ 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.fetchAccountAssets(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..dfee0a039 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([]), + fetchAccountAssetsByScope: jest.fn().mockResolvedValue([]), saveMany: jest.fn().mockResolvedValue(undefined), }; @@ -1053,21 +1053,21 @@ describe('AccountsService', () => { ...MOCK_CONFIG, activeNetworks: [Network.Mainnet, Network.Shasta], }; - mockAssetsService.fetchAssetsAndBalancesForAccount.mockResolvedValue( + mockAssetsService.fetchAccountAssetsByScope.mockResolvedValue( mockAssets, ); await accountsService.synchronize([account]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, + mockAssetsService.fetchAccountAssetsByScope, ).toHaveBeenCalledTimes(2); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); + mockAssetsService.fetchAccountAssetsByScope, + ).toHaveBeenCalledWith(account, Network.Mainnet); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Shasta, account); + mockAssetsService.fetchAccountAssetsByScope, + ).toHaveBeenCalledWith(account, Network.Shasta); expect(mockAssetsService.saveMany).toHaveBeenCalledWith( expect.arrayContaining(mockAssets), ); @@ -1154,7 +1154,7 @@ describe('AccountsService', () => { ...MOCK_CONFIG, activeNetworks: [Network.Mainnet], }; - mockAssetsService.fetchAssetsAndBalancesForAccount + mockAssetsService.fetchAccountAssetsByScope .mockRejectedValueOnce(new Error('grpc unavailable')) .mockResolvedValueOnce(healthyAssets); @@ -1199,7 +1199,7 @@ describe('AccountsService', () => { throw new Error('toString boom'); }, }; - mockAssetsService.fetchAssetsAndBalancesForAccount.mockRejectedValue( + mockAssetsService.fetchAccountAssetsByScope.mockRejectedValue( hostileReason, ); @@ -1238,7 +1238,7 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, + mockAssetsService.fetchAccountAssetsByScope, ).not.toHaveBeenCalled(); expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); }, @@ -1417,8 +1417,8 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, account); + mockAssetsService.fetchAccountAssetsByScope, + ).toHaveBeenCalledWith(account, Network.Mainnet); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledWith(Network.Mainnet, account); @@ -1463,7 +1463,7 @@ describe('AccountsService', () => { ]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, + mockAssetsService.fetchAccountAssetsByScope, ).toHaveBeenCalledTimes(1); expect( mockTransactionsService.fetchNewTransactionsForAccount, @@ -1488,7 +1488,7 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, + mockAssetsService.fetchAccountAssetsByScope, ).toHaveBeenCalledTimes(2); }, ); @@ -1511,14 +1511,14 @@ describe('AccountsService', () => { ]); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, + mockAssetsService.fetchAccountAssetsByScope, ).toHaveBeenCalledTimes(2); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountA); + mockAssetsService.fetchAccountAssetsByScope, + ).toHaveBeenCalledWith(accountA, Network.Mainnet); expect( - mockAssetsService.fetchAssetsAndBalancesForAccount, - ).toHaveBeenCalledWith(Network.Mainnet, accountB); + mockAssetsService.fetchAccountAssetsByScope, + ).toHaveBeenCalledWith(accountB, Network.Mainnet); }, ); }); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index d5bcfe83a..7f512b976 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -522,10 +522,7 @@ export class AccountsService { const assetResponses = await Promise.allSettled( combinations.map(async ({ account, scope }) => { - return this.#assetsService.fetchAssetsAndBalancesForAccount( - scope, - account, - ); + return this.#assetsService.fetchAccountAssetsByScope(account, scope); }), ); 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..a3dc99249 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -376,7 +376,36 @@ async function withAssetsService( } describe('AssetsService', () => { - describe('fetchAssetsAndBalancesForAccount', () => { + describe('fetchAccountAssets', () => { + it('fetches live assets for all the account scopes', async () => { + await withAssetsService(async ({ assetsService }) => { + const mainnetAsset = { + assetType: KnownCaip19Id.TrxMainnet, + } as AssetEntity; + const shastaAsset = { + assetType: KnownCaip19Id.TrxShasta, + } as AssetEntity; + const account: KeyringAccount = { + ...mockAccount, + scopes: [Network.Mainnet, Network.Shasta], + }; + const spy = jest + .spyOn(assetsService, 'fetchAccountAssetsByScope') + .mockResolvedValueOnce([mainnetAsset]) + .mockResolvedValueOnce([shastaAsset]); + + expect(await assetsService.fetchAccountAssets(account)).toStrictEqual([ + mainnetAsset, + shastaAsset, + ]); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith(1, account, Network.Mainnet); + expect(spy).toHaveBeenNthCalledWith(2, account, Network.Shasta); + }); + }); + }); + + describe('fetchAccountAssetsByScope', () => { describe('inactive account fallback', () => { it('falls back to TRC20 balance endpoint when account info fails (inactive account)', async () => { await withAssetsService( @@ -409,9 +438,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -451,9 +480,9 @@ describe('AssetsService', () => { [], ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const bandwidthAsset = assets.find( @@ -487,9 +516,9 @@ describe('AssetsService', () => { [], ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect(assets.length).toBeGreaterThan(0); @@ -523,9 +552,9 @@ describe('AssetsService', () => { [], ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect(mockSnapClient.trackError).toHaveBeenCalledTimes(1); @@ -582,9 +611,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -624,9 +653,9 @@ describe('AssetsService', () => { new Error('Resources endpoint unavailable'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -660,9 +689,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -686,9 +715,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 200 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -712,9 +741,9 @@ describe('AssetsService', () => { getMockAccountResources({ freeNetUsed: 326, NetLimit: 16 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -742,9 +771,9 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -768,9 +797,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -795,9 +824,9 @@ describe('AssetsService', () => { getMockAccountResources({}), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -822,9 +851,9 @@ describe('AssetsService', () => { getMockAccountResources({ NetLimit: 48 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -852,9 +881,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const readyForWithdrawalAsset = findAsset( @@ -885,9 +914,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const readyForWithdrawalAsset = findAsset( @@ -921,9 +950,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const readyForWithdrawalAsset = findAsset( @@ -956,9 +985,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const readyForWithdrawalAsset = findAsset( @@ -994,9 +1023,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const readyForWithdrawalAsset = findAsset( @@ -1026,9 +1055,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1062,9 +1091,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1098,9 +1127,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1139,9 +1168,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1177,9 +1206,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1207,9 +1236,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const inLockPeriodAsset = findAsset( @@ -1236,9 +1265,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1262,9 +1291,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1288,9 +1317,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 5000, EnergyUsed: 4383 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1314,9 +1343,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 46, EnergyUsed: 6511 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1340,9 +1369,9 @@ describe('AssetsService', () => { ); mockTronHttpClient.getAccountResources.mockResolvedValue({}); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1366,9 +1395,9 @@ describe('AssetsService', () => { getMockAccountResources({ EnergyLimit: 329 }), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1393,9 +1422,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(0); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -1419,9 +1448,9 @@ describe('AssetsService', () => { mockTronHttpClient.getAccountResources.mockResolvedValue({}); mockTronHttpClient.getReward.mockResolvedValue(5000000); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); const stakingRewardsAsset = findAsset( @@ -1450,9 +1479,9 @@ describe('AssetsService', () => { new Error('API Error'), ); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( @@ -2991,9 +3020,9 @@ describe('AssetsService', () => { }); mockTronHttpClient.getReward.mockResolvedValue(0); - const assets = await assetsService.fetchAssetsAndBalancesForAccount( - Network.Mainnet, + const assets = await assetsService.fetchAccountAssetsByScope( mockAccount, + Network.Mainnet, ); expect( diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 181a0555e..8f18541e1 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -83,9 +83,17 @@ export class AssetsService { return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } - async fetchAssetsAndBalancesForAccount( - scope: Network, + /** + * Fetches live assets and balances for the given account from the chain, + * for a single scope. + * + * @param account - The account to fetch live assets for. + * @param scope - The scope to fetch live assets for. + * @returns The live assets. + */ + async fetchAccountAssetsByScope( account: KeyringAccount, + scope: Network, ): Promise { if (await this.#shouldReturnAssetsFromCore()) { return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); @@ -94,6 +102,23 @@ export class AssetsService { return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); } + /** + * Fetches live assets and balances for the given account across all its + * scopes from the chain. + * + * @param account - The account to fetch live assets for. + * @returns The live assets. + */ + async fetchAccountAssets(account: KeyringAccount): Promise { + const results = await Promise.all( + account.scopes.map((scope) => + this.fetchAccountAssetsByScope(account, scope as Network), + ), + ); + + return results.flat(); + } + async saveMany(assets: AssetEntity[]): Promise { if (await this.#shouldReturnAssetsFromCore()) { return this.#coreAdapter.saveMany(assets); From 1620bad8ae1deb3355d9f03d8d9a484661afe57e Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 5 Oct 2026 11:01:07 +0100 Subject: [PATCH 2/4] fix(tron-wallet-snap): always fetch live assets for keyring methods via TronGrid The keyring methods (listAccountAssets, getAccountBalances) fetched live data through a feature-flag-gated path: when the assets migration flag was active the fetch routed through the CoreAssetsAdapter, which only returns snap-owned assets, changing the response shape based on flag state. fetchAccountAssets now always hits TronGrid through the Snap adapter regardless of the migration stage; the migration-aware fan-out stays in AssetsService.fetchAccountAssetsByScope for the asset synchronization flow. --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- .../src/services/assets/AssetsService.test.ts | 128 ++++++++++++++---- .../src/services/assets/AssetsService.ts | 13 +- 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index ebe638829..f1a98b4e7 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,7 +15,7 @@ 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 instead of returning persisted state. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) +- `listAccountAssets` and `getAccountBalances` now fetch live assets and balances from the chain instead of returning persisted state. These methods always hit the chain through the Snap's TronGrid clients, regardless of the assets migration feature flag state. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) ### Fixed 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 a3dc99249..42825f644 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -377,31 +377,109 @@ async function withAssetsService( describe('AssetsService', () => { describe('fetchAccountAssets', () => { - it('fetches live assets for all the account scopes', async () => { - await withAssetsService(async ({ assetsService }) => { - const mainnetAsset = { - assetType: KnownCaip19Id.TrxMainnet, - } as AssetEntity; - const shastaAsset = { - assetType: KnownCaip19Id.TrxShasta, - } as AssetEntity; - const account: KeyringAccount = { - ...mockAccount, - scopes: [Network.Mainnet, Network.Shasta], - }; - const spy = jest - .spyOn(assetsService, 'fetchAccountAssetsByScope') - .mockResolvedValueOnce([mainnetAsset]) - .mockResolvedValueOnce([shastaAsset]); - - expect(await assetsService.fetchAccountAssets(account)).toStrictEqual([ - mainnetAsset, - shastaAsset, - ]); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenNthCalledWith(1, account, Network.Mainnet); - expect(spy).toHaveBeenNthCalledWith(2, account, Network.Shasta); - }); + 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, + balance: 1_000_000, + }), + ) + .mockResolvedValueOnce( + createMockTronAccount({ + address: mockAccount.address, + balance: 2_000_000, + }), + ); + + const account: KeyringAccount = { + ...mockAccount, + scopes: [Network.Mainnet, Network.Shasta], + }; + + const assets = await assetsService.fetchAccountAssets(account); + + 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', + ); + }, + ); + }); + + 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, + ); + + mockTrongridApiClient.getAccountInfoByAddress.mockResolvedValue( + createMockTronAccount({ + address: mockAccount.address, + balance: 1_000_000, + trc20: [{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' }], + }), + ); + mockTronHttpClient.getAccountResources.mockResolvedValue( + emptyAccountResources, + ); + + 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.fetchAccountAssets(mockAccount); + + // Fungible assets are returned even though the migration is + // active: the keyring methods must not depend 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); + }, + ); }); }); diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 8f18541e1..375c3b3c8 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -85,7 +85,9 @@ export class AssetsService { /** * Fetches live assets and balances for the given account from the chain, - * for a single scope. + * for a single scope. Used by the asset synchronization flow, which stays + * migration-aware: when the migration is active this only returns + * snap-owned assets. * * @param account - The account to fetch live assets for. * @param scope - The scope to fetch live assets for. @@ -104,7 +106,9 @@ export class AssetsService { /** * Fetches live assets and balances for the given account across all its - * scopes from the chain. + * scopes from the chain. This always hits TronGrid through the Snap + * adapter regardless of the assets migration state, so the keyring + * methods get a consistent response no matter the feature flag. * * @param account - The account to fetch live assets for. * @returns The live assets. @@ -112,7 +116,10 @@ export class AssetsService { async fetchAccountAssets(account: KeyringAccount): Promise { const results = await Promise.all( account.scopes.map((scope) => - this.fetchAccountAssetsByScope(account, scope as Network), + this.#snapAdapter.fetchAssetsAndBalancesForAccount( + scope as Network, + account, + ), ), ); From 5c2c266d3d16cfffd309bf3ade1508d119f2ef33 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 5 Oct 2026 15:49:24 +0100 Subject: [PATCH 3/4] feat(tron-wallet-snap): fetch live assets via AssetsController getAssets The CoreAssetsAdapter fetched live assets by calling TronGrid directly (account info, account resources, staking rewards) and rebuilding the special-asset extraction stack locally, duplicating the fetch pipeline the AssetsController already owns. fetchAccountAssets in the CoreAssetsAdapter now goes through the AssetsController's getAssets action with forceUpdate and bypassServerCache, so neither client nor server caches are used and the special assets flow is owned by the controller pipeline. The Snap adapter keeps the direct TronGrid fetch for the non-migration path, and fetchAccountAssetsFromTrongrid stays as the flag-independent fallback. The asset synchronization flow fetches once per account instead of once per account-and-scope combination. --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- packages/tron-wallet-snap/src/context.ts | 5 +- .../services/accounts/AccountsService.test.ts | 75 +- .../src/services/accounts/AccountsService.ts | 13 +- .../src/services/assets/AssetsService.test.ts | 1242 +---------------- .../src/services/assets/AssetsService.ts | 56 +- .../assets/adapters/CoreAssetsAdapter.test.ts | 170 +-- .../assets/adapters/CoreAssetsAdapter.ts | 111 +- .../assets/adapters/SnapAssetsAdapter.ts | 10 + .../assets/utils/toInternalAccount.ts | 34 + .../src/types/core-messenger.ts | 4 +- 11 files changed, 239 insertions(+), 1483 deletions(-) create mode 100644 packages/tron-wallet-snap/src/services/assets/utils/toInternalAccount.ts diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index f1a98b4e7..a8f1d2557 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,7 +15,7 @@ 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 instead of returning persisted state. These methods always hit the chain through the Snap's TronGrid clients, regardless of the assets migration feature flag state. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) +- `listAccountAssets` and `getAccountBalances` now fetch live assets and balances from the chain instead of returning persisted state. When the assets migration is active the fetch goes through the AssetsController fetch pipeline (`getAssets` with `forceUpdate` and `bypassServerCache`); otherwise it hits the chain through the Snap's TronGrid clients. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) ### Fixed 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/services/accounts/AccountsService.test.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts index dfee0a039..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 > = { - fetchAccountAssetsByScope: 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.fetchAccountAssetsByScope.mockResolvedValue( - mockAssets, - ); + async ({ accountsService, mockAssetsService }) => { + mockAssetsService.fetchAccountAssets.mockResolvedValue(mockAssets); await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledWith(account, Network.Mainnet); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledWith(account, Network.Shasta); + 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.fetchAccountAssetsByScope + mockAssetsService.fetchAccountAssets .mockRejectedValueOnce(new Error('grpc unavailable')) .mockResolvedValueOnce(healthyAssets); @@ -1199,9 +1188,7 @@ describe('AccountsService', () => { throw new Error('toString boom'); }, }; - mockAssetsService.fetchAccountAssetsByScope.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.fetchAccountAssetsByScope, - ).not.toHaveBeenCalled(); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + account, + ); expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); }, ); @@ -1416,9 +1403,9 @@ describe('AccountsService', () => { await accountsService.synchronize([account]); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledWith(account, Network.Mainnet); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( + account, + ); expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledWith(Network.Mainnet, account); @@ -1462,9 +1449,7 @@ describe('AccountsService', () => { accountsService.synchronize([account]), ]); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).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.fetchAccountAssetsByScope, - ).toHaveBeenCalledTimes(2); + expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledTimes(2); }, ); }); @@ -1510,15 +1493,13 @@ describe('AccountsService', () => { accountsService.synchronize([accountB]), ]); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledTimes(2); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledWith(accountA, Network.Mainnet); - expect( - mockAssetsService.fetchAccountAssetsByScope, - ).toHaveBeenCalledWith(accountB, Network.Mainnet); + 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 7f512b976..8a3bfd821 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -515,15 +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.fetchAccountAssetsByScope(account, scope); - }), + accounts.map((account) => + this.#assetsService.fetchAccountAssets(account), + ), ); const assets = assetResponses.flatMap((response) => @@ -533,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 42825f644..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,6 +344,7 @@ async function withAssetsService( mockTokenApiClient, mockSnapClient, mockCoreMessenger, + mockGetAssets, setMigrationStage, }); } @@ -427,6 +401,57 @@ describe('AssetsService', () => { ); }); + it('routes through the AssetsController fetch pipeline when migration is active', async () => { + await withAssetsService( + async ({ assetsService, mockGetAssets, setMigrationStage }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + + 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, + }), + }, + }); + + const assets = await assetsService.fetchAccountAssets(mockAccount); + + 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('fetchAccountAssetsFromTrongrid', () => { it('always hits the chain through the Snap adapter regardless of the migration stage', async () => { await withAssetsService( async ({ @@ -463,10 +488,11 @@ describe('AssetsService', () => { }), ); - const assets = await assetsService.fetchAccountAssets(mockAccount); + const assets = + await assetsService.fetchAccountAssetsFromTrongrid(mockAccount); // Fungible assets are returned even though the migration is - // active: the keyring methods must not depend on the flag state. + // active: the TronGrid fallback never depends on the flag state. expect( assets.some( (asset: AssetEntity) => @@ -483,1095 +509,6 @@ describe('AssetsService', () => { }); }); - describe('fetchAccountAssetsByScope', () => { - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { - unfreeze_amount: 1000000, - unfreeze_expire_time: futureTime, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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( - createMockTronAccount({ - address: mockAccount.address, - unfrozenV2: [ - { unfreeze_amount: 1000000, unfreeze_expire_time: pastTime1 }, - { unfreeze_amount: 2000000, unfreeze_expire_time: pastTime2 }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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 assets = await assetsService.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - const readyForWithdrawalAsset = findAsset( - assets, - KnownCaip19Id.TrxReadyForWithdrawalMainnet, - ); - expect(readyForWithdrawalAsset).toBeDefined(); - expect(readyForWithdrawalAsset?.rawAmount).toBe('1000000'); - }, - ); - }); - }); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - const inLockPeriodAsset = findAsset( - assets, - KnownCaip19Id.TrxInLockPeriodMainnet, - ); - expect(inLockPeriodAsset).toBeDefined(); - expect(inLockPeriodAsset?.rawAmount).toBe('3000000'); - }, - ); - }); - - 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, - }, - ], - }), - ); - mockTronHttpClient.getAccountResources.mockResolvedValue({}); - - const assets = await assetsService.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - expect( - findAsset(assets, KnownCaip19Id.MaximumEnergyMainnet)?.rawAmount, - ).toBe('329'); - }, - ); - }); - }); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - 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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - const stakingRewardsAsset = findAsset( - assets, - KnownCaip19Id.TrxStakingRewardsMainnet, - ); - expect(stakingRewardsAsset?.rawAmount).toBe('5000000'); - expect(stakingRewardsAsset?.uiAmount).toBe('5'); - expect(stakingRewardsAsset?.symbol).toBe('trx-staking-rewards'); - }, - ); - }); - - 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 assets = await assetsService.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - expect( - findAsset(assets, KnownCaip19Id.TrxStakingRewardsMainnet) - ?.rawAmount, - ).toBe('0'); - }, - ); - }); - }); - }); - describe('saveMany', () => { it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { await withAssetsService( @@ -3069,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.fetchAccountAssetsByScope( - mockAccount, - Network.Mainnet, - ); - - expect( - mockTrongridApiClient.getTrc20BalancesByAddress, - ).not.toHaveBeenCalled(); - expect(assets.length).toBeGreaterThan(0); - expect( - assets.every((asset: AssetEntity) => - SNAP_OWNED_ASSETS.includes(asset.assetType), - ), - ).toBe(true); - expect( - assets.some( - (asset: AssetEntity) => - asset.assetType === KnownCaip19Id.TrxMainnet, - ), - ).toBe(false); - }, - ); - }); - 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 375c3b3c8..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,47 +82,44 @@ export class AssetsService { return this.#snapAdapter.getAccountAssetByID(accountId, assetId); } + async getAccountAssets(accountId: string): Promise { + if (await this.#shouldReturnAssetsFromCore()) { + return this.#coreAdapter.getAccountAssets(accountId); + } + + return this.#snapAdapter.getAccountAssets(accountId); + } + /** - * Fetches live assets and balances for the given account from the chain, - * for a single scope. Used by the asset synchronization flow, which stays - * migration-aware: when the migration is active this only returns - * snap-owned assets. + * 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. - * @param scope - The scope to fetch live assets for. * @returns The live assets. */ - async fetchAccountAssetsByScope( - account: KeyringAccount, - scope: Network, - ): Promise { + async fetchAccountAssets(account: KeyringAccount): Promise { if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.fetchAssetsAndBalancesForAccount(scope, account); + return this.#coreAdapter.fetchAccountAssets(account); } - return this.#snapAdapter.fetchAssetsAndBalancesForAccount(scope, account); + return this.#snapAdapter.fetchAccountAssets(account); } /** * Fetches live assets and balances for the given account across all its - * scopes from the chain. This always hits TronGrid through the Snap - * adapter regardless of the assets migration state, so the keyring - * methods get a consistent response no matter the feature flag. + * 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 fetchAccountAssets(account: KeyringAccount): Promise { - const results = await Promise.all( - account.scopes.map((scope) => - this.#snapAdapter.fetchAssetsAndBalancesForAccount( - scope as Network, - account, - ), - ), - ); - - return results.flat(); + async fetchAccountAssetsFromTrongrid( + account: KeyringAccount, + ): Promise { + return this.#snapAdapter.fetchAccountAssets(account); } async saveMany(assets: AssetEntity[]): Promise { @@ -133,12 +129,4 @@ export class AssetsService { return this.#snapAdapter.saveMany(assets); } - - async getAccountAssets(accountId: string): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.getAccountAssets(accountId); - } - - return this.#snapAdapter.getAccountAssets(accountId); - } } 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. From a1db237072d52ab74d3f5d5af33b8f27c957a4fa Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Mon, 5 Oct 2026 16:16:42 +0100 Subject: [PATCH 4/4] refactor(tron-wallet-snap): fetch keyring assets via trongrid and rename validation structs --- packages/tron-wallet-snap/CHANGELOG.md | 2 +- packages/tron-wallet-snap/jest.config.js | 8 +++---- packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/keyring.test.ts | 20 +++++++++--------- .../src/handlers/keyring/keyring.ts | 21 ++++++++++--------- .../src/validation/structs.ts | 6 +++--- .../src/validation/validators.test.ts | 4 ++-- 7 files changed, 32 insertions(+), 31 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index a8f1d2557..7ec13d897 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -15,7 +15,7 @@ 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 instead of returning persisted state. When the assets migration is active the fetch goes through the AssetsController fetch pipeline (`getAssets` with `forceUpdate` and `bypassServerCache`); otherwise it hits the chain through the Snap's TronGrid clients. ([#388](https://github.com/MetaMask/internal-snaps/pull/388)) +- `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 diff --git a/packages/tron-wallet-snap/jest.config.js b/packages/tron-wallet-snap/jest.config.js index 1a54e0191..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.95, - functions: 81.35, - lines: 86.34, - statements: 86.34, + 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/handlers/keyring/keyring.test.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts index 386a55cfb..2ba64e921 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -102,7 +102,7 @@ describe('KeyringHandler', () => { }), } as unknown as jest.Mocked; mockAssetsService = { - fetchAccountAssets: jest + fetchAccountAssetsFromTrongrid: jest .fn() .mockResolvedValue([mockLiveAsset, mockShastaAsset]), } as unknown as jest.Mocked; @@ -697,13 +697,13 @@ describe('KeyringHandler', () => { KnownCaip19Id.TrxMainnet, KnownCaip19Id.TrxShasta, ]); - expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( - mockAccount, - ); + expect( + mockAssetsService.fetchAccountAssetsFromTrongrid, + ).toHaveBeenCalledWith(mockAccount); }); it('propagates fetch failures', async () => { - mockAssetsService.fetchAccountAssets.mockRejectedValue( + mockAssetsService.fetchAccountAssetsFromTrongrid.mockRejectedValue( new Error('network error'), ); @@ -733,9 +733,9 @@ describe('KeyringHandler', () => { unit: 'TRX', }, }); - expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( - mockAccount, - ); + expect( + mockAssetsService.fetchAccountAssetsFromTrongrid, + ).toHaveBeenCalledWith(mockAccount); }); it('excludes token assets with zero balance', async () => { @@ -746,7 +746,7 @@ describe('KeyringHandler', () => { uiAmount: '0', }; - mockAssetsService.fetchAccountAssets.mockResolvedValue([ + mockAssetsService.fetchAccountAssetsFromTrongrid.mockResolvedValue([ mockLiveAsset, zeroBalanceToken, ]); @@ -765,7 +765,7 @@ describe('KeyringHandler', () => { }); it('propagates fetch failures', async () => { - mockAssetsService.fetchAccountAssets.mockRejectedValue( + mockAssetsService.fetchAccountAssetsFromTrongrid.mockRejectedValue( new Error('network error'), ); diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index 1989b705d..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); 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.fetchAccountAssets(account); + 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); @@ -277,7 +277,8 @@ export class KeyringHandler implements KeyringSnapRpc { const account = await this.#getAccountOrThrow(accountId); - const assetsList = await this.#assetsService.fetchAccountAssets(account); + const assetsList = + await this.#assetsService.fetchAccountAssetsFromTrongrid(account); const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) 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`); }); });