From bfdba455ac1bec75f6bb28de3ada9218f0c608a5 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 09:27:07 +0100 Subject: [PATCH 01/14] fix: re-parse required tokens when token rates load after transaction added --- eslint-suppressions.json | 2 +- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/TransactionPayController.test.ts | 22 +- .../src/TransactionPayController.ts | 12 +- .../transaction-pay-controller/src/types.ts | 10 +- .../src/utils/transaction.test.ts | 190 +++++++++++++++++- .../src/utils/transaction.ts | 52 +++++ 7 files changed, 287 insertions(+), 5 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1d41465cfa..59e8d93ac6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2239,7 +2239,7 @@ }, "packages/transaction-pay-controller/src/utils/transaction.ts": { "no-restricted-syntax": { - "count": 2 + "count": 5 } }, "packages/user-operation-controller/src/UserOperationController.test.ts": { diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c9bea4df38..28369c9783 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) - Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) +### Fixed + +- Re-run `parseRequiredTokens` for in-flight transactions when token rates or currency rates become available, recovering required-token state when fiat rate fetches resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. + ## [21.0.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 1e18803732..55e04aa637 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -17,7 +17,11 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { getTransaction, pollTransactionChanges } from './utils/transaction'; +import { + getTransaction, + pollRateChanges, + pollTransactionChanges, +} from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); jest.mock('./actions/update-payment-token'); @@ -42,6 +46,7 @@ describe('TransactionPayController', () => { const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); + const pollRateChangesMock = jest.mocked(pollRateChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -80,6 +85,21 @@ describe('TransactionPayController', () => { updateQuotesMock.mockResolvedValue(true); }); + describe('constructor', () => { + it('subscribes to rate changes for in-flight retry', () => { + const controller = createController(); + + expect(pollRateChangesMock).toHaveBeenCalledWith( + messenger, + expect.any(Function), + expect.any(Function), + ); + + const getControllerState = pollRateChangesMock.mock.calls[0][1]; + expect(getControllerState()).toBe(controller.state); + }); + }); + describe('updatePaymentToken', () => { it('calls util', () => { createController().updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 61cb6b4790..18835fbf98 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -26,7 +26,11 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { getTransaction, pollTransactionChanges } from './utils/transaction'; +import { + getTransaction, + pollRateChanges, + pollTransactionChanges, +} from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', @@ -93,6 +97,12 @@ export class TransactionPayController extends BaseController< this.#removeTransactionData.bind(this), ); + pollRateChanges( + messenger, + () => this.state, + this.#updateTransactionData.bind(this), + ); + // eslint-disable-next-line no-new new QuoteRefresher({ getStrategies: this.#getStrategiesWithFallback.bind(this), diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 7c5064498e..4e05db1987 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -1,9 +1,14 @@ -import type { AssetsControllerGetStateForTransactionPayAction } from '@metamask/assets-controller'; +import type { + AssetsControllerGetStateForTransactionPayAction, + AssetsControllerStateChangeEvent, +} from '@metamask/assets-controller'; import type { CurrencyRateControllerGetStateAction, + CurrencyRateStateChange, TokenBalancesControllerGetStateAction, } from '@metamask/assets-controllers'; import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; +import type { TokenRatesControllerStateChangeEvent } from '@metamask/assets-controllers'; import type { TokensControllerGetStateAction } from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; import type { ControllerStateChangeEvent } from '@metamask/base-controller'; @@ -82,7 +87,10 @@ export type AllowedActions = | TransactionControllerUpdateTransactionAction; export type AllowedEvents = + | AssetsControllerStateChangeEvent | BridgeStatusControllerStateChangeEvent + | CurrencyRateStateChange + | TokenRatesControllerStateChangeEvent | TransactionControllerStateChangeEvent | TransactionControllerUnapprovedTransactionAddedEvent; diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index c24fbc5c62..e06d07f3bb 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -8,18 +8,25 @@ import type { Hex } from '@metamask/utils'; import { noop } from 'lodash'; import { getMessengerMock } from '../tests/messenger-mock'; -import type { TransactionData, TransactionPayRequiredToken } from '../types'; +import type { + TransactionData, + TransactionPayControllerState, + TransactionPayRequiredToken, +} from '../types'; +import { getAssetsUnifyStateFeature } from './feature-flags'; import { parseRequiredTokens } from './required-tokens'; import { FINALIZED_STATUSES, collectTransactionIds, getTransaction, isPredictWithdrawTransaction, + pollRateChanges, pollTransactionChanges, updateTransaction, waitForTransactionConfirmed, } from './transaction'; +jest.mock('./feature-flags'); jest.mock('./required-tokens'); const TRANSACTION_ID_MOCK = '123-456'; @@ -44,6 +51,7 @@ const TRANSCTION_TOKEN_REQUIRED_MOCK = { describe('Transaction Utils', () => { const parseRequiredTokensMock = jest.mocked(parseRequiredTokens); + const getAssetsUnifyStateFeatureMock = jest.mocked(getAssetsUnifyStateFeature); const { messenger, getTransactionControllerStateMock, @@ -57,6 +65,8 @@ describe('Transaction Utils', () => { getTransactionControllerStateMock.mockReturnValue({ transactions: [] as TransactionMeta[], } as TransactionControllerState); + + getAssetsUnifyStateFeatureMock.mockReturnValue(false); }); describe('getTransaction', () => { @@ -189,6 +199,184 @@ describe('Transaction Utils', () => { }); }); + describe('pollRateChanges', () => { + function buildState( + data: Partial & { + tokens: TransactionPayRequiredToken[]; + } = { tokens: [] }, + ): TransactionPayControllerState { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + isLoading: false, + ...data, + } as TransactionData, + }, + }; + } + + let isolatedMessenger: ReturnType['messenger']; + let isolatedPublish: ReturnType['publish']; + let isolatedGetTransactionControllerStateMock: ReturnType< + typeof getMessengerMock + >['getTransactionControllerStateMock']; + + beforeEach(() => { + const fresh = getMessengerMock(); + isolatedMessenger = fresh.messenger; + isolatedPublish = fresh.publish; + isolatedGetTransactionControllerStateMock = + fresh.getTransactionControllerStateMock; + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [] as TransactionMeta[], + } as TransactionControllerState); + }); + + it('re-parses required tokens for transactions with empty tokens when token rates change', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + const transactionData = {} as TransactionData; + updateTransactionDataMock.mock.calls[0][1](transactionData); + expect(transactionData.tokens).toStrictEqual([ + TRANSCTION_TOKEN_REQUIRED_MOCK, + ]); + }); + + it('re-parses required tokens when currency rates change', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('CurrencyRateController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + }); + + it('subscribes to AssetsController state changes when unify-state feature is enabled', () => { + const updateTransactionDataMock = jest.fn(); + + getAssetsUnifyStateFeatureMock.mockReturnValue(true); + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('AssetsController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe to TokenRatesController/CurrencyRateController when unify-state feature is enabled', () => { + const updateTransactionDataMock = jest.fn(); + + getAssetsUnifyStateFeatureMock.mockReturnValue(true); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish('CurrencyRateController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + }); + + it('skips transactions whose tokens are already populated', () => { + const updateTransactionDataMock = jest.fn(); + + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => + buildState({ + tokens: [TRANSCTION_TOKEN_REQUIRED_MOCK], + }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + expect(parseRequiredTokensMock).not.toHaveBeenCalled(); + }); + + it.each(FINALIZED_STATUSES)( + 'skips transactions whose status is %s', + (status) => { + const updateTransactionDataMock = jest.fn(); + + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [{ ...TRANSACTION_META_MOCK, status }], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + }, + ); + + it('skips transactions that no longer exist in TransactionController state', () => { + const updateTransactionDataMock = jest.fn(); + + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [] as TransactionMeta[], + } as TransactionControllerState); + + pollRateChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + }); + }); + describe('updateTransaction', () => { it('updates transaction', () => { getTransactionControllerStateMock.mockReturnValue({ diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index ee75e3a182..c549989eca 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -10,8 +10,10 @@ import { cloneDeep } from 'lodash'; import { projectLogger } from '../logger'; import type { TransactionPayControllerMessenger, + TransactionPayControllerState, UpdateTransactionDataCallback, } from '../types'; +import { getAssetsUnifyStateFeature } from './feature-flags'; import { parseRequiredTokens } from './required-tokens'; const log = createModuleLogger(projectLogger, 'transaction'); @@ -103,6 +105,56 @@ export function pollTransactionChanges( ); } +/** + * Subscribe to rate-source state changes and re-run {@link parseRequiredTokens} + * for in-flight transactions whose required tokens have not yet been resolved. + * + * `parseRequiredTokens` returns an empty array when token fiat rates are + * unavailable. Without this subscription, those transactions stay deadlocked: + * the existing `TransactionController:stateChange` subscription only re-parses + * when `txParams.data` changes, but the client typically gates `data` edits on + * having a resolved required token. This handler closes the loop by re-parsing + * when the underlying rate state lands, mirroring the same source selection + * `getTokenFiatRate` uses. + * + * @param messenger - Controller messenger. + * @param getControllerState - Callback returning the current pay-controller + * state, used to find transactions with empty `tokens` to retry. + * @param updateTransactionData - Callback to update transaction data. + */ +export function pollRateChanges( + messenger: TransactionPayControllerMessenger, + getControllerState: () => TransactionPayControllerState, + updateTransactionData: UpdateTransactionDataCallback, +): void { + const handler = (): void => { + const { transactionData } = getControllerState(); + + for (const [transactionId, data] of Object.entries(transactionData)) { + if (data.tokens.length > 0) { + continue; + } + + const transaction = getTransaction(transactionId, messenger); + + if (!transaction || FINALIZED_STATUSES.includes(transaction.status)) { + continue; + } + + log('Rate state changed, re-parsing required tokens', { transactionId }); + onTransactionChange(transaction, messenger, updateTransactionData); + } + }; + + if (getAssetsUnifyStateFeature(messenger)) { + messenger.subscribe('AssetsController:stateChange', handler); + return; + } + + messenger.subscribe('TokenRatesController:stateChange', handler); + messenger.subscribe('CurrencyRateController:stateChange', handler); +} + /** * Wait for a transaction to be confirmed or fail. * From 47ed87e2e642df29ddcddb08ddd0c40a8ae64303 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 10:00:34 +0100 Subject: [PATCH 02/14] chore: surface rate-change patches and required-token bail reasons in logs --- .../src/utils/required-tokens.ts | 27 +++- .../src/utils/transaction.ts | 124 +++++++++++++----- 2 files changed, 117 insertions(+), 34 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts index 93367c647b..6aafd7b816 100644 --- a/packages/transaction-pay-controller/src/utils/required-tokens.ts +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -3,7 +3,9 @@ import { toHex } from '@metamask/controller-utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { projectLogger } from '../logger'; import type { TransactionPayControllerMessenger, TransactionPayRequiredToken, @@ -15,6 +17,8 @@ import { getTokenInfo, } from './token'; +const log = createModuleLogger(projectLogger, 'required-tokens'); + const FOUR_BYTE_TOKEN_TRANSFER = '0xa9059cbb'; /** @@ -61,19 +65,28 @@ function getTokenTransferToken( const { data, to } = getTokenTransferData(transaction) ?? {}; if (!to || !data) { + log('No token transfer detected in transaction', { + transactionId: transaction.id, + }); return undefined; } let transferAmount: Hex | undefined; + let decodeError: unknown; try { const result = new Interface(abiERC20).decodeFunctionData('transfer', data); transferAmount = toHex(result._value); - } catch { - // Intentionally empty + } catch (error) { + decodeError = error; } if (transferAmount === undefined) { + log('Failed to decode transfer calldata', { + transactionId: transaction.id, + to, + error: decodeError, + }); return undefined; } @@ -105,6 +118,16 @@ function buildRequiredToken( const tokenBalance = getTokenBalance(messenger, from, chainId, tokenAddress); if (tokenDecimals === undefined || !symbol || fiatRates === undefined) { + log('Cannot build required token: missing data', { + transactionId: transaction.id, + chainId, + tokenAddress, + missing: { + decimals: tokenDecimals === undefined, + symbol: !symbol, + fiatRates: fiatRates === undefined, + }, + }); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index c549989eca..873557479b 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -5,6 +5,7 @@ import { import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; import { projectLogger } from '../logger'; @@ -66,16 +67,29 @@ export function pollTransactionChanges( (tx) => !previousTransactions?.find((prevTx) => prevTx.id === tx.id), ); - const updatedTransactions = transactions.filter((tx) => { - const previousTransaction = previousTransactions?.find( - (prevTx) => prevTx.id === tx.id, - ); - - return ( - previousTransaction && - previousTransaction?.txParams.data !== tx.txParams.data + const updatedTransactions = transactions + .map((tx) => { + const previousTransaction = previousTransactions?.find( + (prevTx) => prevTx.id === tx.id, + ); + + if ( + !previousTransaction || + previousTransaction.txParams.data === tx.txParams.data + ) { + return undefined; + } + + return { tx, previousTransaction }; + }) + .filter( + ( + entry, + ): entry is { + tx: TransactionMeta; + previousTransaction: TransactionMeta; + } => entry !== undefined, ); - }); const finalizedTransactions = transactions.filter((tx) => { const previousTransaction = previousTransactions?.find( @@ -97,8 +111,17 @@ export function pollTransactionChanges( onTransactionFinalized(tx, removeTransactionData), ); - [...newTransactions, ...updatedTransactions].forEach((tx) => - onTransactionChange(tx, messenger, updateTransactionData), + newTransactions.forEach((tx) => + onTransactionChange(tx, undefined, messenger, updateTransactionData), + ); + + updatedTransactions.forEach(({ tx, previousTransaction }) => + onTransactionChange( + tx, + previousTransaction, + messenger, + updateTransactionData, + ), ); }, (state) => state.transactions, @@ -127,32 +150,53 @@ export function pollRateChanges( getControllerState: () => TransactionPayControllerState, updateTransactionData: UpdateTransactionDataCallback, ): void { - const handler = (): void => { - const { transactionData } = getControllerState(); - - for (const [transactionId, data] of Object.entries(transactionData)) { - if (data.tokens.length > 0) { - continue; - } - - const transaction = getTransaction(transactionId, messenger); - - if (!transaction || FINALIZED_STATUSES.includes(transaction.status)) { - continue; + const buildHandler = + (source: string) => + (_state: unknown, patches: Patch[] | undefined): void => { + const { transactionData } = getControllerState(); + + for (const [transactionId, data] of Object.entries(transactionData)) { + if (data.tokens.length > 0) { + continue; + } + + const transaction = getTransaction(transactionId, messenger); + + if (!transaction || FINALIZED_STATUSES.includes(transaction.status)) { + continue; + } + + log('Rate state changed, re-parsing required tokens', { + transactionId, + source, + patches, + }); + + onTransactionChange( + transaction, + undefined, + messenger, + updateTransactionData, + ); } - - log('Rate state changed, re-parsing required tokens', { transactionId }); - onTransactionChange(transaction, messenger, updateTransactionData); - } - }; + }; if (getAssetsUnifyStateFeature(messenger)) { - messenger.subscribe('AssetsController:stateChange', handler); + messenger.subscribe( + 'AssetsController:stateChange', + buildHandler('AssetsController'), + ); return; } - messenger.subscribe('TokenRatesController:stateChange', handler); - messenger.subscribe('CurrencyRateController:stateChange', handler); + messenger.subscribe( + 'TokenRatesController:stateChange', + buildHandler('TokenRatesController'), + ); + messenger.subscribe( + 'CurrencyRateController:stateChange', + buildHandler('CurrencyRateController'), + ); } /** @@ -318,17 +362,33 @@ export function isPredictWithdrawTransaction( * Handle a transaction change by updating its associated data. * * @param transaction - Transaction metadata. + * @param previousTransaction - Previous transaction metadata, when this is an + * update rather than a new transaction or a rate-driven recompute. Used to + * surface the calldata diff in logs. * @param messenger - Controller messenger. * @param updateTransactionData - Callback to update transaction data. */ function onTransactionChange( transaction: TransactionMeta, + previousTransaction: TransactionMeta | undefined, messenger: TransactionPayControllerMessenger, updateTransactionData: UpdateTransactionDataCallback, ): void { const tokens = parseRequiredTokens(transaction, messenger); - log('Transaction changed', { transaction, tokens }); + log('Transaction changed', { + transactionId: transaction.id, + chainId: transaction.chainId, + tokens, + ...(previousTransaction + ? { + dataChanged: { + from: previousTransaction.txParams.data, + to: transaction.txParams.data, + }, + } + : {}), + }); updateTransactionData(transaction.id, (data) => { data.tokens = tokens; From f10485483fc619b779798c960d6a20189585f1cb Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 11:40:57 +0100 Subject: [PATCH 03/14] fix: re-parse required tokens on token state changes too --- eslint-suppressions.json | 2 +- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/TransactionPayController.test.ts | 8 ++-- .../src/TransactionPayController.ts | 4 +- .../transaction-pay-controller/src/types.ts | 6 ++- .../src/utils/transaction.test.ts | 40 ++++++++++++++----- .../src/utils/transaction.ts | 26 +++++++----- 7 files changed, 59 insertions(+), 29 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 59e8d93ac6..0e0cf97028 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2239,7 +2239,7 @@ }, "packages/transaction-pay-controller/src/utils/transaction.ts": { "no-restricted-syntax": { - "count": 5 + "count": 6 } }, "packages/user-operation-controller/src/UserOperationController.test.ts": { diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 28369c9783..a6b731286b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Re-run `parseRequiredTokens` for in-flight transactions when token rates or currency rates become available, recovering required-token state when fiat rate fetches resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. +- Re-run `parseRequiredTokens` for in-flight transactions when token info, token rates, or currency rates become available, recovering required-token state when those state sources resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. ## [21.0.0] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 55e04aa637..8313a7c664 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -19,7 +19,7 @@ import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { getTransaction, - pollRateChanges, + subscribeTokenChanges, pollTransactionChanges, } from './utils/transaction'; @@ -46,7 +46,7 @@ describe('TransactionPayController', () => { const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); - const pollRateChangesMock = jest.mocked(pollRateChanges); + const subscribeTokenChangesMock = jest.mocked(subscribeTokenChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -89,13 +89,13 @@ describe('TransactionPayController', () => { it('subscribes to rate changes for in-flight retry', () => { const controller = createController(); - expect(pollRateChangesMock).toHaveBeenCalledWith( + expect(subscribeTokenChangesMock).toHaveBeenCalledWith( messenger, expect.any(Function), expect.any(Function), ); - const getControllerState = pollRateChangesMock.mock.calls[0][1]; + const getControllerState = subscribeTokenChangesMock.mock.calls[0][1]; expect(getControllerState()).toBe(controller.state); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 18835fbf98..cc372ba610 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -28,7 +28,7 @@ import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { getTransaction, - pollRateChanges, + subscribeTokenChanges, pollTransactionChanges, } from './utils/transaction'; @@ -97,7 +97,7 @@ export class TransactionPayController extends BaseController< this.#removeTransactionData.bind(this), ); - pollRateChanges( + subscribeTokenChanges( messenger, () => this.state, this.#updateTransactionData.bind(this), diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 4e05db1987..d3ecc63e47 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -9,7 +9,10 @@ import type { } from '@metamask/assets-controllers'; import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import type { TokenRatesControllerStateChangeEvent } from '@metamask/assets-controllers'; -import type { TokensControllerGetStateAction } from '@metamask/assets-controllers'; +import type { + TokensControllerGetStateAction, + TokensControllerStateChangeEvent, +} from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; import type { ControllerStateChangeEvent } from '@metamask/base-controller'; import type { ControllerGetStateAction } from '@metamask/base-controller'; @@ -91,6 +94,7 @@ export type AllowedEvents = | BridgeStatusControllerStateChangeEvent | CurrencyRateStateChange | TokenRatesControllerStateChangeEvent + | TokensControllerStateChangeEvent | TransactionControllerStateChangeEvent | TransactionControllerUnapprovedTransactionAddedEvent; diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index e06d07f3bb..067e7ed5e1 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -20,7 +20,7 @@ import { collectTransactionIds, getTransaction, isPredictWithdrawTransaction, - pollRateChanges, + subscribeTokenChanges, pollTransactionChanges, updateTransaction, waitForTransactionConfirmed, @@ -199,7 +199,7 @@ describe('Transaction Utils', () => { }); }); - describe('pollRateChanges', () => { + describe('subscribeTokenChanges', () => { function buildState( data: Partial & { tokens: TransactionPayRequiredToken[]; @@ -240,7 +240,7 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, @@ -264,7 +264,7 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, @@ -275,6 +275,25 @@ describe('Transaction Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); + it('re-parses required tokens when token state changes', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + subscribeTokenChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokensController:stateChange', {} as never, []); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + }); + it('subscribes to AssetsController state changes when unify-state feature is enabled', () => { const updateTransactionDataMock = jest.fn(); @@ -284,7 +303,7 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, @@ -295,7 +314,7 @@ describe('Transaction Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); - it('does not subscribe to TokenRatesController/CurrencyRateController when unify-state feature is enabled', () => { + it('does not subscribe to per-source events when unify-state feature is enabled', () => { const updateTransactionDataMock = jest.fn(); getAssetsUnifyStateFeatureMock.mockReturnValue(true); @@ -303,12 +322,13 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); + isolatedPublish('TokensController:stateChange', {} as never, []); isolatedPublish('TokenRatesController:stateChange', {} as never, []); isolatedPublish('CurrencyRateController:stateChange', {} as never, []); @@ -322,7 +342,7 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ @@ -346,7 +366,7 @@ describe('Transaction Utils', () => { transactions: [{ ...TRANSACTION_META_MOCK, status }], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, @@ -365,7 +385,7 @@ describe('Transaction Utils', () => { transactions: [] as TransactionMeta[], } as TransactionControllerState); - pollRateChanges( + subscribeTokenChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 873557479b..06d2d6aee7 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -129,23 +129,25 @@ export function pollTransactionChanges( } /** - * Subscribe to rate-source state changes and re-run {@link parseRequiredTokens} - * for in-flight transactions whose required tokens have not yet been resolved. + * Subscribe to token-data and rate-source state changes and re-run + * {@link parseRequiredTokens} for in-flight transactions whose required + * tokens have not yet been resolved. * - * `parseRequiredTokens` returns an empty array when token fiat rates are - * unavailable. Without this subscription, those transactions stay deadlocked: - * the existing `TransactionController:stateChange` subscription only re-parses - * when `txParams.data` changes, but the client typically gates `data` edits on + * `parseRequiredTokens` returns an empty array when any of token info, + * token fiat rates, or native currency rates are unavailable. Without this + * subscription, those transactions stay deadlocked: the existing + * `TransactionController:stateChange` subscription only re-parses when + * `txParams.data` changes, but the client typically gates `data` edits on * having a resolved required token. This handler closes the loop by re-parsing - * when the underlying rate state lands, mirroring the same source selection - * `getTokenFiatRate` uses. + * when any of the underlying state sources land, mirroring the same source + * selection `getTokenInfo` and `getTokenFiatRate` use. * * @param messenger - Controller messenger. * @param getControllerState - Callback returning the current pay-controller * state, used to find transactions with empty `tokens` to retry. * @param updateTransactionData - Callback to update transaction data. */ -export function pollRateChanges( +export function subscribeTokenChanges( messenger: TransactionPayControllerMessenger, getControllerState: () => TransactionPayControllerState, updateTransactionData: UpdateTransactionDataCallback, @@ -166,7 +168,7 @@ export function pollRateChanges( continue; } - log('Rate state changed, re-parsing required tokens', { + log('Token or rate state changed, re-parsing required tokens', { transactionId, source, patches, @@ -189,6 +191,10 @@ export function pollRateChanges( return; } + messenger.subscribe( + 'TokensController:stateChange', + buildHandler('TokensController'), + ); messenger.subscribe( 'TokenRatesController:stateChange', buildHandler('TokenRatesController'), From 71b1d94a33dcbcb8c5109dcefd9dfa3dda41a937 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 12:49:33 +0100 Subject: [PATCH 04/14] fix: resolve lint formatting and add changelog PR link --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- .../transaction-pay-controller/src/utils/transaction.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a6b731286b..6e678da67c 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Re-run `parseRequiredTokens` for in-flight transactions when token info, token rates, or currency rates become available, recovering required-token state when those state sources resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. +- Re-run `parseRequiredTokens` for in-flight transactions when token info, token rates, or currency rates become available, recovering required-token state when those state sources resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. ([#8714](https://github.com/MetaMask/core/pull/8714)) ## [21.0.0] diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 067e7ed5e1..2c769447f2 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -51,7 +51,9 @@ const TRANSCTION_TOKEN_REQUIRED_MOCK = { describe('Transaction Utils', () => { const parseRequiredTokensMock = jest.mocked(parseRequiredTokens); - const getAssetsUnifyStateFeatureMock = jest.mocked(getAssetsUnifyStateFeature); + const getAssetsUnifyStateFeatureMock = jest.mocked( + getAssetsUnifyStateFeature, + ); const { messenger, getTransactionControllerStateMock, From b393f0cfe0b32712bc7145b30ab395cd13ed20ca Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 21:52:35 +0100 Subject: [PATCH 05/14] refactor: simplify asset subscriptions and revert pollTransactionChanges changes - Rename pollTransactionChanges to subscribeTransactionChanges - Rename subscribeTokenChanges to subscribeAssetChanges - Add selectors to limit subscribe fires to relevant state slices - Revert structural changes to the transaction subscription - Shorten log messages and JSDoc --- eslint-suppressions.json | 2 +- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/TransactionPayController.test.ts | 14 +-- .../src/TransactionPayController.ts | 8 +- .../src/utils/required-tokens.ts | 4 +- .../src/utils/transaction.test.ts | 92 +++++++++++----- .../src/utils/transaction.ts | 100 ++++-------------- 7 files changed, 102 insertions(+), 120 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0e0cf97028..877359245e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2361,4 +2361,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6e678da67c..70d7ecf721 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Re-run `parseRequiredTokens` for in-flight transactions when token info, token rates, or currency rates become available, recovering required-token state when those state sources resolved after the transaction was first parsed. Previously the only retry trigger was a `txParams.data` change, which left consumers stuck when they gated `data` edits on having a resolved required token. ([#8714](https://github.com/MetaMask/core/pull/8714)) +- Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) ## [21.0.0] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 8313a7c664..7d2fd17916 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -19,8 +19,8 @@ import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { getTransaction, - subscribeTokenChanges, - pollTransactionChanges, + subscribeAssetChanges, + subscribeTransactionChanges, } from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); @@ -45,8 +45,8 @@ describe('TransactionPayController', () => { const getTransactionMock = jest.mocked(getTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); - const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); - const subscribeTokenChangesMock = jest.mocked(subscribeTokenChanges); + const subscribeTransactionChangesMock = jest.mocked(subscribeTransactionChanges); + const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -89,13 +89,13 @@ describe('TransactionPayController', () => { it('subscribes to rate changes for in-flight retry', () => { const controller = createController(); - expect(subscribeTokenChangesMock).toHaveBeenCalledWith( + expect(subscribeAssetChangesMock).toHaveBeenCalledWith( messenger, expect.any(Function), expect.any(Function), ); - const getControllerState = subscribeTokenChangesMock.mock.calls[0][1]; + const getControllerState = subscribeAssetChangesMock.mock.calls[0][1]; expect(getControllerState()).toBe(controller.state); }); }); @@ -694,7 +694,7 @@ describe('TransactionPayController', () => { ).toBeDefined(); const removeTransactionDataCallback = - pollTransactionChangesMock.mock.calls[0][2]; + subscribeTransactionChangesMock.mock.calls[0][2]; removeTransactionDataCallback(TRANSACTION_ID_MOCK); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index cc372ba610..19d4fdfb6f 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -28,8 +28,8 @@ import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { getTransaction, - subscribeTokenChanges, - pollTransactionChanges, + subscribeAssetChanges, + subscribeTransactionChanges, } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ @@ -91,13 +91,13 @@ export class TransactionPayController extends BaseController< MESSENGER_EXPOSED_METHODS, ); - pollTransactionChanges( + subscribeTransactionChanges( messenger, this.#updateTransactionData.bind(this), this.#removeTransactionData.bind(this), ); - subscribeTokenChanges( + subscribeAssetChanges( messenger, () => this.state, this.#updateTransactionData.bind(this), diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts index 6aafd7b816..9103a6c1ce 100644 --- a/packages/transaction-pay-controller/src/utils/required-tokens.ts +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -65,7 +65,7 @@ function getTokenTransferToken( const { data, to } = getTokenTransferData(transaction) ?? {}; if (!to || !data) { - log('No token transfer detected in transaction', { + log('No token transfer detected', { transactionId: transaction.id, }); return undefined; @@ -118,7 +118,7 @@ function buildRequiredToken( const tokenBalance = getTokenBalance(messenger, from, chainId, tokenAddress); if (tokenDecimals === undefined || !symbol || fiatRates === undefined) { - log('Cannot build required token: missing data', { + log('Missing token data', { transactionId: transaction.id, chainId, tokenAddress, diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 2c769447f2..a20ed58e00 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -20,8 +20,8 @@ import { collectTransactionIds, getTransaction, isPredictWithdrawTransaction, - subscribeTokenChanges, - pollTransactionChanges, + subscribeAssetChanges, + subscribeTransactionChanges, updateTransaction, waitForTransactionConfirmed, } from './transaction'; @@ -91,13 +91,13 @@ describe('Transaction Utils', () => { }); }); - describe('pollTransactionChanges', () => { + describe('subscribeTransactionChanges', () => { it('updates state for new transactions', () => { const updateTransactionDataMock = jest.fn(); parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); - pollTransactionChanges(messenger, updateTransactionDataMock, noop); + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); publish( 'TransactionController:stateChange', @@ -122,7 +122,7 @@ describe('Transaction Utils', () => { parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); - pollTransactionChanges(messenger, updateTransactionDataMock, noop); + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); publish( 'TransactionController:stateChange', @@ -150,7 +150,7 @@ describe('Transaction Utils', () => { (status) => { const removeTransactionDataMock = jest.fn(); - pollTransactionChanges(messenger, noop, removeTransactionDataMock); + subscribeTransactionChanges(messenger, noop, removeTransactionDataMock); publish( 'TransactionController:stateChange', @@ -177,7 +177,7 @@ describe('Transaction Utils', () => { it('removes state if transaction is deleted', () => { const removeTransactionDataMock = jest.fn(); - pollTransactionChanges(messenger, noop, removeTransactionDataMock); + subscribeTransactionChanges(messenger, noop, removeTransactionDataMock); publish( 'TransactionController:stateChange', @@ -201,7 +201,7 @@ describe('Transaction Utils', () => { }); }); - describe('subscribeTokenChanges', () => { + describe('subscribeAssetChanges', () => { function buildState( data: Partial & { tokens: TransactionPayRequiredToken[]; @@ -242,13 +242,17 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish( + 'TokenRatesController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); const transactionData = {} as TransactionData; @@ -266,13 +270,17 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('CurrencyRateController:stateChange', {} as never, []); + isolatedPublish( + 'CurrencyRateController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -285,13 +293,17 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('TokensController:stateChange', {} as never, []); + isolatedPublish( + 'TokensController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -305,13 +317,17 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('AssetsController:stateChange', {} as never, []); + isolatedPublish( + 'AssetsController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -324,15 +340,27 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('TokensController:stateChange', {} as never, []); - isolatedPublish('TokenRatesController:stateChange', {} as never, []); - isolatedPublish('CurrencyRateController:stateChange', {} as never, []); + isolatedPublish( + 'TokensController:stateChange', + {} as never, + [], + ); + isolatedPublish( + 'TokenRatesController:stateChange', + {} as never, + [], + ); + isolatedPublish( + 'CurrencyRateController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }); @@ -344,7 +372,7 @@ describe('Transaction Utils', () => { transactions: [TRANSACTION_META_MOCK], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ @@ -353,7 +381,11 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish( + 'TokenRatesController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).not.toHaveBeenCalled(); expect(parseRequiredTokensMock).not.toHaveBeenCalled(); @@ -368,13 +400,17 @@ describe('Transaction Utils', () => { transactions: [{ ...TRANSACTION_META_MOCK, status }], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish( + 'TokenRatesController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }, @@ -387,13 +423,17 @@ describe('Transaction Utils', () => { transactions: [] as TransactionMeta[], } as TransactionControllerState); - subscribeTokenChanges( + subscribeAssetChanges( isolatedMessenger, () => buildState({ tokens: [] }), updateTransactionDataMock, ); - isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish( + 'TokenRatesController:stateChange', + {} as never, + [], + ); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }); diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 06d2d6aee7..c97d4004ec 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -46,13 +46,13 @@ export function getTransaction( } /** - * Poll for transaction changes and update the transaction data accordingly. + * Subscribe to transaction changes and update the transaction data accordingly. * * @param messenger - Controller messenger. * @param updateTransactionData - Callback to update transaction data. * @param removeTransactionData - Callback to remove transaction data. */ -export function pollTransactionChanges( +export function subscribeTransactionChanges( messenger: TransactionPayControllerMessenger, updateTransactionData: UpdateTransactionDataCallback, removeTransactionData: (transactionId: string) => void, @@ -67,30 +67,17 @@ export function pollTransactionChanges( (tx) => !previousTransactions?.find((prevTx) => prevTx.id === tx.id), ); - const updatedTransactions = transactions - .map((tx) => { - const previousTransaction = previousTransactions?.find( - (prevTx) => prevTx.id === tx.id, - ); - - if ( - !previousTransaction || - previousTransaction.txParams.data === tx.txParams.data - ) { - return undefined; - } - - return { tx, previousTransaction }; - }) - .filter( - ( - entry, - ): entry is { - tx: TransactionMeta; - previousTransaction: TransactionMeta; - } => entry !== undefined, + const updatedTransactions = transactions.filter((tx) => { + const previousTransaction = previousTransactions?.find( + (prevTx) => prevTx.id === tx.id, ); + return ( + previousTransaction && + previousTransaction?.txParams.data !== tx.txParams.data + ); + }); + const finalizedTransactions = transactions.filter((tx) => { const previousTransaction = previousTransactions?.find( (prevTx) => prevTx.id === tx.id, @@ -111,17 +98,8 @@ export function pollTransactionChanges( onTransactionFinalized(tx, removeTransactionData), ); - newTransactions.forEach((tx) => - onTransactionChange(tx, undefined, messenger, updateTransactionData), - ); - - updatedTransactions.forEach(({ tx, previousTransaction }) => - onTransactionChange( - tx, - previousTransaction, - messenger, - updateTransactionData, - ), + [...newTransactions, ...updatedTransactions].forEach((tx) => + onTransactionChange(tx, messenger, updateTransactionData), ); }, (state) => state.transactions, @@ -129,25 +107,14 @@ export function pollTransactionChanges( } /** - * Subscribe to token-data and rate-source state changes and re-run - * {@link parseRequiredTokens} for in-flight transactions whose required - * tokens have not yet been resolved. - * - * `parseRequiredTokens` returns an empty array when any of token info, - * token fiat rates, or native currency rates are unavailable. Without this - * subscription, those transactions stay deadlocked: the existing - * `TransactionController:stateChange` subscription only re-parses when - * `txParams.data` changes, but the client typically gates `data` edits on - * having a resolved required token. This handler closes the loop by re-parsing - * when any of the underlying state sources land, mirroring the same source - * selection `getTokenInfo` and `getTokenFiatRate` use. + * Subscribe to asset state changes and re-parse required tokens for + * in-flight transactions whose tokens have not yet resolved. * * @param messenger - Controller messenger. - * @param getControllerState - Callback returning the current pay-controller - * state, used to find transactions with empty `tokens` to retry. + * @param getControllerState - Callback returning the current controller state. * @param updateTransactionData - Callback to update transaction data. */ -export function subscribeTokenChanges( +export function subscribeAssetChanges( messenger: TransactionPayControllerMessenger, getControllerState: () => TransactionPayControllerState, updateTransactionData: UpdateTransactionDataCallback, @@ -168,18 +135,9 @@ export function subscribeTokenChanges( continue; } - log('Token or rate state changed, re-parsing required tokens', { - transactionId, - source, - patches, - }); - - onTransactionChange( - transaction, - undefined, - messenger, - updateTransactionData, - ); + log('Asset data changed', { transactionId, source, patches }); + + onTransactionChange(transaction, messenger, updateTransactionData); } }; @@ -368,33 +326,17 @@ export function isPredictWithdrawTransaction( * Handle a transaction change by updating its associated data. * * @param transaction - Transaction metadata. - * @param previousTransaction - Previous transaction metadata, when this is an - * update rather than a new transaction or a rate-driven recompute. Used to - * surface the calldata diff in logs. * @param messenger - Controller messenger. * @param updateTransactionData - Callback to update transaction data. */ function onTransactionChange( transaction: TransactionMeta, - previousTransaction: TransactionMeta | undefined, messenger: TransactionPayControllerMessenger, updateTransactionData: UpdateTransactionDataCallback, ): void { const tokens = parseRequiredTokens(transaction, messenger); - log('Transaction changed', { - transactionId: transaction.id, - chainId: transaction.chainId, - tokens, - ...(previousTransaction - ? { - dataChanged: { - from: previousTransaction.txParams.data, - to: transaction.txParams.data, - }, - } - : {}), - }); + log('Transaction changed', { transaction, tokens }); updateTransactionData(transaction.id, (data) => { data.tokens = tokens; From 299297a5725f8b67e4100c4582773707976caa70 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 22:11:56 +0100 Subject: [PATCH 06/14] fix: prettier formatting in transaction test --- .../src/utils/transaction.test.ts | 60 ++++--------------- 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index a20ed58e00..c409c89670 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -248,11 +248,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokenRatesController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokenRatesController:stateChange', {} as never, []); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); const transactionData = {} as TransactionData; @@ -276,11 +272,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'CurrencyRateController:stateChange', - {} as never, - [], - ); + isolatedPublish('CurrencyRateController:stateChange', {} as never, []); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -299,11 +291,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokensController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokensController:stateChange', {} as never, []); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -323,11 +311,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'AssetsController:stateChange', - {} as never, - [], - ); + isolatedPublish('AssetsController:stateChange', {} as never, []); expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); }); @@ -346,21 +330,9 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokensController:stateChange', - {} as never, - [], - ); - isolatedPublish( - 'TokenRatesController:stateChange', - {} as never, - [], - ); - isolatedPublish( - 'CurrencyRateController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokensController:stateChange', {} as never, []); + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + isolatedPublish('CurrencyRateController:stateChange', {} as never, []); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }); @@ -381,11 +353,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokenRatesController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokenRatesController:stateChange', {} as never, []); expect(updateTransactionDataMock).not.toHaveBeenCalled(); expect(parseRequiredTokensMock).not.toHaveBeenCalled(); @@ -406,11 +374,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokenRatesController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokenRatesController:stateChange', {} as never, []); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }, @@ -429,11 +393,7 @@ describe('Transaction Utils', () => { updateTransactionDataMock, ); - isolatedPublish( - 'TokenRatesController:stateChange', - {} as never, - [], - ); + isolatedPublish('TokenRatesController:stateChange', {} as never, []); expect(updateTransactionDataMock).not.toHaveBeenCalled(); }); From 60779acb1daaed15485d6e8c6980c8f23f1f0a94 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 6 May 2026 22:31:22 +0100 Subject: [PATCH 07/14] fix: prettier formatting --- eslint-suppressions.json | 2 +- packages/transaction-pay-controller/CHANGELOG.md | 5 +---- .../src/TransactionPayController.test.ts | 4 +++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 877359245e..0e0cf97028 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2361,4 +2361,4 @@ "count": 10 } } -} \ No newline at end of file +} diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 70d7ecf721..5f23ff1727 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720)) +- Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) ## [21.1.0] @@ -31,10 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) - Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) -### Fixed - -- Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) - ## [21.0.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 7d2fd17916..e857a98390 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -45,7 +45,9 @@ describe('TransactionPayController', () => { const getTransactionMock = jest.mocked(getTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); - const subscribeTransactionChangesMock = jest.mocked(subscribeTransactionChanges); + const subscribeTransactionChangesMock = jest.mocked( + subscribeTransactionChanges, + ); const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; From 3d503f7151ba323525dbaf486c06817013f3773a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 09:22:31 +0100 Subject: [PATCH 08/14] fix: skip asset re-parse when tokens still empty --- .../src/utils/transaction.test.ts | 20 +++++++++++++++++++ .../src/utils/transaction.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index c409c89670..cb13fcabcb 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -359,6 +359,26 @@ describe('Transaction Utils', () => { expect(parseRequiredTokensMock).not.toHaveBeenCalled(); }); + it('skips transactions when parseRequiredTokens still returns empty', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([]); + isolatedGetTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + + subscribeAssetChanges( + isolatedMessenger, + () => buildState({ tokens: [] }), + updateTransactionDataMock, + ); + + isolatedPublish('TokenRatesController:stateChange', {} as never, []); + + expect(parseRequiredTokensMock).toHaveBeenCalledTimes(1); + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + }); + it.each(FINALIZED_STATUSES)( 'skips transactions whose status is %s', (status) => { diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index c97d4004ec..c6f62c7525 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -135,9 +135,17 @@ export function subscribeAssetChanges( continue; } + const tokens = parseRequiredTokens(transaction, messenger); + + if (tokens.length === 0) { + continue; + } + log('Asset data changed', { transactionId, source, patches }); - onTransactionChange(transaction, messenger, updateTransactionData); + updateTransactionData(transaction.id, (data) => { + data.tokens = tokens; + }); } }; From 98bcf46acf5accb03d484c35bfe9041efbf2e2fa Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 09:28:05 +0100 Subject: [PATCH 09/14] fix: rename shadowed variable --- packages/transaction-pay-controller/src/utils/transaction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index c6f62c7525..8502354295 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -143,8 +143,8 @@ export function subscribeAssetChanges( log('Asset data changed', { transactionId, source, patches }); - updateTransactionData(transaction.id, (data) => { - data.tokens = tokens; + updateTransactionData(transaction.id, (draft) => { + draft.tokens = tokens; }); } }; From c745ac1448262dc2239a6cb96b81ef5075787fa6 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 09:34:36 +0100 Subject: [PATCH 10/14] Revert "fix: rename shadowed variable" This reverts commit 98bcf46acf5accb03d484c35bfe9041efbf2e2fa. --- packages/transaction-pay-controller/src/utils/transaction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 8502354295..c6f62c7525 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -143,8 +143,8 @@ export function subscribeAssetChanges( log('Asset data changed', { transactionId, source, patches }); - updateTransactionData(transaction.id, (draft) => { - draft.tokens = tokens; + updateTransactionData(transaction.id, (data) => { + data.tokens = tokens; }); } }; From 8f8f60708022e5b1bd58ce0a48fd6a326f6e2b49 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 09:34:36 +0100 Subject: [PATCH 11/14] Revert "fix: skip asset re-parse when tokens still empty" This reverts commit 3d503f7151ba323525dbaf486c06817013f3773a. --- .../src/utils/transaction.test.ts | 20 ------------------- .../src/utils/transaction.ts | 10 +--------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index cb13fcabcb..c409c89670 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -359,26 +359,6 @@ describe('Transaction Utils', () => { expect(parseRequiredTokensMock).not.toHaveBeenCalled(); }); - it('skips transactions when parseRequiredTokens still returns empty', () => { - const updateTransactionDataMock = jest.fn(); - - parseRequiredTokensMock.mockReturnValue([]); - isolatedGetTransactionControllerStateMock.mockReturnValue({ - transactions: [TRANSACTION_META_MOCK], - } as TransactionControllerState); - - subscribeAssetChanges( - isolatedMessenger, - () => buildState({ tokens: [] }), - updateTransactionDataMock, - ); - - isolatedPublish('TokenRatesController:stateChange', {} as never, []); - - expect(parseRequiredTokensMock).toHaveBeenCalledTimes(1); - expect(updateTransactionDataMock).not.toHaveBeenCalled(); - }); - it.each(FINALIZED_STATUSES)( 'skips transactions whose status is %s', (status) => { diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index c6f62c7525..c97d4004ec 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -135,17 +135,9 @@ export function subscribeAssetChanges( continue; } - const tokens = parseRequiredTokens(transaction, messenger); - - if (tokens.length === 0) { - continue; - } - log('Asset data changed', { transactionId, source, patches }); - updateTransactionData(transaction.id, (data) => { - data.tokens = tokens; - }); + onTransactionChange(transaction, messenger, updateTransactionData); } }; From a5eca830ffe92e1f3f97379eb09dd656b3c0b8ff Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 11:02:29 +0100 Subject: [PATCH 12/14] docs: mark new AllowedEvents as breaking in changelog --- packages/transaction-pay-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5f23ff1727..fae2f9740b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) - Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +### Changed + +- **BREAKING:** Add `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents` ([#8714](https://github.com/MetaMask/core/pull/8714)) + - Consumers must grant these events when creating the controller messenger. + ### Fixed - Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720)) From 3ab3824340d597d77805598baa1b3e3c0407003c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 11:03:13 +0100 Subject: [PATCH 13/14] docs: single breaking changelog entry with nested lines --- packages/transaction-pay-controller/CHANGELOG.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index fae2f9740b..05c290aed2 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,15 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) - Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) -### Changed - -- **BREAKING:** Add `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents` ([#8714](https://github.com/MetaMask/core/pull/8714)) - - Consumers must grant these events when creating the controller messenger. - ### Fixed - Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720)) -- Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) +- **BREAKING:** Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) + - Adds `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents`. + - Consumers must grant these events when creating the controller messenger. ## [21.1.0] From a25d13a1f88308ef6216a777f70a5e22967d2c93 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 7 May 2026 11:03:37 +0100 Subject: [PATCH 14/14] docs: move breaking entry to Changed section --- packages/transaction-pay-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 05c290aed2..e1e1a72ea1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,15 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) + - Adds `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents`. + - Consumers must grant these events when creating the controller messenger. - Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) - Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) ### Fixed - Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720)) -- **BREAKING:** Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714)) - - Adds `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents`. - - Consumers must grant these events when creating the controller messenger. ## [21.1.0]