From 3fcfc68551dde2e739726525eddd3fd588171bc8 Mon Sep 17 00:00:00 2001 From: gabrieledm Date: Tue, 1 Sep 2026 22:10:44 +0200 Subject: [PATCH 1/3] fix: force RPC slow pipeline to update balances --- .../src/AssetsController.test.ts | 147 +++++++++++++++++- .../assets-controller/src/AssetsController.ts | 64 +++++++- 2 files changed, 203 insertions(+), 8 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 59e80e44428..4a9bfb67f60 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -21,6 +21,8 @@ import type { import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource.js'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js'; import { PriceDataSource } from './data-sources/PriceDataSource.js'; +import { RpcDataSource } from './data-sources/RpcDataSource.js'; +import { SnapDataSource } from './data-sources/SnapDataSource.js'; import { TokenDataSource } from './data-sources/TokenDataSource.js'; import { buildDefaultAssetsInfo } from './defaults.js'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js'; @@ -1498,6 +1500,117 @@ describe('AssetsController', () => { ); }); + it('uses RPC as the authoritative slow pipeline after a transaction', async () => { + const arcNativeAssetId = 'eip155:5042/slip44:5042' as Caip19AssetId; + const arcEurcAssetId = + 'eip155:5042/erc20:0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as Caip19AssetId; + const arcAccount = createMockInternalAccount({ + scopes: ['eip155:5042'], + }); + const initialState: Partial = { + assetsInfo: { + [arcNativeAssetId]: { + type: 'native', + symbol: 'USDC', + name: 'USDC', + decimals: 18, + }, + [arcEurcAssetId]: { + type: 'erc20', + symbol: 'EURC', + name: 'EURC', + decimals: 6, + }, + }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [arcNativeAssetId]: { amount: '1' }, + [arcEurcAssetId]: { amount: '5' }, + }, + }, + }; + + const snapActiveChainsSpy = jest + .spyOn(SnapDataSource.prototype, 'getActiveChainsSync') + .mockReturnValue(['eip155:5042']); + const rpcActiveChainsSpy = jest + .spyOn(RpcDataSource.prototype, 'getActiveChainsSync') + .mockReturnValue(['eip155:5042']); + const snapMiddleware = jest.fn(async (ctx, next) => + next({ + ...ctx, + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [arcNativeAssetId]: { amount: '1' }, + [arcEurcAssetId]: { amount: '5' }, + }, + }, + }, + }), + ); + const rpcMiddleware = jest.fn(async (ctx, next) => + next({ + ...ctx, + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [arcNativeAssetId]: { amount: '2' }, + [arcEurcAssetId]: { amount: '0' }, + }, + }, + }, + }), + ); + const snapMiddlewareSpy = jest + .spyOn(SnapDataSource.prototype, 'assetsMiddleware', 'get') + .mockReturnValue(snapMiddleware); + const rpcMiddlewareSpy = jest + .spyOn(RpcDataSource.prototype, 'assetsMiddleware', 'get') + .mockReturnValue(rpcMiddleware); + + try { + await withController( + { state: initialState }, + async ({ controller }) => { + await controller.getAssets([arcAccount], { + chainIds: ['eip155:5042'], + forceUpdate: true, + postTransaction: true, + }); + await flushPromises(); + + expect(snapMiddleware).not.toHaveBeenCalled(); + expect(rpcMiddleware).toHaveBeenCalled(); + expect(rpcMiddleware).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + chainIds: ['eip155:5042'], + customAssets: expect.arrayContaining([arcEurcAssetId]), + }), + }), + expect.any(Function), + ); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + arcNativeAssetId + ], + ).toStrictEqual({ amount: '2' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + arcEurcAssetId + ], + ).toStrictEqual({ amount: '0' }); + }, + ); + } finally { + snapActiveChainsSpy.mockRestore(); + rpcActiveChainsSpy.mockRestore(); + snapMiddlewareSpy.mockRestore(); + rpcMiddlewareSpy.mockRestore(); + } + }); + it('getAssets resolves without error when isBasicFunctionality is false', async () => { await withController( { isBasicFunctionality: () => false }, @@ -2846,6 +2959,7 @@ describe('AssetsController', () => { { chainIds: ['eip155:42161'], forceUpdate: true, + postTransaction: true, }, ); @@ -2878,7 +2992,7 @@ describe('AssetsController', () => { }); }); - it('does not force refresh assets on transaction events for AccountActivity-active chains', async () => { + it('does not force refresh assets for unapproved transactions on AccountActivity-active chains', async () => { await withController(async ({ controller, messenger }) => { const getAssetsSpy = jest .spyOn(controller, 'getAssets') @@ -2895,6 +3009,28 @@ describe('AssetsController', () => { chainId: '0xa4b1', txParams: { from: '0x1234567890123456789012345678901234567890' }, }); + + await flushPromises(); + + expect(getAssetsSpy).not.toHaveBeenCalled(); + + getAssetsSpy.mockRestore(); + }); + }); + + it('force refreshes confirmed transactions on AccountActivity-active chains', async () => { + await withController(async ({ controller, messenger }) => { + const getAssetsSpy = jest + .spyOn(controller, 'getAssets') + .mockResolvedValue({}); + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:42161'], + status: 'up', + }); + + await flushPromises(); + messenger.publish('TransactionController:transactionConfirmed', { chainId: '0xa4b1', txParams: { from: '0x1234567890123456789012345678901234567890' }, @@ -2902,7 +3038,14 @@ describe('AssetsController', () => { await flushPromises(); - expect(getAssetsSpy).not.toHaveBeenCalled(); + expect(getAssetsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: MOCK_ACCOUNT_ID })], + { + chainIds: ['eip155:42161'], + forceUpdate: true, + postTransaction: true, + }, + ); getAssetsSpy.mockRestore(); }); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 8754c27d3d0..87810b0c272 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -114,6 +114,7 @@ import { createParallelBalanceMiddleware, createParallelMiddleware, } from './middlewares/ParallelMiddleware.js'; +import type { BalanceSource } from './middlewares/ParallelMiddleware.js'; import { RpcFallbackMiddleware } from './middlewares/RpcFallbackMiddleware.js'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js'; import { @@ -1213,7 +1214,9 @@ export class AssetsController extends BaseController< this.messenger.subscribe( 'TransactionController:transactionConfirmed', (transactionMeta: TransactionMeta) => { - this.#refreshAssetsForTransaction(transactionMeta); + this.#refreshAssetsForTransaction(transactionMeta, { + postTransaction: true, + }); }, ); // Start tracking only after the account tree is fully built. Unlock can @@ -1234,8 +1237,15 @@ export class AssetsController extends BaseController< * chain is already covered by AccountActivity (real-time WebSocket balances). * * @param transactionMeta - The transaction that triggered the refresh. + * @param options - Additional options for the refresh. + * @param options.postTransaction - When true, forces the RPC slow pipeline for + * all chains so that potentially stale Accounts API cache data is overridden + * with authoritative on-chain values immediately after the transaction mines. */ - #refreshAssetsForTransaction(transactionMeta: TransactionMeta): void { + #refreshAssetsForTransaction( + transactionMeta: TransactionMeta, + options?: { postTransaction?: boolean }, + ): void { const hexChainId = transactionMeta.chainId; if (!hexChainId) { return; @@ -1244,8 +1254,11 @@ export class AssetsController extends BaseController< const caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; // AccountActivity pushes live balance updates for its active chains; a - // force getAssets would be redundant and can race the WebSocket path. + // pre-confirmation force getAssets would be redundant and can race the + // WebSocket path. After confirmation, still run the post-transaction RPC + // pass because AccountActivity may update only part of a swap pair. if ( + !options?.postTransaction && this.#accountActivityDataSource .getActiveChainsSync() .includes(caipChainId) @@ -1268,6 +1281,7 @@ export class AssetsController extends BaseController< this.getAssets([matchedAccount], { chainIds: [caipChainId], forceUpdate: true, + postTransaction: options?.postTransaction, }).catch((error) => { log('Failed to refresh assets after transaction event', { error }); }); @@ -1671,6 +1685,12 @@ export class AssetsController extends BaseController< assetsForPriceUpdate?: Caip19AssetId[]; /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ updateMode?: AssetsUpdateMode; + /** + * When true, forces the RPC slow pipeline for all chains regardless of + * Accounts API coverage. Use after a transaction confirms to override + * potentially stale API cache with authoritative on-chain values. + */ + postTransaction?: boolean; }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; @@ -1683,9 +1703,26 @@ export class AssetsController extends BaseController< // Collect custom assets for all requested accounts const customAssets: Caip19AssetId[] = []; + const chainIdSet = new Set(chainIds); for (const account of accounts) { const accountCustomAssets = this.getCustomAssets(account.id); customAssets.push(...accountCustomAssets); + + // When refreshing after a confirmed transaction, also include graduated + // tokens — ERC-20s currently in assetsBalance but removed from + // customAssets by CustomAssetGraduationMiddleware. The Accounts API + // indexes incoming transfers (received tokens) slower than outgoing + // ones, so without a direct RPC query the "to" token in a swap keeps + // the stale API-cached balance until the next polling cycle. + if (options?.postTransaction) { + const stateBalances = this.state.assetsBalance[account.id] ?? {}; + for (const assetId of Object.keys(stateBalances) as Caip19AssetId[]) { + const assetChainId = assetId.split('/')[0] as ChainId; + if (chainIdSet.has(assetChainId) && !customAssets.includes(assetId)) { + customAssets.push(assetId); + } + } + } } if (options?.forceUpdate) { @@ -1800,12 +1837,16 @@ export class AssetsController extends BaseController< const slowPipelineChainIds = this.#getSlowPipelineChainIds( chainIds, response, + { forceAll: options?.postTransaction === true }, ); if (slowPipelineChainIds.length > 0) { - const slowSources = this.#isBasicFunctionality() - ? [this.#snapDataSource, this.#rpcDataSource] - : [this.#rpcDataSource]; + let slowSources: BalanceSource[]; + if (options?.postTransaction || !this.#isBasicFunctionality()) { + slowSources = [this.#rpcDataSource]; + } else { + slowSources = [this.#snapDataSource, this.#rpcDataSource]; + } const slowRequest = { ...request, chainIds: slowPipelineChainIds }; @@ -2589,12 +2630,23 @@ export class AssetsController extends BaseController< * * @param chainIds - Chains requested by the caller. * @param fastResponse - Response committed by the fast pipeline. + * @param options - Additional options. + * @param options.forceAll - When true, bypasses the Accounts API coverage + * check and returns all chain IDs so the RPC slow pipeline runs for every + * chain. Use after a transaction confirms to override potentially stale API + * cache data with authoritative on-chain values. * @returns Chain IDs that still need the slow pipeline. */ #getSlowPipelineChainIds( chainIds: ChainId[], fastResponse: DataResponse, + options?: { forceAll?: boolean }, ): ChainId[] { + // Post-transaction: always run RPC to override potentially stale Accounts API data. + if (options?.forceAll) { + return chainIds; + } + const accountsApiChains = new Set( this.#accountsApiDataSource.getActiveChainsSync(), ); From ad7c150fe17dd3550ed7a7b42496a62b0634be14 Mon Sep 17 00:00:00 2001 From: gabrieledm Date: Tue, 1 Sep 2026 22:16:17 +0200 Subject: [PATCH 2/3] doc: changelog --- packages/assets-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 45acf6cd067..534fd046b0f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^69.6.1` to `^69.7.0` ([#10046](https://github.com/MetaMask/core/pull/10046)) +### Fixed + +- Fix stale ARC USDC/EURC balances after confirmed swaps by running an authoritative RPC balance refresh for post-transaction asset updates ([#10058](https://github.com/MetaMask/core/pull/10058)) + ## [14.0.3] ### Changed From 64bce4b11ea9494ff4a6c840e3dd667ed57069bd Mon Sep 17 00:00:00 2001 From: gabrieledm Date: Tue, 1 Sep 2026 22:26:45 +0200 Subject: [PATCH 3/3] fix: added check for staking contracts --- packages/assets-controller/src/AssetsController.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 87810b0c272..ba1035426b6 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1718,7 +1718,11 @@ export class AssetsController extends BaseController< const stateBalances = this.state.assetsBalance[account.id] ?? {}; for (const assetId of Object.keys(stateBalances) as Caip19AssetId[]) { const assetChainId = assetId.split('/')[0] as ChainId; - if (chainIdSet.has(assetChainId) && !customAssets.includes(assetId)) { + if ( + chainIdSet.has(assetChainId) && + !isStakingContractAssetId(assetId) && + !customAssets.includes(assetId) + ) { customAssets.push(assetId); } }