From 7bbd68db528bb23846c0ccaa8ab30e9fe55b4ed6 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 7 Oct 2026 18:28:18 +0100 Subject: [PATCH] feat(tron): stop syncing and emitting assets while the assets migration is active --- packages/tron-wallet-snap/CHANGELOG.md | 3 + packages/tron-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/keyring.test.ts | 9 ++ .../src/handlers/keyring/keyring.ts | 5 + .../services/accounts/AccountsService.test.ts | 79 ++++++++-- .../src/services/accounts/AccountsService.ts | 12 +- .../src/services/assets/AssetsService.test.ts | 143 +++++++++++++++--- .../src/services/assets/AssetsService.ts | 37 ++++- .../assets/adapters/CoreAssetsAdapter.test.ts | 8 +- .../assets/adapters/CoreAssetsAdapter.ts | 2 +- .../assets/adapters/SnapAssetsAdapter.ts | 90 +++++++---- 11 files changed, 320 insertions(+), 70 deletions(-) diff --git a/packages/tron-wallet-snap/CHANGELOG.md b/packages/tron-wallet-snap/CHANGELOG.md index d6dc30f85..2a4624b9d 100644 --- a/packages/tron-wallet-snap/CHANGELOG.md +++ b/packages/tron-wallet-snap/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Change the last behaviours missing for the `AssetsController` migration to happen ([#424](https://github.com/MetaMask/internal-snaps/pull/424)) + - When the assets migration feature flag is active, the snap no longer fetches, persists, or publishes assets: the periodic sync cronjob and the synchronization triggered by selected account changes no longer touch assets or emit `AccountAssetListUpdated` and `AccountBalancesUpdated` events, leaving asset syncing to the `AssetsController` + - When the live assets and balances are fetched by `getAccountAssets` and `getAccountBalances`, they are persisted to local state without emitting `AccountAssetListUpdated` and `AccountBalancesUpdated` events - Validate network and CAIP asset type values coming from untrusted boundaries (RPC request params, the Keyring API, the AssetsController) instead of force-casting them, so unsupported values fail fast with an `Invalid params` error ([#403](https://github.com/MetaMask/internal-snaps/pull/403)) - 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 diff --git a/packages/tron-wallet-snap/snap.manifest.json b/packages/tron-wallet-snap/snap.manifest.json index 69f25e24d..220a74fde 100644 --- a/packages/tron-wallet-snap/snap.manifest.json +++ b/packages/tron-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "Ts+SpwPXHGuDCZNLn1PfTPwMX8ECWdp46bKrlYUr1vc=", + "shasum": "oUs09g+NmBge4wEQKoT1spo/E1CWpr4vVI6VeXBxmZQ=", "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 1a8df6064..f75fc4366 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -105,6 +105,7 @@ describe('KeyringHandler', () => { fetchAccountAssetsFromTrongrid: jest .fn() .mockResolvedValue([mockLiveAsset, mockShastaAsset]), + saveMany: jest.fn().mockResolvedValue(undefined), } as unknown as jest.Mocked; mockTransactionsService = { checkAddressActivity: jest.fn(), @@ -700,6 +701,10 @@ describe('KeyringHandler', () => { expect( mockAssetsService.fetchAccountAssetsFromTrongrid, ).toHaveBeenCalledWith(mockAccount); + expect(mockAssetsService.saveMany).toHaveBeenCalledWith([ + mockLiveAsset, + mockShastaAsset, + ]); }); it('propagates fetch failures', async () => { @@ -736,6 +741,10 @@ describe('KeyringHandler', () => { expect( mockAssetsService.fetchAccountAssetsFromTrongrid, ).toHaveBeenCalledWith(mockAccount); + expect(mockAssetsService.saveMany).toHaveBeenCalledWith([ + mockLiveAsset, + mockShastaAsset, + ]); }); it('excludes token assets with zero balance', async () => { diff --git a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts index c3d0014f0..e1a746de9 100644 --- a/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/tron-wallet-snap/src/handlers/keyring/keyring.ts @@ -187,6 +187,9 @@ export class KeyringHandler implements KeyringSnapRpc { const assetEntities = await this.#assetsService.fetchAccountAssetsFromTrongrid(account); + + await this.#assetsService.saveMany(assetEntities); + const result = assetEntities .filter( (asset) => @@ -278,6 +281,8 @@ export class KeyringHandler implements KeyringSnapRpc { const assetsList = await this.#assetsService.fetchAccountAssetsFromTrongrid(account); + await this.#assetsService.saveMany(assetsList); + const assetsToUse = assetsList .filter((asset) => assets.includes(asset.assetType)) // Remove token assets with zero balance 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 315e83784..f8167c5b1 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.test.ts @@ -109,7 +109,10 @@ type WithAccountsServiceCallback = (payload: { mockConfigProvider: { config: Config }; mockLogger: Logger; mockAssetsService: jest.Mocked< - Pick + Pick< + AssetsService, + 'isAssetsMigrationEnabled' | 'fetchAccountAssets' | 'saveManyAndEmit' + > >; mockSnapClient: jest.Mocked< Pick @@ -266,10 +269,14 @@ async function withAccountsService( }; const mockAssetsService: jest.Mocked< - Pick + Pick< + AssetsService, + 'isAssetsMigrationEnabled' | 'fetchAccountAssets' | 'saveManyAndEmit' + > > = { + isAssetsMigrationEnabled: jest.fn().mockResolvedValue(false), fetchAccountAssets: jest.fn().mockResolvedValue([]), - saveMany: jest.fn().mockResolvedValue(undefined), + saveManyAndEmit: jest.fn().mockResolvedValue(undefined), }; const mockTransactionsService: jest.Mocked< @@ -1055,7 +1062,7 @@ describe('AccountsService', () => { expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( account, ); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith( + expect(mockAssetsService.saveManyAndEmit).toHaveBeenCalledWith( expect.arrayContaining(mockAssets), ); expect(trackError).not.toHaveBeenCalled(); @@ -1083,7 +1090,7 @@ describe('AccountsService', () => { activeNetworks: [Network.Mainnet, Network.Shasta], }; const saveError = new Error('storage full'); - mockAssetsService.saveMany.mockRejectedValue(saveError); + mockAssetsService.saveManyAndEmit.mockRejectedValue(saveError); // Save failures are batch-level: tracked as their own error (not // attributed to individual accounts), and the method still resolves. @@ -1155,7 +1162,7 @@ describe('AccountsService', () => { 'sync-fail-id: Error: grpc unavailable', ); // The healthy account's assets are still saved. - expect(mockAssetsService.saveMany).toHaveBeenCalledWith( + expect(mockAssetsService.saveManyAndEmit).toHaveBeenCalledWith( healthyAssets, ); }, @@ -1192,7 +1199,7 @@ describe('AccountsService', () => { // still reported; the sync completes and the save still runs. await accountsService.synchronize([account]); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + expect(mockAssetsService.saveManyAndEmit).toHaveBeenCalledWith([]); expect(trackError).toHaveBeenCalledTimes(1); const tracked = (trackError as jest.Mock).mock.calls[0][0] as Error; expect(tracked.message).toBe( @@ -1225,7 +1232,33 @@ describe('AccountsService', () => { expect(mockAssetsService.fetchAccountAssets).toHaveBeenCalledWith( account, ); - expect(mockAssetsService.saveMany).toHaveBeenCalledWith([]); + expect(mockAssetsService.saveManyAndEmit).toHaveBeenCalledWith([]); + }, + ); + }); + + it('skips fetching and saving assets while the assets migration is active', async () => { + const account: TronKeyringAccount = { + id: 'sync-migration-id', + address: 'TSyncMigration123456789012345', + type: TrxAccountType.Eoa, + options: {}, + methods: [], + scopes: [], + entropySource: 'e1', + derivationPath: "m/44'/195'/0'/0/0", + index: 0, + }; + + await withAccountsService( + async ({ accountsService, mockAssetsService }) => { + mockAssetsService.isAssetsMigrationEnabled.mockResolvedValue(true); + + await accountsService.synchronize([account]); + + expect(mockAssetsService.fetchAccountAssets).not.toHaveBeenCalled(); + expect(mockAssetsService.saveManyAndEmit).not.toHaveBeenCalled(); + expect(trackError).not.toHaveBeenCalled(); }, ); }); @@ -1451,7 +1484,35 @@ describe('AccountsService', () => { expect( mockTransactionsService.fetchNewTransactionsForAccount, ).toHaveBeenCalledTimes(1); - expect(mockAssetsService.saveMany).toHaveBeenCalledTimes(1); + expect(mockAssetsService.saveManyAndEmit).toHaveBeenCalledTimes(1); + expect(mockTransactionsService.saveMany).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('skips asset syncing while the assets migration is active, but still syncs transactions', async () => { + const account = makeSyncAccount('migration-id', 0); + + await withAccountsService( + async ({ + accountsService, + mockConfigProvider, + mockAssetsService, + mockTransactionsService, + }) => { + mockConfigProvider.config = { + ...MOCK_CONFIG, + activeNetworks: [Network.Mainnet], + }; + mockAssetsService.isAssetsMigrationEnabled.mockResolvedValue(true); + + await accountsService.synchronize([account]); + + expect(mockAssetsService.fetchAccountAssets).not.toHaveBeenCalled(); + expect(mockAssetsService.saveManyAndEmit).not.toHaveBeenCalled(); + expect( + mockTransactionsService.fetchNewTransactionsForAccount, + ).toHaveBeenCalledWith(Network.Mainnet, account); expect(mockTransactionsService.saveMany).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts index 7f94c5fe1..b6a207bec 100644 --- a/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts +++ b/packages/tron-wallet-snap/src/services/accounts/AccountsService.ts @@ -509,12 +509,22 @@ export class AccountsService { * Synchronizes only assets for the given accounts. * This method can be called independently to sync assets without syncing transactions. * + * A no-op while the assets migration is active: the AssetsController owns + * asset syncing then, so the snap neither fetches nor emits assets. + * * Fetch failures are reported to Sentry in one `SynchronizationError`, * attributed to their account; save failures are tracked standalone. * * @param accounts - The accounts to synchronize assets for. */ async synchronizeAssets(accounts: TronKeyringAccount[]): Promise { + if (await this.#assetsService.isAssetsMigrationEnabled()) { + /** + * No-op when AssetsController is already handling assets + */ + return; + } + const assetResponses = await Promise.allSettled( accounts.map((account) => this.#assetsService.fetchAccountAssets(account), @@ -532,7 +542,7 @@ export class AccountsService { ); try { - await this.#assetsService.saveMany(assets); + await this.#assetsService.saveManyAndEmit(assets); } catch (error) { // Save failures are batch-level (not attributable to one account), so // they are tracked standalone. 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 9a809cd69..3e2351caa 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.test.ts @@ -509,7 +509,7 @@ describe('AssetsService', () => { }); }); - describe('saveMany', () => { + describe('saveManyAndEmit', () => { it('does not remove energy and bandwidth assets even when they have zero amounts', async () => { await withAssetsService( async ({ assetsService, mockState, mockAssetsRepository }) => { @@ -548,7 +548,7 @@ describe('AssetsService', () => { mockState.getKey.mockResolvedValue(assets); - await assetsService.saveMany(assets); + await assetsService.saveManyAndEmit(assets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -601,7 +601,7 @@ describe('AssetsService', () => { [mockAccount.id]: assets, }); - await assetsService.saveMany(assets); + await assetsService.saveManyAndEmit(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( expect.anything(), @@ -674,7 +674,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( finalSavedAssets, @@ -752,7 +752,7 @@ describe('AssetsService', () => { mockState.getKey.mockResolvedValue(assets); - await assetsService.saveMany(assets); + await assetsService.saveManyAndEmit(assets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -812,7 +812,7 @@ describe('AssetsService', () => { mockState.getKey.mockResolvedValue(assets); - await assetsService.saveMany(assets); + await assetsService.saveManyAndEmit(assets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -862,7 +862,7 @@ describe('AssetsService', () => { mockState.getKey.mockResolvedValue(assets); - await assetsService.saveMany(assets); + await assetsService.saveManyAndEmit(assets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( @@ -937,7 +937,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1013,7 +1013,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1092,7 +1092,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1212,7 +1212,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1289,7 +1289,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1367,7 +1367,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1463,7 +1463,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1562,7 +1562,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1639,7 +1639,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1716,7 +1716,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1813,7 +1813,7 @@ describe('AssetsService', () => { [mockAccount.id]: savedAssets, }); - await assetsService.saveMany(updatedAssets); + await assetsService.saveManyAndEmit(updatedAssets); expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith( updatedAssets, @@ -1863,6 +1863,111 @@ describe('AssetsService', () => { }); }); + describe('saveMany', () => { + it('persists the snapshot without emitting keyring events', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ [mockAccount.id]: assets }); + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }, + ); + }); + + it('updates stale non-essential assets balance to 0 without emitting', async () => { + await withAssetsService( + async ({ assetsService, mockState, mockAssetsRepository }) => { + const trc20AssetId = + `${Network.Mainnet}/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` as const; + const savedAssets: AssetEntity[] = [ + { + assetType: trc20AssetId as TokenCaipAssetType, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'USDT', + decimals: 6, + rawAmount: '500000', + uiAmount: '0.5', + iconUrl: '', + }, + ]; + const latestSnapshot: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + ]; + + mockState.getKey.mockResolvedValue({ + [mockAccount.id]: savedAssets, + }); + + await assetsService.saveMany(latestSnapshot); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith([ + ...latestSnapshot, + { + ...savedAssets[0], + rawAmount: '0', + uiAmount: '0', + }, + ]); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }, + ); + }); + + it('persists locally even when the migration is active', async () => { + await withAssetsService( + async ({ assetsService, mockAssetsRepository, setMigrationStage }) => { + setMigrationStage( + SnapsAssetsMigrationStage.ReadAssetsControllerWithoutFallback, + ); + + const assets: AssetEntity[] = [ + { + assetType: KnownCaip19Id.TrxMainnet, + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + symbol: 'TRX', + decimals: 6, + rawAmount: '1000000', + uiAmount: '1', + iconUrl: '', + }, + ]; + + await assetsService.saveMany(assets); + + expect(mockAssetsRepository.saveMany).toHaveBeenCalledWith(assets); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('assets migration', () => { const accountId = mockAccount.id; const fungibleAssetId = KnownCaip19Id.TrxMainnet; @@ -2032,7 +2137,7 @@ describe('AssetsService', () => { iconUrl: '', }; - await assetsService.saveMany([specialAsset, fungibleAsset]); + await assetsService.saveManyAndEmit([specialAsset, fungibleAsset]); expect(mockAssetsRepository.saveMany).not.toHaveBeenCalled(); expect(emitSnapKeyringEvent).toHaveBeenCalledWith( diff --git a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts index 0ca5cc3a1..4607b2c52 100644 --- a/packages/tron-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/tron-wallet-snap/src/services/assets/AssetsService.ts @@ -38,7 +38,7 @@ export class AssetsService { this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; } - async #shouldReturnAssetsFromCore(): Promise { + async isAssetsMigrationEnabled(): Promise { const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( SNAPS_ASSETS_MIGRATION_FLAG_KEYS.tron, ); @@ -56,7 +56,7 @@ export class AssetsService { return []; } - if (await this.#shouldReturnAssetsFromCore()) { + if (await this.isAssetsMigrationEnabled()) { const assets = await this.#coreAdapter.getAccountAssetsByIDs( accountId, assetIds as Caip19AssetId[], @@ -71,7 +71,7 @@ export class AssetsService { accountId: string, assetId: string, ): Promise { - if (await this.#shouldReturnAssetsFromCore()) { + if (await this.isAssetsMigrationEnabled()) { const asset = await this.#coreAdapter.getAccountAssetByID( accountId, assetId as Caip19AssetId, @@ -83,7 +83,7 @@ export class AssetsService { } async getAccountAssets(accountId: string): Promise { - if (await this.#shouldReturnAssetsFromCore()) { + if (await this.isAssetsMigrationEnabled()) { return this.#coreAdapter.getAccountAssets(accountId); } @@ -100,7 +100,7 @@ export class AssetsService { * @returns The live assets. */ async fetchAccountAssets(account: KeyringAccount): Promise { - if (await this.#shouldReturnAssetsFromCore()) { + if (await this.isAssetsMigrationEnabled()) { return this.#coreAdapter.fetchAccountAssets(account); } @@ -122,11 +122,32 @@ export class AssetsService { return this.#snapAdapter.fetchAccountAssets(account); } - async saveMany(assets: AssetEntity[]): Promise { - if (await this.#shouldReturnAssetsFromCore()) { - return this.#coreAdapter.saveMany(assets); + /** + * Persists the latest asset snapshot to the snap's local state and emits + * the corresponding keyring events. Migration-aware: once the migration is + * active, Core owns publishing updates and local persistence is skipped. + * + * @param assets - The latest asset snapshot to persist. + * @returns Nothing. + */ + async saveManyAndEmit(assets: AssetEntity[]): Promise { + if (await this.isAssetsMigrationEnabled()) { + return this.#coreAdapter.saveManyAndEmit(assets); } + return this.#snapAdapter.saveManyAndEmit(assets); + } + + /** + * Persists the latest asset snapshot to the snap's local state without + * emitting keyring events. Always writes through the Snap adapter because + * local state is snap-owned regardless of the migration stage; Core owns + * publishing updates instead. + * + * @param assets - The latest asset snapshot to persist. + * @returns Nothing. + */ + async saveMany(assets: AssetEntity[]): Promise { return this.#snapAdapter.saveMany(assets); } } diff --git a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.test.ts index 0a81944df..2f5a9649d 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 @@ -79,7 +79,7 @@ function createControllerAsset(options: { } /** - * Builds a snap-owned asset entity for `saveMany` tests. + * Builds a snap-owned asset entity for `saveManyAndEmit` tests. * * @param overrides - Fields to override on the asset entity. * @returns An `AssetEntity`. @@ -373,10 +373,10 @@ describe('CoreAssetsAdapter', () => { }); }); - describe('saveMany', () => { + describe('saveManyAndEmit', () => { it('does nothing when there are no snap-owned assets', async () => { await withCoreAssetsAdapter(async ({ adapter }) => { - await adapter.saveMany([ + await adapter.saveManyAndEmit([ createAssetEntity({ assetType: KnownCaip19Id.TrxMainnet, symbol: 'TRX', @@ -392,7 +392,7 @@ describe('CoreAssetsAdapter', () => { it('publishes only snap-owned assets as added with balance updates', async () => { await withCoreAssetsAdapter(async ({ adapter }) => { - await adapter.saveMany([ + await adapter.saveManyAndEmit([ createAssetEntity({ assetType: KnownCaip19Id.TrxMainnet, symbol: 'TRX', 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 4970f91e7..00ceae13d 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -153,7 +153,7 @@ export class CoreAssetsAdapter { * * @param assets - Assets to publish (non snap-owned entries are ignored). */ - async saveMany(assets: AssetEntity[]): Promise { + async saveManyAndEmit(assets: AssetEntity[]): Promise { this.#logger.info('Publishing snap-owned assets', assets); const snapOwnedAssets = assets.filter((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 a13001473..3bc089493 100644 --- a/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/tron-wallet-snap/src/services/assets/adapters/SnapAssetsAdapter.ts @@ -957,9 +957,70 @@ export class SnapAssetsAdapter { * * @param assets - The latest asset snapshot returned by the refresh flow. */ - async saveMany(assets: AssetEntity[]): Promise { + async saveManyAndEmit(assets: AssetEntity[]): Promise { this.#logger.info('Saving assets', assets); + const { assetsToSave, assetListUpdatedPayload, balancesUpdatedPayload } = + await this.#mergeSnapshot(assets); + + // If no assets were added or removed, don't emit the event. + const isEmptyAccountAssetListUpdatedPayload = Object.values( + assetListUpdatedPayload, + ) + .map((item) => item.added.length + item.removed.length) + .every((item) => item === 0); + + if (!isEmptyAccountAssetListUpdatedPayload) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { + assets: assetListUpdatedPayload, + }); + } + + // Save assets using repository + await this.#assetsRepository.saveMany(assetsToSave); + + // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. + const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) + .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes + .some((count) => count > 0); + + // Only emit the event if some balance was changed. + if (isSomeBalanceChanged) { + await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: balancesUpdatedPayload, + }); + } + } + + /** + * Persist the latest fetched assets to local state without emitting any + * keyring events. Like `saveManyAndEmit`, the input is treated as the latest + * snapshot for the account/network pairs included in this sync, and + * disappeared assets are persisted with zero balances. + * + * @param assets - The latest asset snapshot returned by the refresh flow. + */ + async saveMany(assets: AssetEntity[]): Promise { + this.#logger.info('Saving assets without emitting keyring events', assets); + + const { assetsToSave } = await this.#mergeSnapshot(assets); + + await this.#assetsRepository.saveMany(assetsToSave); + } + + /** + * Compares the latest asset snapshot with the previously saved state and + * computes the entities to persist plus the payloads for the asset-list and + * balance keyring events. + * + * @param assets - The latest asset snapshot returned by the refresh flow. + * @returns The entities to persist and the event payloads. + */ + async #mergeSnapshot(assets: AssetEntity[]): Promise<{ + assetsToSave: AssetEntity[]; + assetListUpdatedPayload: AccountAssetListUpdatedEvent['params']['assets']; + balancesUpdatedPayload: AccountBalancesUpdatedEvent['params']['balances']; + }> { const hasZeroAmount = (asset: AssetEntity): boolean => asset.rawAmount === '0' || asset.uiAmount === '0'; @@ -1050,19 +1111,6 @@ export class SnapAssetsAdapter { }; } - // If no assets were added or removed, don't emit the event. - const isEmptyAccountAssetListUpdatedPayload = Object.values( - assetListUpdatedPayload, - ) - .map((item) => item.added.length + item.removed.length) - .every((item) => item === 0); - - if (!isEmptyAccountAssetListUpdatedPayload) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountAssetListUpdated, { - assets: assetListUpdatedPayload, - }); - } - // Emit synthetic zero-balance entries for disappeared assets so clients can // clear cached balances even when the backend omits zero-balance tokens // instead of returning them explicitly. @@ -1073,8 +1121,6 @@ export class SnapAssetsAdapter { })); const assetsToSave = [...assets, ...removedAssetsWithZeroBalance]; - // Save assets using repository - await this.#assetsRepository.saveMany(assetsToSave); // Broadcast the current snapshot plus synthetic zero-balance removals so the // client can reconcile both visible assets and cached balances in one pass. @@ -1094,17 +1140,7 @@ export class SnapAssetsAdapter { {}, ); - // Traverse the balancesUpdatedPayload object to check if we have at least 1 account that has at least 1 balance updated. - const isSomeBalanceChanged = Object.values(balancesUpdatedPayload) - .map((accountAssets) => Object.keys(accountAssets).length) // To each accountAssets object, map the number of assetTypes - .some((count) => count > 0); - - // Only emit the event if some balance was changed. - if (isSomeBalanceChanged) { - await emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: balancesUpdatedPayload, - }); - } + return { assetsToSave, assetListUpdatedPayload, balancesUpdatedPayload }; } async getAll(): Promise {