From 4f279267823792c41385d41fbef5ae89fbf20dbf Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 23 Nov 2025 02:22:43 +0000 Subject: [PATCH 1/3] Add clear quotes action --- .../src/TransactionPayController.ts | 17 +++++ .../src/actions/clear-quotes.ts | 71 +++++++++++++++++++ .../src/helpers/QuoteRefresher.ts | 12 +++- .../transaction-pay-controller/src/index.ts | 1 + .../src/strategy/relay/relay-quotes.ts | 3 +- .../transaction-pay-controller/src/types.ts | 14 ++++ .../src/utils/quotes.ts | 27 ++++++- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 packages/transaction-pay-controller/src/actions/clear-quotes.ts diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index be80ea3f7d5..93ba606fe07 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -4,10 +4,12 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Draft } from 'immer'; import { noop } from 'lodash'; +import { clearQuotes, getAbortSignal } from './actions/clear-quotes'; import { updatePaymentToken } from './actions/update-payment-token'; import { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; import type { + ClearQuotesRequest, GetDelegationTransactionCallback, TransactionData, TransactionPayControllerMessenger, @@ -73,6 +75,13 @@ export class TransactionPayController extends BaseController< }); } + clearQuotes(request: ClearQuotesRequest) { + clearQuotes(request, { + messenger: this.messenger, + updateTransactionData: this.#updateTransactionData.bind(this), + }); + } + updatePaymentToken(request: UpdatePaymentTokenRequest) { updatePaymentToken(request, { messenger: this.messenger, @@ -122,7 +131,10 @@ export class TransactionPayController extends BaseController< }); if (shouldUpdateQuotes) { + const abortSignal = getAbortSignal(transactionId); + updateQuotes({ + abortSignal, messenger: this.messenger, transactionData: this.state.transactionData[transactionId], transactionId, @@ -132,6 +144,11 @@ export class TransactionPayController extends BaseController< } #registerActionHandlers() { + this.messenger.registerActionHandler( + 'TransactionPayController:clearQuotes', + this.clearQuotes.bind(this), + ); + this.messenger.registerActionHandler( 'TransactionPayController:getDelegationTransaction', this.#getDelegationTransaction.bind(this), diff --git a/packages/transaction-pay-controller/src/actions/clear-quotes.ts b/packages/transaction-pay-controller/src/actions/clear-quotes.ts new file mode 100644 index 00000000000..ff9644743b9 --- /dev/null +++ b/packages/transaction-pay-controller/src/actions/clear-quotes.ts @@ -0,0 +1,71 @@ +import { createModuleLogger } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '..'; +import { projectLogger } from '../logger'; +import type { + ClearQuotesRequest, + UpdateTransactionDataCallback, +} from '../types'; + +const log = createModuleLogger(projectLogger, 'clear-quotes'); + +const abortControllersByTransactionId: Record = {}; + +export type ClearQuotesOptions = { + messenger: TransactionPayControllerMessenger; + updateTransactionData: UpdateTransactionDataCallback; +}; + +/** + * Clear the quotes for a specific transaction. + * + * @param request - Request parameters. + * @param options - Options bag. + */ +export function clearQuotes( + request: ClearQuotesRequest, + options: ClearQuotesOptions, +) { + const { reason: requestReason, transactionId } = request; + const { updateTransactionData } = options; + const reason = requestReason ?? 'Clear quotes action'; + + getAbortController(transactionId).abort(reason); + delete abortControllersByTransactionId[transactionId]; + + updateTransactionData(transactionId, (data) => { + data.isLoading = false; + data.quotes = undefined; + data.sourceAmounts = undefined; + data.totals = undefined; + }); + + log('Cleared quotes', { transactionId, reason }); +} + +/** + * Get the AbortSignal for a specific transaction. + * + * @param transactionId - ID of the transaction. + * @returns AbortSignal instance. + */ +export function getAbortSignal(transactionId: string) { + return getAbortController(transactionId).signal; +} + +/** + * Get or create an AbortController for a specific transaction. + * + * @param transactionId - ID of the transaction. + * @returns - AbortController instance. + */ +function getAbortController(transactionId: string) { + let abortController = abortControllersByTransactionId[transactionId]; + + if (!abortController) { + abortController = new AbortController(); + abortControllersByTransactionId[transactionId] = abortController; + } + + return abortController; +} diff --git a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts index 2526bee511b..14521d6f177 100644 --- a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts +++ b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts @@ -14,6 +14,8 @@ const CHECK_INTERVAL = 1000; // 1 Second const log = createModuleLogger(projectLogger, 'quote-refresh'); export class QuoteRefresher { + #abortController: AbortController; + #isRunning: boolean; #isUpdating: boolean; @@ -31,6 +33,7 @@ export class QuoteRefresher { messenger: TransactionPayControllerMessenger; updateTransactionData: UpdateTransactionDataCallback; }) { + this.#abortController = new AbortController(); this.#messenger = messenger; this.#isRunning = false; this.#isUpdating = false; @@ -61,6 +64,9 @@ export class QuoteRefresher { this.#isRunning = false; + this.#abortController.abort(); + this.#abortController = new AbortController(); + log('Stopped'); } @@ -68,7 +74,11 @@ export class QuoteRefresher { this.#isUpdating = true; try { - await refreshQuotes(this.#messenger, this.#updateTransactionData); + await refreshQuotes( + this.#messenger, + this.#updateTransactionData, + this.#abortController.signal, + ); } catch (error) { log('Error refreshing quotes', error); } finally { diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 04b8fe4d86b..6a1f15c7f59 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,6 +1,7 @@ export type { TransactionPayControllerActions, TransactionPayControllerEvents, + TransactionPayControllerClearQuotesAction, TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStateAction, TransactionPayControllerGetStrategyAction, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 7f6c60cdb91..b9260abe5b9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -77,7 +77,7 @@ async function getSingleQuote( request: QuoteRequest, fullRequest: PayStrategyGetQuotesRequest, ): Promise> { - const { messenger, transaction } = fullRequest; + const { abortSignal, messenger, transaction } = fullRequest; try { const body = { @@ -101,6 +101,7 @@ async function getSingleQuote( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + signal: abortSignal, }); const quote = (await response.json()) as RelayQuote; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fa13bada97b..ca7aaceaea2 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -59,6 +59,11 @@ export type AllowedEvents = | TransactionControllerStateChangeEvent | TransactionControllerUnapprovedTransactionAddedEvent; +export type TransactionPayControllerClearQuotesAction = { + type: `${typeof CONTROLLER_NAME}:clearQuotes`; + handler: (request: ClearQuotesRequest) => void; +}; + export type TransactionPayControllerGetStateAction = ControllerGetStateAction< typeof CONTROLLER_NAME, TransactionPayControllerState @@ -88,6 +93,7 @@ export type TransactionPayControllerStateChangeEvent = >; export type TransactionPayControllerActions = + | TransactionPayControllerClearQuotesAction | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction @@ -322,6 +328,9 @@ export type TransactionPayQuote = { /** Request to get quotes for a transaction. */ export type PayStrategyGetQuotesRequest = { + /** Optional abort signal to cancel the quote retrieval. */ + abortSignal?: AbortSignal; + /** Controller messenger. */ messenger: TransactionPayControllerMessenger; @@ -447,3 +456,8 @@ export type Amount = FiatValue & { /** Amount in atomic format without factoring token decimals. */ raw: string; }; + +export type ClearQuotesRequest = { + reason?: string; + transactionId: string; +}; diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index f396ba94b6d..add83952173 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -27,6 +27,7 @@ const DEFAULT_REFRESH_INTERVAL = 30 * 1000; // 30 Seconds const log = createModuleLogger(projectLogger, 'quotes'); export type UpdateQuotesRequest = { + abortSignal?: AbortSignal; messenger: TransactionPayControllerMessenger; transactionData: TransactionData | undefined; transactionId: string; @@ -42,8 +43,13 @@ export type UpdateQuotesRequest = { export async function updateQuotes( request: UpdateQuotesRequest, ): Promise { - const { messenger, transactionData, transactionId, updateTransactionData } = - request; + const { + abortSignal, + messenger, + transactionData, + transactionId, + updateTransactionData, + } = request; const transaction = getTransaction(transactionId, messenger); @@ -69,6 +75,7 @@ export async function updateQuotes( updateTransactionData(transactionId, (data) => { data.isLoading = true; + data.quotes = undefined; }); try { @@ -76,8 +83,18 @@ export async function updateQuotes( transaction, requests, messenger, + abortSignal, ); + if (abortSignal?.aborted) { + log('Update quotes aborted', { + transactionId, + reason: abortSignal.reason, + }); + + return false; + } + const totals = calculateTotals({ quotes: quotes as TransactionPayQuote[], messenger, @@ -162,10 +179,12 @@ function syncTransaction({ * * @param messenger - Messenger instance. * @param updateTransactionData - Callback to update transaction data. + * @param abortSignal - Optional abort signal to cancel the quote refresh. */ export async function refreshQuotes( messenger: TransactionPayControllerMessenger, updateTransactionData: UpdateTransactionDataCallback, + abortSignal?: AbortSignal, ) { const state = messenger.call('TransactionPayController:getState'); const transactionIds = Object.keys(state.transactionData); @@ -194,6 +213,7 @@ export async function refreshQuotes( } const isUpdated = await updateQuotes({ + abortSignal, messenger, transactionData, transactionId, @@ -264,12 +284,14 @@ function buildQuoteRequests({ * @param transaction - Transaction metadata. * @param requests - Quote requests. * @param messenger - Controller messenger. + * @param abortSignal - Optional abort signal to cancel the quote retrieval. * @returns An object containing batch transactions and quotes. */ async function getQuotes( transaction: TransactionMeta, requests: QuoteRequest[], messenger: TransactionPayControllerMessenger, + abortSignal?: AbortSignal, ) { const { id: transactionId } = transaction; const strategy = getStrategy(messenger as never, transaction); @@ -278,6 +300,7 @@ async function getQuotes( try { quotes = requests?.length ? ((await strategy.getQuotes({ + abortSignal, messenger, requests, transaction, From bd85eb9c28636d7dcad27cb188dbc135d1ffcdd6 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 23 Nov 2025 02:58:08 +0000 Subject: [PATCH 2/3] Add unit tests --- .../src/TransactionPayController.test.ts | 20 ++++++++ .../src/utils/quotes.test.ts | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 473fc919c12..c3242d0d602 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -12,8 +12,10 @@ import type { import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; +import { clearQuotes } from './actions/clear-quotes'; jest.mock('./actions/update-payment-token'); +jest.mock('./actions/clear-quotes'); jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); @@ -28,6 +30,7 @@ describe('TransactionPayController', () => { const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); + const clearQuotesMock = jest.mocked(clearQuotes); let messenger: TransactionPayControllerMessenger; /** @@ -195,4 +198,21 @@ describe('TransactionPayController', () => { ).toBeUndefined(); }); }); + + describe('messenger', () => { + it('clear quotes', () => { + createController(); + + messenger.call('TransactionPayController:clearQuotes', { + transactionId: TRANSACTION_ID_MOCK, + }); + + expect(clearQuotesMock).toHaveBeenCalledWith( + { + transactionId: TRANSACTION_ID_MOCK, + }, + expect.anything(), + ); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 9de6050952a..d923a653f50 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -319,6 +319,25 @@ describe('Quotes Utils', () => { expect(updateTransactionDataMock).not.toHaveBeenCalled(); expect(result).toBe(false); }); + + it('skips update if abort signal is aborted', async () => { + const abortController = new AbortController(); + abortController.abort(); + + await run({ + abortSignal: abortController.signal, + }); + + const transactionDataMock = {}; + + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: undefined, + }); + }); }); describe('refreshQuotes', () => { @@ -406,5 +425,37 @@ describe('Quotes Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(0); }); + + it('skips update if abort signal is aborted', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_ID_MOCK]: { + isLoading: false, + paymentToken: TRANSACTION_DATA_MOCK.paymentToken, + quotes: [QUOTE_MOCK], + quotesLastUpdated: 1, + } as TransactionData, + }, + }); + + const abortController = new AbortController(); + abortController.abort(); + + await refreshQuotes( + messenger, + updateTransactionDataMock, + abortController.signal, + ); + + const transactionDataMock = {}; + + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: undefined, + }); + }); }); }); From 690ae4b380420c2facaa3c5c9a4bc97f6badea42 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 24 Nov 2025 09:53:02 +0000 Subject: [PATCH 3/3] Add unit tests --- .../src/actions/clear-quotes.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/transaction-pay-controller/src/actions/clear-quotes.test.ts diff --git a/packages/transaction-pay-controller/src/actions/clear-quotes.test.ts b/packages/transaction-pay-controller/src/actions/clear-quotes.test.ts new file mode 100644 index 00000000000..6abdd6a8fb7 --- /dev/null +++ b/packages/transaction-pay-controller/src/actions/clear-quotes.test.ts @@ -0,0 +1,88 @@ +import { clearQuotes, getAbortSignal } from './clear-quotes'; +import type { TransactionData } from '../types'; + +const TRANSACTION_ID_MOCK = '123-456'; +const REASON_MOCK = 'Test reason'; + +describe('Clear Quotes Action', () => { + it('removes quotes from state', () => { + const updateTransactionDataMock = jest.fn(); + + clearQuotes( + { + transactionId: TRANSACTION_ID_MOCK, + reason: 'User requested clear', + }, + { + messenger: {} as never, + updateTransactionData: updateTransactionDataMock, + }, + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + + const transactionDataMock = { + isLoading: true, + quotes: [{}], + sourceAmounts: {}, + totals: {}, + } as TransactionData; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toStrictEqual({ + isLoading: false, + quotes: undefined, + sourceAmounts: undefined, + totals: undefined, + }); + }); + + it('aborts signal for the transaction with default reason', () => { + const abortMock = jest.fn(); + + jest + .spyOn(AbortController.prototype, 'abort') + .mockImplementation(abortMock); + + clearQuotes( + { + transactionId: TRANSACTION_ID_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: jest.fn(), + }, + ); + + expect(abortMock).toHaveBeenCalledWith('Clear quotes action'); + }); + + it('aborts signal for the transaction with provided reason', () => { + const abortMock = jest.fn(); + + jest + .spyOn(AbortController.prototype, 'abort') + .mockImplementation(abortMock); + + clearQuotes( + { + transactionId: TRANSACTION_ID_MOCK, + reason: REASON_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: jest.fn(), + }, + ); + + expect(abortMock).toHaveBeenCalledWith(REASON_MOCK); + }); + + describe('getAbortSignal', () => { + it('returns an AbortSignal instance', () => { + const signal = getAbortSignal(TRANSACTION_ID_MOCK); + expect(signal).toBeInstanceOf(AbortSignal); + }); + }); +});