From 09f054c516a42f9e478ba2dba80dd18abc555e74 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 24 Apr 2026 13:19:39 +0100 Subject: [PATCH] Support bidirectional pay strategy fallback --- packages/transaction-controller/CHANGELOG.md | 1 + .../src/TransactionController.ts | 3 +- .../src/utils/gas.test.ts | 1 + .../transaction-controller/src/utils/gas.ts | 14 ++- .../transaction-pay-controller/CHANGELOG.md | 4 + .../strategy/across/AcrossStrategy.test.ts | 90 ++++++++++++++++ .../src/strategy/across/AcrossStrategy.ts | 48 ++++++++- .../src/strategy/across/across-quotes.test.ts | 39 +++++++ .../src/strategy/across/across-quotes.ts | 12 +-- .../src/strategy/across/types.ts | 1 + .../transaction-pay-controller/src/types.ts | 28 ++++- .../src/utils/quote-gas.test.ts | 31 ++++++ .../src/utils/quote-gas.ts | 5 +- .../src/utils/quotes.test.ts | 102 +++++++++++++++++- .../src/utils/quotes.ts | 25 ++++- .../src/utils/strategy.test.ts | 69 +++++++++++- .../src/utils/strategy.ts | 42 +++++++- 17 files changed, 494 insertions(+), 21 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e6d999f4e9b..4cdca835551 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Snapshot `txParamsOriginal` in `updateEditableParams` when `containerTypes` are first applied ([#8546](https://github.com/MetaMask/core/pull/8546)) +- Add `requiresAuthorizationList` to `TransactionController:estimateGasBatch` results when EIP-7702 batch gas estimation requires a first-time account upgrade ([#8577](https://github.com/MetaMask/core/pull/8577)) ### Fixed diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index abd3ece24a0..ac6e9561d99 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -149,6 +149,7 @@ import { getTransactionHistoryLimit, } from './utils/feature-flags'; import { updateFirstTimeInteraction } from './utils/first-time-interaction'; +import type { EstimateGasBatchResult } from './utils/gas'; import { addGasBuffer, estimateGas, @@ -1717,7 +1718,7 @@ export class TransactionController extends BaseController< chainId: Hex; from: Hex; transactions: BatchTransactionParams[]; - }): Promise<{ totalGasLimit: number; gasLimits: number[] }> { + }): Promise { return estimateGasBatch({ from, getSimulationConfig: this.#getSimulationConfig, diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index e913c40b462..184441bd039 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -1144,6 +1144,7 @@ describe('gas', () => { expect(result).toStrictEqual({ totalGasLimit: GAS_MOCK, gasLimits: [GAS_MOCK], + requiresAuthorizationList: true, }); expect(generateEIP7702BatchTransactionMock).toHaveBeenCalledWith( diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index b21d479919c..dd4a952a5ff 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -35,6 +35,12 @@ export type UpdateGasRequest = { txMeta: TransactionMeta; }; +export type EstimateGasBatchResult = { + gasLimits: number[]; + requiresAuthorizationList?: true; + totalGasLimit: number; +}; + export const log = createModuleLogger(projectLogger, 'gas'); export const FIXED_GAS = '0x5208'; @@ -202,7 +208,7 @@ export async function estimateGasBatch({ messenger: TransactionControllerMessenger; networkClientId: NetworkClientId; transactions: BatchTransactionParams[]; -}): Promise<{ totalGasLimit: number; gasLimits: number[] }> { +}): Promise { const chainId = getChainId({ messenger, networkClientId }); const is7702Result = await isAtomicBatchSupported({ @@ -245,7 +251,11 @@ export async function estimateGasBatch({ log('Estimated EIP-7702 gas limit', totalGasLimit); - return { totalGasLimit, gasLimits: [totalGasLimit] }; + return { + gasLimits: [totalGasLimit], + ...(isUpgradeRequired ? { requiresAuthorizationList: true } : {}), + totalGasLimit, + }; } const allTransactionsHaveGas = transactions.every( diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d599896821b..cab7f66d35f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fall back from Across to later pay strategies when Across quotes would require a first-time EIP-7702 authorization list ([#8577](https://github.com/MetaMask/core/pull/8577)) + ## [19.3.0] ### Added diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index 8d60ba13d94..efe6eb6234d 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -244,6 +244,96 @@ describe('AcrossStrategy', () => { ).toBe(false); }); + it('returns false when the transaction has an authorization list', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + }, + } as TransactionMeta, + }), + ).toBe(false); + }); + + it('does not support authorization lists during request support checks', () => { + const strategy = new AcrossStrategy(); + const result = strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + }, + } as TransactionMeta, + }); + + expect(result).toBe(false); + }); + + it('does not support quotes that require first-time 7702 upgrades', () => { + const strategy = new AcrossStrategy(); + const quote = { + original: { + metamask: { + gasLimits: [], + is7702: true, + requiresAuthorizationList: true, + }, + }, + } as TransactionPayQuote; + + const result = strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toBe(false); + }); + + it('supports 7702 quotes that do not require an authorization list', () => { + const strategy = new AcrossStrategy(); + const quote = { + original: { + metamask: { + gasLimits: [], + is7702: true, + }, + }, + } as TransactionPayQuote; + + expect( + strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: TRANSACTION_META_MOCK, + }), + ).toBe(true); + }); + + it('returns false for unsupported destination actions', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + data: '0x12345678' as Hex, + to: '0xdef' as Hex, + }, + } as TransactionMeta, + }), + ).toBe(false); + }); + it('returns true when all requests are cross-chain', () => { const strategy = new AcrossStrategy(); expect(strategy.supports(baseRequest)).toBe(true); diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index 09d9269b9d5..c54cde6187a 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -2,11 +2,13 @@ import { TransactionType } from '@metamask/transaction-controller'; import type { PayStrategy, + PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { getAcrossDestination } from './across-actions'; import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; import { isSupportedAcrossPerpsDepositRequest } from './perps'; @@ -28,18 +30,54 @@ export class AcrossStrategy implements PayStrategy { } if (request.transaction?.type === TransactionType.perpsDeposit) { - return actionableRequests.every((singleRequest) => + const supportsPerpsDeposit = actionableRequests.every((singleRequest) => isSupportedAcrossPerpsDepositRequest( singleRequest, request.transaction?.type, ), ); + + if (!supportsPerpsDeposit) { + return false; + } + } else { + // Across doesn't support same-chain swaps (e.g. mUSD conversions). + const hasSameChainRequest = actionableRequests.some( + (singleRequest) => + singleRequest.sourceChainId === singleRequest.targetChainId, + ); + + if (hasSameChainRequest) { + return false; + } + } + + // Across cannot submit EIP-7702 authorization lists. This pre-quote check + // catches transactions where the authorization list is already present. + // First-time 7702 upgrades discovered during gas planning are handled in + // `checkQuoteSupport` below. + if (request.transaction.txParams?.authorizationList?.length) { + return false; } - // Across doesn't support same-chain swaps (e.g. mUSD conversions). - return actionableRequests.every( - (singleRequest) => - singleRequest.sourceChainId !== singleRequest.targetChainId, + return actionableRequests.every((singleRequest) => { + try { + getAcrossDestination(request.transaction, singleRequest); + return true; + } catch { + return false; + } + }); + } + + checkQuoteSupport( + request: PayStrategyCheckQuoteSupportRequest, + ): boolean { + // Gas planning can discover that TransactionController would add an + // authorization list for a first-time 7702 upgrade. `is7702` alone is not a + // blocker because it also covers already-upgraded accounts. + return !request.quotes.some( + (quote) => quote.original.metamask.requiresAuthorizationList, ); } diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 2b5e411625f..0af7ac0fdfe 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -1188,6 +1188,7 @@ describe('Across Quotes', () => { estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 51000, gasLimits: [51000], + requiresAuthorizationList: true, }); successfulFetchMock.mockResolvedValue({ @@ -1232,6 +1233,7 @@ describe('Across Quotes', () => { }, ]); expect(result[0].original.metamask.is7702).toBe(true); + expect(result[0].original.metamask.requiresAuthorizationList).toBe(true); expect(calculateGasCostMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -1253,6 +1255,43 @@ describe('Across Quotes', () => { ); }); + it('omits the authorization-list flag when a combined batch does not require one', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metamask).toStrictEqual({ + gasLimits: [ + { + estimate: 51000, + max: 51000, + }, + ], + is7702: true, + }); + }); + it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => { const estimateQuoteGasLimitsSpy = jest.spyOn( quoteGasUtils, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index b612c92aa0c..4f4f7778658 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -200,11 +200,8 @@ async function normalizeQuote( const dustUsd = calculateDustUsd(quote, request, targetFiatRate); const dust = getFiatValueFromUsd(dustUsd, usdToFiatRate); - const { gasLimits, is7702, sourceNetwork } = await calculateSourceNetworkCost( - quote, - messenger, - request, - ); + const { gasLimits, is7702, requiresAuthorizationList, sourceNetwork } = + await calculateSourceNetworkCost(quote, messenger, request); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -244,6 +241,7 @@ async function normalizeQuote( const metamask = { gasLimits, is7702, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), }; return { @@ -390,6 +388,7 @@ async function calculateSourceNetworkCost( sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; is7702: boolean; + requiresAuthorizationList?: true; }> { const acrossFallbackGas = getPayStrategiesConfig(messenger).across.fallbackGas; @@ -409,7 +408,7 @@ async function calculateSourceNetworkCost( value: transaction.value ?? '0x0', })), }); - const { batchGasLimit, is7702 } = gasEstimates; + const { batchGasLimit, is7702, requiresAuthorizationList } = gasEstimates; if (is7702) { if (!batchGasLimit) { @@ -438,6 +437,7 @@ async function calculateSourceNetworkCost( max, }, is7702: true, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), gasLimits: [ { estimate: batchGasLimit.estimate, diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts index b7e668de82f..af0f66a3f6f 100644 --- a/packages/transaction-pay-controller/src/strategy/across/types.ts +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -87,6 +87,7 @@ export type AcrossQuote = { metamask: { gasLimits: AcrossGasLimits; is7702?: boolean; + requiresAuthorizationList?: true; }; quote: AcrossSwapApprovalResponse; request: { diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 7e5b448514a..175668f3827 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -475,6 +475,18 @@ export type PayStrategyGetBatchRequest = { quotes: TransactionPayQuote[]; }; +/** Request to check whether retrieved quotes can be executed by a strategy. */ +export type PayStrategyCheckQuoteSupportRequest = { + /** Controller messenger. */ + messenger: TransactionPayControllerMessenger; + + /** Quotes returned by the strategy. */ + quotes: TransactionPayQuote[]; + + /** Metadata of the original target transaction. */ + transaction: TransactionMeta; +}; + /** Request to get refresh interval for a specific strategy. */ export type PayStrategyGetRefreshIntervalRequest = { /** Chain ID of the source or payment token. */ @@ -490,13 +502,27 @@ export type PayStrategy = { * Check if the strategy supports the given request. * Defaults to true if not implemented. */ - supports?: (request: PayStrategyGetQuotesRequest) => boolean; + supports?: ( + request: PayStrategyGetQuotesRequest, + ) => boolean | Promise; /** Retrieve quotes for required tokens. */ getQuotes: ( request: PayStrategyGetQuotesRequest, ) => Promise[]>; + /** + * Check if the returned quotes are supported after provider quote + * construction and gas planning. + * + * Use this for limitations that are only knowable once quote metadata is + * available, such as whether execution will require an EIP-7702 + * authorization list. + */ + checkQuoteSupport?: ( + request: PayStrategyCheckQuoteSupportRequest, + ) => boolean | Promise; + /** Retrieve batch transactions for quotes, if supported by the strategy. */ getBatchTransactions?: ( request: PayStrategyGetBatchRequest, diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.test.ts b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts index 2273edca62d..64cef9bccae 100644 --- a/packages/transaction-pay-controller/src/utils/quote-gas.test.ts +++ b/packages/transaction-pay-controller/src/utils/quote-gas.test.ts @@ -153,6 +153,37 @@ describe('quote gas estimation', () => { }); }); + it('marks batch estimates that require an authorization list', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 50000, + gasLimits: [50000], + requiresAuthorizationList: true, + }); + + const result = await estimateQuoteGasLimits({ + messenger, + transactions: TRANSACTIONS_MOCK, + }); + + expect(result).toStrictEqual({ + batchGasLimit: { + estimate: 50000, + max: 50000, + }, + gasLimits: [ + { + estimate: 50000, + max: 50000, + }, + ], + is7702: true, + requiresAuthorizationList: true, + totalGasEstimate: 50000, + totalGasLimit: 50000, + usedBatch: true, + }); + }); + it('uses per-transaction batch gas limits and preserves provided gas when it already matches', async () => { getGasBufferMock.mockReturnValue(1.5); estimateGasBatchMock.mockResolvedValue({ diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.ts b/packages/transaction-pay-controller/src/utils/quote-gas.ts index 128154ca037..25c2823c1d3 100644 --- a/packages/transaction-pay-controller/src/utils/quote-gas.ts +++ b/packages/transaction-pay-controller/src/utils/quote-gas.ts @@ -42,6 +42,7 @@ export async function estimateQuoteGasLimits({ batchGasLimit?: QuoteGasLimit; gasLimits: QuoteGasLimit[]; is7702: boolean; + requiresAuthorizationList?: true; totalGasEstimate: number; totalGasLimit: number; usedBatch: boolean; @@ -78,6 +79,7 @@ async function estimateQuoteGasLimitsBatch( batchGasLimit?: QuoteGasLimit; gasLimits: QuoteGasLimit[]; is7702: boolean; + requiresAuthorizationList?: true; totalGasEstimate: number; totalGasLimit: number; }> { @@ -88,7 +90,7 @@ async function estimateQuoteGasLimitsBatch( parseGasLimit(transaction.gas), ); - const { gasLimits } = await messenger.call( + const { gasLimits, requiresAuthorizationList } = await messenger.call( 'TransactionController:estimateGasBatch', { chainId: firstTransaction.chainId, @@ -131,6 +133,7 @@ async function estimateQuoteGasLimitsBatch( ...(batchGasLimit ? { batchGasLimit } : {}), gasLimits: bufferedGasLimits, is7702, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), totalGasEstimate: totalGasLimit, totalGasLimit, }; diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index def00a8b1a5..2d2030acff4 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -16,7 +16,12 @@ import type { } from '../types'; import type { UpdateQuotesRequest } from './quotes'; import { refreshQuotes, updateQuotes } from './quotes'; -import { getStrategiesByName, getStrategyByName } from './strategy'; +import { + checkStrategyQuoteSupport, + checkStrategySupport, + getStrategiesByName, + getStrategyByName, +} from './strategy'; import { getLiveTokenBalance, getTokenFiatRate } from './token'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; @@ -100,6 +105,8 @@ describe('Quotes Utils', () => { const updateTransactionDataMock = jest.fn(); const getStrategyByNameMock = jest.mocked(getStrategyByName); const getStrategiesByNameMock = jest.mocked(getStrategiesByName); + const checkStrategyQuoteSupportMock = jest.mocked(checkStrategyQuoteSupport); + const checkStrategySupportMock = jest.mocked(checkStrategySupport); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const calculateTotalsMock = jest.mocked(calculateTotals); @@ -153,6 +160,18 @@ describe('Quotes Utils', () => { } }), ); + checkStrategySupportMock.mockImplementation(async (strategy, request) => { + return strategy.supports ? await strategy.supports(request) : true; + }); + checkStrategyQuoteSupportMock.mockImplementation( + async (strategy, request) => { + if (strategy.checkQuoteSupport) { + return await strategy.checkQuoteSupport(request); + } + + return true; + }, + ); getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); getQuotesMock.mockResolvedValue([QUOTE_MOCK]); @@ -338,6 +357,87 @@ describe('Quotes Utils', () => { expect(supportedStrategy.getQuotes).toHaveBeenCalled(); }); + it('skips strategies that fail support checks', async () => { + const unsupportedStrategy = { + supports: jest.fn().mockResolvedValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + const supportedStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Bridge, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Bridge) { + return unsupportedStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return supportedStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(unsupportedStrategy.supports).toHaveBeenCalled(); + expect(unsupportedStrategy.getQuotes).not.toHaveBeenCalled(); + expect(supportedStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('falls back to next strategy when post-quote support checks fail', async () => { + const unsupportedStrategy = { + supports: jest.fn().mockReturnValue(true), + checkQuoteSupport: jest.fn().mockResolvedValue(false), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: jest.fn(), + execute: jest.fn(), + }; + + const supportedStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Bridge, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Bridge) { + return unsupportedStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return supportedStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(unsupportedStrategy.getQuotes).toHaveBeenCalled(); + expect(unsupportedStrategy.checkQuoteSupport).toHaveBeenCalledWith({ + messenger, + quotes: [QUOTE_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + expect(unsupportedStrategy.getBatchTransactions).not.toHaveBeenCalled(); + expect(supportedStrategy.getQuotes).toHaveBeenCalled(); + }); + it('continues to next strategy if supports throws', async () => { const brokenStrategy = { supports: jest.fn().mockImplementation(() => { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index a5448ba0558..ec0b7bf927a 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -17,7 +17,12 @@ import type { TransactionPaymentToken, UpdateTransactionDataCallback, } from '../types'; -import { getStrategiesByName, getStrategyByName } from './strategy'; +import { + checkStrategyQuoteSupport, + checkStrategySupport, + getStrategiesByName, + getStrategyByName, +} from './strategy'; import { computeTokenAmounts, getLiveTokenBalance, @@ -512,7 +517,9 @@ async function getQuotes( for (const { name, strategy } of strategies) { try { - if (strategy.supports && !strategy.supports(request)) { + const support = await checkStrategySupport(strategy, request); + + if (!support) { log('Strategy does not support request', { strategy: name, transactionId, @@ -529,6 +536,20 @@ async function getQuotes( continue; } + const quoteSupport = await checkStrategyQuoteSupport(strategy, { + messenger, + quotes, + transaction, + }); + + if (!quoteSupport) { + log('Strategy does not support quotes', { + strategy: name, + transactionId, + }); + continue; + } + log('Updated', { transactionId, quotes }); const batchTransactions = strategy.getBatchTransactions diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index df59807fa65..ef89fb27d08 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -4,7 +4,13 @@ import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; -import { getStrategiesByName, getStrategyByName } from './strategy'; +import type { PayStrategyGetQuotesRequest } from '../types'; +import { + checkStrategyQuoteSupport, + checkStrategySupport, + getStrategiesByName, + getStrategyByName, +} from './strategy'; describe('Strategy Utils', () => { describe('getStrategyByName', () => { @@ -79,4 +85,65 @@ describe('Strategy Utils', () => { expect(onUnknownStrategy).toHaveBeenCalledWith('UnknownStrategy'); }); }); + + describe('checkStrategySupport', () => { + const request = {} as PayStrategyGetQuotesRequest; + + it('uses supports when available', async () => { + const strategy = { + getQuotes: jest.fn(), + execute: jest.fn(), + supports: jest.fn().mockReturnValue(true), + }; + + expect(await checkStrategySupport(strategy, request)).toBe(true); + expect(strategy.supports).toHaveBeenCalledWith(request); + }); + + it('supports async supports checks', async () => { + const strategy = { + getQuotes: jest.fn(), + execute: jest.fn(), + supports: jest.fn().mockResolvedValue(false), + }; + + expect(await checkStrategySupport(strategy, request)).toBe(false); + expect(strategy.supports).toHaveBeenCalledWith(request); + }); + + it('defaults to supported when no support check is provided', async () => { + const strategy = { + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + expect(await checkStrategySupport(strategy, request)).toBe(true); + }); + }); + + describe('checkStrategyQuoteSupport', () => { + const request = { + quotes: [], + } as never; + + it('uses checkQuoteSupport when available', async () => { + const strategy = { + checkQuoteSupport: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + expect(await checkStrategyQuoteSupport(strategy, request)).toBe(false); + expect(strategy.checkQuoteSupport).toHaveBeenCalledWith(request); + }); + + it('defaults to supported when no post-quote support check is provided', async () => { + const strategy = { + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + expect(await checkStrategyQuoteSupport(strategy, request)).toBe(true); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index bfecf06b86c..3ec3ef5ca8d 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -4,7 +4,11 @@ import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; -import type { PayStrategy } from '../types'; +import type { + PayStrategy, + PayStrategyCheckQuoteSupportRequest, + PayStrategyGetQuotesRequest, +} from '../types'; export type NamedStrategy = { name: TransactionPayStrategy; @@ -69,3 +73,39 @@ export function getStrategiesByName( namedStrategy !== undefined, ); } + +/** + * Check whether a strategy supports a quote request. + * + * Defaults to supported when the strategy has no request-level limitations. + * + * @param strategy - Strategy instance. + * @param request - Quote request. + * @returns Whether the strategy supports the request. + */ +export async function checkStrategySupport( + strategy: PayStrategy, + request: PayStrategyGetQuotesRequest, +): Promise { + return strategy.supports ? await strategy.supports(request) : true; +} + +/** + * Check whether a strategy supports quotes after quote construction. + * + * Defaults to supported when the strategy has no post-quote limitations. + * + * @param strategy - Strategy instance. + * @param request - Post-quote support request. + * @returns Whether the strategy supports the quotes. + */ +export async function checkStrategyQuoteSupport( + strategy: PayStrategy, + request: PayStrategyCheckQuoteSupportRequest, +): Promise { + if (strategy.checkQuoteSupport) { + return await strategy.checkQuoteSupport(request); + } + + return true; +}