From 790802619270fe62861235fd1a6d770dba153864 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 9 Feb 2026 10:52:14 +0000 Subject: [PATCH] feat(transaction-pay): add ordered strategy fallback orchestration --- .../ARCHITECTURE.md | 9 +- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/TransactionPayController.test.ts | 91 +++++- .../src/TransactionPayController.ts | 36 ++- .../src/constants.ts | 14 + .../src/helpers/QuoteRefresher.test.ts | 8 + .../src/helpers/QuoteRefresher.ts | 15 +- .../helpers/TransactionPayPublishHook.test.ts | 24 +- .../src/helpers/TransactionPayPublishHook.ts | 4 +- .../transaction-pay-controller/src/types.ts | 9 + .../src/utils/feature-flags.test.ts | 72 +++++ .../src/utils/feature-flags.ts | 36 +++ .../src/utils/quotes.test.ts | 290 +++++++++++++++++- .../src/utils/quotes.ts | 107 +++++-- .../src/utils/strategy.test.ts | 84 +++-- .../src/utils/strategy.ts | 55 ++-- 16 files changed, 735 insertions(+), 123 deletions(-) diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index c44e17a11eb..1b7cc23602e 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -28,6 +28,9 @@ The mechanism by which the tokens are provided on the target chain is abstracted Each `PayStrategy` dictates how the `quotes` are retrieved, which detail the associated fees and strategy specific data, and how those quotes are actioned or "submitted". +`TransactionPayController` provides an ordered strategy list via internal `getStrategies` callback configuration. +The quote flow iterates strategies in order, applies `supports(...)` compatibility checks when present, and falls back to the next compatible strategy if quote retrieval fails or returns no quotes. + ### Bridge The `BridgeStrategy` bridges tokens from the payment our source token to the target chain. @@ -54,11 +57,11 @@ The high level interaction with the `TransactionPayController` is as follows: 4. Controller identifies any required tokens and adds them to its state. 5. If a client confirmation is using `MetaMask Pay`, the user selects a payment token (or it is done automatically) which invokes the `updatePaymentToken` action. - The below steps are also triggered if the transaction `data` is updated. -6. Controller selects an appropriate `PayStrategy` using the `getStrategy` action. -7. Controller requests quotes from the `PayStrategy` and persists them in state, including associated totals. +6. Controller resolves an ordered set of `PayStrategy` implementations using internal callback configuration. +7. Controller requests quotes from each compatible strategy in order until one returns quotes, then persists those quotes and associated totals. 8. Resulting fees and totals are presented in the client transaction confirmation. 9. If approved by the user, the target transaction is signed and published. -10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the same `PayStrategy`. +10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the strategy encoded in the quote. 11. The hook waits for any transactions and quotes to complete. 12. Depending on the pay strategy and required tokens, the original target transaction is also published as the required funds are now in place on the user's account on the target chain. 13. Target transaction is finalized and any related controller state is removed. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 500bcf29d25..c5d1534cf5a 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] +### Added + +- Add ordered strategy fallback mechanism for quote retrieval ([#7868](https://github.com/MetaMask/core/pull/7868)) + ## [16.0.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 37cb66393a5..fc6f7faff78 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -11,6 +11,7 @@ import type { TransactionPayControllerMessenger, TransactionPaySourceAmount, } from './types'; +import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; @@ -19,6 +20,7 @@ jest.mock('./actions/update-payment-token'); jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); +jest.mock('./utils/feature-flags'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -30,6 +32,7 @@ describe('TransactionPayController', () => { const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); + const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; /** @@ -48,7 +51,7 @@ describe('TransactionPayController', () => { jest.resetAllMocks(); messenger = getMessengerMock({ skipRegister: true }).messenger; - + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); updateQuotesMock.mockResolvedValue(true); }); @@ -160,6 +163,91 @@ describe('TransactionPayController', () => { ), ).toBe(TransactionPayStrategy.Test); }); + + it('does not query feature flag strategy order when getStrategies callback returns values', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => [ + TransactionPayStrategy.Test, + ], + messenger, + }); + + getStrategyOrderMock.mockClear(); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Test); + + expect(getStrategyOrderMock).not.toHaveBeenCalled(); + }); + + it('returns relay if getStrategies callback returns empty', async () => { + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Test]); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => [], + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Test); + }); + + it('falls back to feature flag if getStrategies callback returns invalid first value', async () => { + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Bridge]); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => + [undefined] as unknown as TransactionPayStrategy[], + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Bridge); + }); + + it('returns default strategy order when no callbacks and no strategy order feature flag', async () => { + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + + createController(); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Relay); + }); + + it('returns strategy from feature flag when no callbacks are provided', async () => { + getStrategyOrderMock.mockReturnValue([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ]); + + createController(); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Test); + }); }); describe('transaction data update', () => { @@ -215,6 +303,7 @@ describe('TransactionPayController', () => { ); expect(updateQuotesMock).toHaveBeenCalledWith({ + getStrategies: expect.any(Function), messenger, transactionData: expect.objectContaining({ sourceAmounts: [{ sourceAmountHuman: '1.23' }], diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c4e9c5bfc45..7b8b2f20ed5 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -5,7 +5,11 @@ import type { Draft } from 'immer'; import { noop } from 'lodash'; import { updatePaymentToken } from './actions/update-payment-token'; -import { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; +import { + CONTROLLER_NAME, + isTransactionPayStrategy, + TransactionPayStrategy, +} from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; import type { GetDelegationTransactionCallback, @@ -16,6 +20,7 @@ import type { TransactionPayControllerState, UpdatePaymentTokenRequest, } from './types'; +import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; @@ -44,9 +49,14 @@ export class TransactionPayController extends BaseController< transaction: TransactionMeta, ) => TransactionPayStrategy; + readonly #getStrategies?: ( + transaction: TransactionMeta, + ) => TransactionPayStrategy[]; + constructor({ getDelegationTransaction, getStrategy, + getStrategies, messenger, state, }: TransactionPayControllerOptions) { @@ -59,6 +69,7 @@ export class TransactionPayController extends BaseController< this.#getDelegationTransaction = getDelegationTransaction; this.#getStrategy = getStrategy; + this.#getStrategies = getStrategies; this.#registerActionHandlers(); @@ -70,6 +81,7 @@ export class TransactionPayController extends BaseController< // eslint-disable-next-line no-new new QuoteRefresher({ + getStrategies: this.#getStrategiesWithFallback.bind(this), messenger, updateTransactionData: this.#updateTransactionData.bind(this), }); @@ -153,6 +165,7 @@ export class TransactionPayController extends BaseController< if (shouldUpdateQuotes) { updateQuotes({ + getStrategies: this.#getStrategiesWithFallback.bind(this), messenger: this.messenger, transactionData: this.state.transactionData[transactionId], transactionId, @@ -169,8 +182,8 @@ export class TransactionPayController extends BaseController< this.messenger.registerActionHandler( 'TransactionPayController:getStrategy', - this.#getStrategy ?? - ((): TransactionPayStrategy => TransactionPayStrategy.Relay), + (transaction: TransactionMeta): TransactionPayStrategy => + this.#getStrategiesWithFallback(transaction)[0], ); this.messenger.registerActionHandler( @@ -183,4 +196,21 @@ export class TransactionPayController extends BaseController< this.updatePaymentToken.bind(this), ); } + + #getStrategiesWithFallback( + transaction: TransactionMeta, + ): TransactionPayStrategy[] { + const strategyCandidates: unknown[] = + this.#getStrategies?.(transaction) ?? + (this.#getStrategy ? [this.#getStrategy(transaction)] : []); + + const validStrategies = strategyCandidates.filter( + (strategy): strategy is TransactionPayStrategy => + isTransactionPayStrategy(strategy), + ); + + return validStrategies.length + ? validStrategies + : getStrategyOrder(this.messenger); + } } diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index f4e8074c944..73dd4540621 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -37,3 +37,17 @@ export enum TransactionPayStrategy { Relay = 'relay', Test = 'test', } + +const VALID_STRATEGIES = new Set(Object.values(TransactionPayStrategy)); + +/** + * Checks if a value is a valid transaction pay strategy. + * + * @param strategy - Candidate strategy value. + * @returns True if the value is a valid strategy. + */ +export function isTransactionPayStrategy( + strategy: unknown, +): strategy is TransactionPayStrategy { + return VALID_STRATEGIES.has(strategy as TransactionPayStrategy); +} diff --git a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts index 9b60f918c02..3320fd0f4b7 100644 --- a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts +++ b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.test.ts @@ -4,6 +4,7 @@ import { createDeferredPromise } from '@metamask/utils'; import { QuoteRefresher } from './QuoteRefresher'; import { flushPromises } from '../../../../tests/helpers'; +import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionData, @@ -51,6 +52,7 @@ describe('QuoteRefresher', () => { it('polls if quotes detected in state', async () => { new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData: jest.fn(), }); @@ -65,6 +67,7 @@ describe('QuoteRefresher', () => { it('does not poll if no quotes in state', async () => { new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData: jest.fn(), }); @@ -79,6 +82,7 @@ describe('QuoteRefresher', () => { it('polls again after interval', async () => { new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData: jest.fn(), }); @@ -96,6 +100,7 @@ describe('QuoteRefresher', () => { it('stops polling if quotes removed', async () => { new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData: jest.fn(), }); @@ -113,6 +118,7 @@ describe('QuoteRefresher', () => { const updateTransactionData = jest.fn(); new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData, }); @@ -134,6 +140,7 @@ describe('QuoteRefresher', () => { const updateTransactionData = jest.fn(); new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData, }); @@ -159,6 +166,7 @@ describe('QuoteRefresher', () => { const updateTransactionData = jest.fn(); new QuoteRefresher({ + getStrategies: jest.fn().mockReturnValue([TransactionPayStrategy.Relay]), messenger, updateTransactionData, }); diff --git a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts index 9e7c7334c7f..ec93f5c22db 100644 --- a/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts +++ b/packages/transaction-pay-controller/src/helpers/QuoteRefresher.ts @@ -1,3 +1,4 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; import { createModuleLogger } from '@metamask/utils'; import { noop } from 'lodash'; @@ -5,6 +6,7 @@ import type { TransactionPayControllerMessenger, TransactionPayControllerState, } from '..'; +import { TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import type { UpdateTransactionDataCallback } from '../types'; import { refreshQuotes } from '../utils/quotes'; @@ -22,15 +24,22 @@ export class QuoteRefresher { #timeoutId: NodeJS.Timeout | undefined; + readonly #getStrategies: ( + transaction: TransactionMeta, + ) => TransactionPayStrategy[]; + readonly #updateTransactionData: UpdateTransactionDataCallback; constructor({ + getStrategies, messenger, updateTransactionData, }: { + getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[]; messenger: TransactionPayControllerMessenger; updateTransactionData: UpdateTransactionDataCallback; }) { + this.#getStrategies = getStrategies; this.#messenger = messenger; this.#isRunning = false; this.#isUpdating = false; @@ -68,7 +77,11 @@ export class QuoteRefresher { this.#isUpdating = true; try { - await refreshQuotes(this.#messenger, this.#updateTransactionData); + await refreshQuotes( + this.#messenger, + this.#updateTransactionData, + this.#getStrategies, + ); } catch (error) { log('Error refreshing quotes', error); } finally { diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index 91daa76d6b7..611498b1d98 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -5,14 +5,14 @@ import type { import { TransactionPayPublishHook } from './TransactionPayPublishHook'; import { TransactionPayStrategy } from '..'; -import { TestStrategy } from '../strategy/test/TestStrategy'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionPayControllerState, TransactionPayQuote, } from '../types'; +import { getStrategyByName } from '../utils/strategy'; -jest.mock('../strategy/test/TestStrategy'); +jest.mock('../utils/strategy'); const TRANSACTION_META_MOCK = { id: '123-456', @@ -21,14 +21,16 @@ const TRANSACTION_META_MOCK = { }, } as TransactionMeta; -const QUOTE_MOCK = {} as TransactionPayQuote; +const QUOTE_MOCK = { + strategy: TransactionPayStrategy.Test, +} as TransactionPayQuote; describe('TransactionPayPublishHook', () => { const isSmartTransactionMock = jest.fn(); const executeMock = jest.fn(); + const getStrategyByNameMock = jest.mocked(getStrategyByName); - const { messenger, getControllerStateMock, getStrategyMock } = - getMessengerMock(); + const { messenger, getControllerStateMock } = getMessengerMock(); let hook: TransactionPayPublishHook; @@ -49,10 +51,10 @@ describe('TransactionPayPublishHook', () => { messenger, }); - jest.mocked(TestStrategy).mockReturnValue({ + getStrategyByNameMock.mockReturnValue({ execute: executeMock, getQuotes: jest.fn(), - } as unknown as TestStrategy); + } as never); isSmartTransactionMock.mockReturnValue(false); @@ -63,8 +65,6 @@ describe('TransactionPayPublishHook', () => { }, }, } as TransactionPayControllerState); - - getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); }); it('executes strategy with quotes', async () => { @@ -77,6 +77,12 @@ describe('TransactionPayPublishHook', () => { ); }); + it('selects strategy from quote', async () => { + await runHook(); + + expect(getStrategyByNameMock).toHaveBeenCalledWith(QUOTE_MOCK.strategy); + }); + it('does nothing if no quotes in state', async () => { getControllerStateMock.mockReturnValue({ transactionData: {}, diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index c49cbcf252f..d65953c2d2c 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -9,7 +9,7 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../types'; -import { getStrategy } from '../utils/strategy'; +import { getStrategyByName } from '../utils/strategy'; const log = createModuleLogger(projectLogger, 'pay-publish-hook'); @@ -68,7 +68,7 @@ export class TransactionPayPublishHook { return EMPTY_RESULT; } - const strategy = getStrategy(this.#messenger, transactionMeta); + const strategy = getStrategyByName(quotes[0].strategy); return await strategy.execute({ isSmartTransaction: this.#isSmartTransaction, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 794d3270ca0..4b1fcdfa766 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -135,6 +135,9 @@ export type TransactionPayControllerOptions = { /** Callback to select the PayStrategy for a transaction. */ getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy; + /** Callback to select ordered PayStrategies for a transaction. */ + getStrategies?: (transaction: TransactionMeta) => TransactionPayStrategy[]; + /** Controller messenger. */ messenger: TransactionPayControllerMessenger; @@ -426,6 +429,12 @@ export type PayStrategyGetRefreshIntervalRequest = { /** Strategy used to obtain required tokens for a transaction. */ export type PayStrategy = { + /** + * Check if the strategy supports the given request. + * Defaults to true if not implemented. + */ + supports?: (request: PayStrategyGetQuotesRequest) => boolean; + /** Retrieve quotes for required tokens. */ getQuotes: ( request: PayStrategyGetQuotesRequest, diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 566092cad03..f4d96cf1af0 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -6,12 +6,15 @@ import { DEFAULT_RELAY_FALLBACK_GAS_MAX, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, + DEFAULT_STRATEGY_ORDER, getEIP7702SupportedChains, getFeatureFlags, getGasBuffer, getSlippage, + getStrategyOrder, } from './feature-flags'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; +import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; const GAS_FALLBACK_ESTIMATE_MOCK = 123; @@ -385,4 +388,73 @@ describe('Feature Flags Utils', () => { expect(supportedChains).toStrictEqual([]); }); }); + + describe('getStrategyOrder', () => { + it('returns default strategy order when none is set', () => { + const strategyOrder = getStrategyOrder(messenger); + + expect(strategyOrder).toStrictEqual(DEFAULT_STRATEGY_ORDER); + }); + + it('returns strategy order from feature flags', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: [ + TransactionPayStrategy.Test, + TransactionPayStrategy.Bridge, + TransactionPayStrategy.Relay, + ], + }, + }, + }); + + const strategyOrder = getStrategyOrder(messenger); + + expect(strategyOrder).toStrictEqual([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Bridge, + TransactionPayStrategy.Relay, + ]); + }); + + it('filters unknown and duplicate strategies', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: [ + TransactionPayStrategy.Test, + 'unknown-strategy', + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ], + }, + }, + }); + + const strategyOrder = getStrategyOrder(messenger); + + expect(strategyOrder).toStrictEqual([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ]); + }); + + it('falls back to default strategy order when all entries are invalid', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: ['unknown-strategy'], + }, + }, + }); + + const strategyOrder = getStrategyOrder(messenger); + + expect(strategyOrder).toStrictEqual(DEFAULT_STRATEGY_ORDER); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 54fe05c0f68..e2cc7f51673 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1,17 +1,24 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { uniq } from 'lodash'; import type { TransactionPayControllerMessenger } from '..'; +import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import { RELAY_URL_BASE } from '../strategy/relay/constants'; const log = createModuleLogger(projectLogger, 'feature-flags'); +type StrategyOrder = [TransactionPayStrategy, ...TransactionPayStrategy[]]; + export const DEFAULT_GAS_BUFFER = 1.0; export const DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE = 900000; export const DEFAULT_RELAY_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; export const DEFAULT_SLIPPAGE = 0.005; +export const DEFAULT_STRATEGY_ORDER: StrategyOrder = [ + TransactionPayStrategy.Relay, +]; type FeatureFlagsRaw = { gasBuffer?: { @@ -32,6 +39,7 @@ type FeatureFlagsRaw = { relayQuoteUrl?: string; slippage?: number; slippageTokens?: Record>; + strategyOrder?: string[]; }; export type FeatureFlags = { @@ -44,6 +52,34 @@ export type FeatureFlags = { slippage: number; }; +/** + * Get ordered list of strategies to try. + * + * @param messenger - Controller messenger. + * @returns Ordered strategy list. + */ +export function getStrategyOrder( + messenger: TransactionPayControllerMessenger, +): StrategyOrder { + const { strategyOrder: strategyPriority } = getFeatureFlagsRaw(messenger); + + if (!Array.isArray(strategyPriority)) { + return [...DEFAULT_STRATEGY_ORDER]; + } + + const validStrategyPriority = uniq( + strategyPriority.filter((strategy): strategy is TransactionPayStrategy => + isTransactionPayStrategy(strategy), + ), + ); + + if (!validStrategyPriority.length) { + return [...DEFAULT_STRATEGY_ORDER]; + } + + return validStrategyPriority as StrategyOrder; +} + /** * Get feature flags related to the controller. * diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 133372a091a..f9bf38a1af5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -6,10 +6,11 @@ import { cloneDeep } from 'lodash'; import type { UpdateQuotesRequest } from './quotes'; import { refreshQuotes, updateQuotes } from './quotes'; -import { getStrategy, getStrategyByName } from './strategy'; +import { getStrategiesByName, getStrategyByName } from './strategy'; import { getLiveTokenBalance, getTokenFiatRate } from './token'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; +import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionPaySourceAmount, @@ -64,6 +65,7 @@ const QUOTE_MOCK = { usd: '1.23', fiat: '2.34', }, + strategy: TransactionPayStrategy.Test, } as TransactionPayQuote; const TOTALS_MOCK = { @@ -96,13 +98,14 @@ const BATCH_TRANSACTION_MOCK = { describe('Quotes Utils', () => { const { messenger, getControllerStateMock } = getMessengerMock(); const updateTransactionDataMock = jest.fn(); - const getStrategyMock = jest.mocked(getStrategy); const getStrategyByNameMock = jest.mocked(getStrategyByName); + const getStrategiesByNameMock = jest.mocked(getStrategiesByName); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const calculateTotalsMock = jest.mocked(calculateTotals); const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getStrategiesMock = jest.fn(); const getQuotesMock = jest.fn(); const getBatchTransactionsMock = jest.fn(); @@ -114,6 +117,7 @@ describe('Quotes Utils', () => { */ async function run(params?: Partial): Promise { return await updateQuotes({ + getStrategies: getStrategiesMock, messenger, transactionData: cloneDeep(TRANSACTION_DATA_MOCK), transactionId: TRANSACTION_ID_MOCK, @@ -126,17 +130,29 @@ describe('Quotes Utils', () => { jest.resetAllMocks(); jest.clearAllTimers(); - getStrategyMock.mockReturnValue({ - execute: jest.fn(), - getQuotes: getQuotesMock, - getBatchTransactions: getBatchTransactionsMock, - }); + getStrategiesMock.mockReturnValue([TransactionPayStrategy.Test]); getStrategyByNameMock.mockReturnValue({ execute: jest.fn(), getQuotes: getQuotesMock, getBatchTransactions: getBatchTransactionsMock, }); + getStrategiesByNameMock.mockImplementation( + (strategyNames, onUnknownStrategy) => + strategyNames.flatMap((strategyName) => { + try { + return [ + { + name: strategyName, + strategy: getStrategyByNameMock(strategyName), + }, + ]; + } catch { + onUnknownStrategy?.(strategyName); + return []; + } + }), + ); getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); getQuotesMock.mockResolvedValue([QUOTE_MOCK]); @@ -210,6 +226,233 @@ describe('Quotes Utils', () => { }); }); + it('falls back to next strategy when quotes fail', async () => { + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue(new Error('Strategy error')), + execute: jest.fn(), + }; + + const secondStrategy = { + 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 firstStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return secondStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalled(); + expect(secondStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('falls back to next strategy when batch transactions fail', async () => { + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: jest + .fn() + .mockRejectedValue(new Error('Batch error')), + execute: jest.fn(), + }; + + const secondStrategy = { + 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 firstStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return secondStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalled(); + expect(firstStrategy.getBatchTransactions).toHaveBeenCalled(); + expect(secondStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('skips strategies that do not support the request', async () => { + const unsupportedStrategy = { + supports: jest.fn().mockReturnValue(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.getQuotes).not.toHaveBeenCalled(); + expect(supportedStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('continues to next strategy if supports throws', async () => { + const brokenStrategy = { + supports: jest.fn().mockImplementation(() => { + throw new Error('Supports error'); + }), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + const fallbackStrategy = { + 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 brokenStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return fallbackStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(brokenStrategy.getQuotes).not.toHaveBeenCalled(); + expect(fallbackStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('tries next strategy when quotes are empty', async () => { + const emptyStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([]), + execute: jest.fn(), + }; + + const fallbackStrategy = { + 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 emptyStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return fallbackStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(emptyStrategy.getQuotes).toHaveBeenCalled(); + expect(fallbackStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('skips unknown strategies and tries the next valid strategy', async () => { + const fallbackStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + 'unknown-strategy' as TransactionPayStrategy, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Relay) { + return fallbackStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(getStrategyByNameMock).toHaveBeenCalledWith('unknown-strategy'); + expect(fallbackStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('defaults to no batch transactions when strategy does not provide them', async () => { + const strategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([TransactionPayStrategy.Bridge]); + getStrategyByNameMock.mockReturnValue(strategy as never); + + await run(); + + expect(strategy.getQuotes).toHaveBeenCalled(); + }); + it('clears state if no payment token', async () => { await run({ transactionData: { @@ -256,6 +499,15 @@ describe('Quotes Utils', () => { }); }); + it('resolves strategies via getStrategiesByName', async () => { + await run(); + + expect(getStrategiesByNameMock).toHaveBeenCalledWith( + [TransactionPayStrategy.Test], + expect.any(Function), + ); + }); + it('gets quotes with no minimum if allowUnderMinimum is true', async () => { await run({ transactionData: { @@ -456,7 +708,11 @@ describe('Quotes Utils', () => { }, }); - await refreshQuotes(messenger, updateTransactionDataMock); + await refreshQuotes( + messenger, + updateTransactionDataMock, + getStrategiesMock, + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(4); @@ -481,7 +737,11 @@ describe('Quotes Utils', () => { }, }); - await refreshQuotes(messenger, updateTransactionDataMock); + await refreshQuotes( + messenger, + updateTransactionDataMock, + getStrategiesMock, + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(4); @@ -507,7 +767,11 @@ describe('Quotes Utils', () => { }, }); - await refreshQuotes(messenger, updateTransactionDataMock); + await refreshQuotes( + messenger, + updateTransactionDataMock, + getStrategiesMock, + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(0); }); @@ -524,7 +788,11 @@ describe('Quotes Utils', () => { }, }); - await refreshQuotes(messenger, updateTransactionDataMock); + await refreshQuotes( + messenger, + updateTransactionDataMock, + getStrategiesMock, + ); expect(updateTransactionDataMock).toHaveBeenCalledTimes(0); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 889089f566d..9c946ed0a2c 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -4,7 +4,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { getStrategy, getStrategyByName } from './strategy'; +import { getStrategiesByName, getStrategyByName } from './strategy'; import { computeTokenAmounts, getLiveTokenBalance, @@ -12,6 +12,7 @@ import { } from './token'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; +import { TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import type { QuoteRequest, @@ -30,6 +31,7 @@ const DEFAULT_REFRESH_INTERVAL = 30 * 1000; // 30 Seconds const log = createModuleLogger(projectLogger, 'quotes'); export type UpdateQuotesRequest = { + getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[]; messenger: TransactionPayControllerMessenger; transactionData: TransactionData | undefined; transactionId: string; @@ -45,8 +47,13 @@ export type UpdateQuotesRequest = { export async function updateQuotes( request: UpdateQuotesRequest, ): Promise { - const { messenger, transactionData, transactionId, updateTransactionData } = - request; + const { + getStrategies, + messenger, + transactionData, + transactionId, + updateTransactionData, + } = request; const transaction = getTransaction(transactionId, messenger); @@ -96,6 +103,7 @@ export async function updateQuotes( const { batchTransactions, quotes } = await getQuotes( transaction, requests, + getStrategies, messenger, ); @@ -190,10 +198,12 @@ function syncTransaction({ * * @param messenger - Messenger instance. * @param updateTransactionData - Callback to update transaction data. + * @param getStrategies - Callback to get ordered strategy names for a transaction. */ export async function refreshQuotes( messenger: TransactionPayControllerMessenger, updateTransactionData: UpdateTransactionDataCallback, + getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[], ): Promise { const state = messenger.call('TransactionPayController:getState'); const transactionIds = Object.keys(state.transactionData); @@ -222,6 +232,7 @@ export async function refreshQuotes( } const isUpdated = await updateQuotes({ + getStrategies, messenger, transactionData, transactionId, @@ -441,47 +452,91 @@ async function refreshPaymentTokenBalance({ * * @param transaction - Transaction metadata. * @param requests - Quote requests. + * @param getStrategies - Callback to get ordered strategy names for a transaction. * @param messenger - Controller messenger. * @returns An object containing batch transactions and quotes. */ async function getQuotes( transaction: TransactionMeta, requests: QuoteRequest[], + getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[], messenger: TransactionPayControllerMessenger, ): Promise<{ batchTransactions: BatchTransaction[]; quotes: TransactionPayQuote[]; }> { const { id: transactionId } = transaction; - const strategy = getStrategy(messenger as never, transaction); - let quotes: TransactionPayQuote[] | undefined = []; + const strategies = getStrategiesByName( + getStrategies(transaction), + (strategyName) => { + log('Skipping unknown strategy', { + strategy: strategyName, + transactionId, + }); + }, + ); - try { - quotes = requests?.length - ? ((await strategy.getQuotes({ - messenger, - requests, - transaction, - })) as TransactionPayQuote[]) - : []; - } catch (error) { - log('Error fetching quotes', { error, transactionId }); + if (!requests?.length) { + return { + batchTransactions: [], + quotes: [], + }; } - log('Updated', { transactionId, quotes }); + const request = { + messenger, + requests, + transaction, + }; - const batchTransactions = - quotes?.length && strategy.getBatchTransactions - ? await strategy.getBatchTransactions({ - messenger, - quotes, - }) - : []; + for (const { name, strategy } of strategies) { + try { + if (strategy.supports && !strategy.supports(request)) { + log('Strategy does not support request', { + strategy: name, + transactionId, + }); + continue; + } + + const quotes = (await strategy.getQuotes( + request, + )) as TransactionPayQuote[]; + + if (!quotes.length) { + log('Strategy returned no quotes', { strategy: name, transactionId }); + continue; + } + + log('Updated', { transactionId, quotes }); + + const batchTransactions = strategy.getBatchTransactions + ? await strategy.getBatchTransactions({ + messenger, + quotes, + }) + : []; + + log('Batch transactions', { transactionId, batchTransactions }); + + return { + batchTransactions, + quotes, + }; + } catch (error) { + log('Strategy failed, trying next', { + error, + strategy: name, + transactionId, + }); + continue; + } + } - log('Batch transactions', { transactionId, batchTransactions }); + log('No quotes available', { transactionId }); return { - batchTransactions, - quotes, + batchTransactions: [], + quotes: [], }; } diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 7b2c65593e2..8e781f203dc 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -1,55 +1,10 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; - -import { getStrategy, getStrategyByName } from './strategy'; +import { getStrategiesByName, getStrategyByName } from './strategy'; import { TransactionPayStrategy } from '../constants'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; -import { getMessengerMock } from '../tests/messenger-mock'; - -const TRANSACTION_META_MOCK = {} as TransactionMeta; describe('Strategy Utils', () => { - const { messenger, getStrategyMock } = getMessengerMock(); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('getStrategy', () => { - it('returns TestStrategy if strategy name is Test', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); - - const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); - - expect(strategy).toBeInstanceOf(TestStrategy); - }); - - it('returns BridgeStrategy if strategy name is Bridge', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Bridge); - - const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); - - expect(strategy).toBeInstanceOf(BridgeStrategy); - }); - - it('returns RelayStrategy if strategy name is Relay', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Relay); - - const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); - - expect(strategy).toBeInstanceOf(RelayStrategy); - }); - - it('throws if strategy name is unknown', async () => { - getStrategyMock.mockReturnValue('UnknownStrategy' as never); - - expect(() => getStrategy(messenger, TRANSACTION_META_MOCK)).toThrow( - 'Unknown strategy: UnknownStrategy', - ); - }); - }); - describe('getStrategyByName', () => { it('returns TestStrategy if strategy name is Test', () => { const strategy = getStrategyByName(TransactionPayStrategy.Test); @@ -72,4 +27,41 @@ describe('Strategy Utils', () => { ); }); }); + + describe('getStrategiesByName', () => { + it('returns strategies in input order', () => { + const strategies = getStrategiesByName([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Bridge, + TransactionPayStrategy.Relay, + ]); + + expect(strategies).toHaveLength(3); + expect(strategies[0].name).toBe(TransactionPayStrategy.Test); + expect(strategies[1].name).toBe(TransactionPayStrategy.Bridge); + expect(strategies[2].name).toBe(TransactionPayStrategy.Relay); + expect(strategies[0].strategy).toBeInstanceOf(TestStrategy); + expect(strategies[1].strategy).toBeInstanceOf(BridgeStrategy); + expect(strategies[2].strategy).toBeInstanceOf(RelayStrategy); + }); + + it('skips unknown strategies and calls callback', () => { + const onUnknownStrategy = jest.fn(); + + const strategies = getStrategiesByName( + [ + TransactionPayStrategy.Test, + 'UnknownStrategy' as TransactionPayStrategy, + TransactionPayStrategy.Relay, + ], + onUnknownStrategy, + ); + + expect(strategies.map(({ name }) => name)).toStrictEqual([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ]); + expect(onUnknownStrategy).toHaveBeenCalledWith('UnknownStrategy'); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index dc505431f4e..22c2cf7c9dc 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -1,29 +1,13 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; - import { TransactionPayStrategy } from '../constants'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; -import type { PayStrategy, TransactionPayControllerMessenger } from '../types'; - -/** - * Get the payment strategy instance. - * - * @param messenger - Controller messenger - * @param transaction - Transaction to get the strategy for. - * @returns The payment strategy instance. - */ -export function getStrategy( - messenger: TransactionPayControllerMessenger, - transaction: TransactionMeta, -): PayStrategy { - const strategyName = messenger.call( - 'TransactionPayController:getStrategy', - transaction, - ); +import type { PayStrategy } from '../types'; - return getStrategyByName(strategyName); -} +export type NamedStrategy = { + name: TransactionPayStrategy; + strategy: PayStrategy; +}; /** * Get strategy instance by name. @@ -48,3 +32,32 @@ export function getStrategyByName( throw new Error(`Unknown strategy: ${strategyName as string}`); } } + +/** + * Resolve strategy names into strategy instances, skipping unknown entries. + * + * @param strategyNames - Ordered strategy names. + * @param onUnknownStrategy - Callback invoked for unknown strategies. + * @returns Ordered valid strategies with names. + */ +export function getStrategiesByName( + strategyNames: TransactionPayStrategy[], + onUnknownStrategy?: (strategyName: TransactionPayStrategy) => void, +): NamedStrategy[] { + return strategyNames + .map((strategyName) => { + try { + return { + name: strategyName, + strategy: getStrategyByName(strategyName), + }; + } catch { + onUnknownStrategy?.(strategyName); + return undefined; + } + }) + .filter( + (namedStrategy): namedStrategy is NamedStrategy => + namedStrategy !== undefined, + ); +}