Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 145 additions & 2 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AssetsControllerState> = {
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 },
Expand Down Expand Up @@ -2846,6 +2959,7 @@ describe('AssetsController', () => {
{
chainIds: ['eip155:42161'],
forceUpdate: true,
postTransaction: true,
},
);

Expand Down Expand Up @@ -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')
Expand All @@ -2895,14 +3009,43 @@ 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' },
});

await flushPromises();

expect(getAssetsSpy).not.toHaveBeenCalled();
expect(getAssetsSpy).toHaveBeenCalledWith(
[expect.objectContaining({ id: MOCK_ACCOUNT_ID })],
{
chainIds: ['eip155:42161'],
forceUpdate: true,
postTransaction: true,
},
);

getAssetsSpy.mockRestore();
});
Expand Down
68 changes: 62 additions & 6 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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 });
});
Expand Down Expand Up @@ -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<Record<AccountId, Record<Caip19AssetId, Asset>>> {
const chainIds = options?.chainIds ?? [...this.#enabledChains];
Expand All @@ -1683,9 +1703,30 @@ 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) &&
!isStakingContractAssetId(assetId) &&
!customAssets.includes(assetId)
) {
customAssets.push(assetId);
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (options?.forceUpdate) {
Expand Down Expand Up @@ -1800,12 +1841,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 };

Expand Down Expand Up @@ -2589,12 +2634,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(),
);
Expand Down