From ddfc2454f3eeb78bf4ddd9224eb739ecf52e29ba Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 24 Mar 2026 14:02:19 +0000 Subject: [PATCH 1/4] feat(transaction-pay-controller): add route-based strategy resolution --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/TransactionPayController.test.ts | 116 +++++++ .../src/TransactionPayController.ts | 27 +- .../transaction-pay-controller/src/index.ts | 5 + .../transaction-pay-controller/src/types.ts | 9 + .../src/utils/feature-flags.test.ts | 304 ++++++++++++++++++ .../src/utils/feature-flags.ts | 62 +++- .../src/utils/strategy-routing.ts | 247 ++++++++++++++ 8 files changed, 753 insertions(+), 18 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/strategy-routing.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ead04dc76fe..32988b9c0a4 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `FiatStrategy` to retrieve quotes via `RampsController` ([#8121](https://github.com/MetaMask/core/pull/8121)) +- Add route-based `confirmations_pay` strategy resolution via the `getStrategyRouteContext` controller option and `getStrategyOrderForRouteFromFeatureFlags` helper ([#8282](https://github.com/MetaMask/core/pull/8282)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index f19bac3064e..22e5ed78c00 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -13,6 +13,8 @@ import type { TransactionPaySourceAmount, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; +import type { TransactionPayRouteContext } from './utils/strategy-routing'; +import { getStrategyOrderForRoute } from './utils/strategy-routing'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; @@ -23,6 +25,7 @@ jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); jest.mock('./utils/feature-flags'); +jest.mock('./utils/strategy-routing'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -36,6 +39,7 @@ describe('TransactionPayController', () => { const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); + const getStrategyOrderForRouteMock = jest.mocked(getStrategyOrderForRoute); let messenger: TransactionPayControllerMessenger; /** @@ -55,6 +59,9 @@ describe('TransactionPayController', () => { messenger = getMessengerMock({ skipRegister: true }).messenger; getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + getStrategyOrderForRouteMock.mockReturnValue([ + TransactionPayStrategy.Relay, + ]); updateQuotesMock.mockResolvedValue(true); }); @@ -337,6 +344,115 @@ describe('TransactionPayController', () => { ).toBe(TransactionPayStrategy.Bridge); }); + it('uses route-based feature flag resolution when getStrategyRouteContext is provided', async () => { + const getStrategyRouteContext = jest.fn(() => ({ + chainId: '0xa4b1' as Hex, + tokenAddress: '0xabc' as Hex, + transactionType: 'perpsDeposit', + })); + + getStrategyOrderForRouteMock.mockReturnValue([ + TransactionPayStrategy.Across, + ]); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategyRouteContext, + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Across); + expect(getStrategyRouteContext).toHaveBeenCalledWith( + TRANSACTION_META_MOCK, + ); + expect(getStrategyOrderForRouteMock).toHaveBeenCalledWith(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }); + }); + + it('falls back to default strategy order when route-based resolution returns no strategies', async () => { + getStrategyOrderForRouteMock.mockReturnValue([]); + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategyRouteContext: (): TransactionPayRouteContext => ({ + chainId: '0xa4b1' as Hex, + tokenAddress: '0xabc' as Hex, + transactionType: 'perpsDeposit', + }), + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Relay); + expect(getStrategyOrderForRouteMock).toHaveBeenCalledWith(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }); + expect(getStrategyOrderMock).toHaveBeenCalledWith(messenger); + }); + + it('falls back to default strategy order for quote refresh when route-based resolution returns no strategies', async () => { + getStrategyOrderForRouteMock.mockReturnValue([]); + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + + const controller = new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategyRouteContext: (): TransactionPayRouteContext => ({ + chainId: '0xa4b1' as Hex, + tokenAddress: '0xabc' as Hex, + transactionType: 'perpsDeposit', + }), + messenger, + }); + + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isPostQuote = true; + }); + + const { getStrategies } = updateQuotesMock.mock.calls[0][0]; + + expect(getStrategies(TRANSACTION_META_MOCK)).toStrictEqual([ + TransactionPayStrategy.Relay, + ]); + }); + + it('prefers explicit getStrategies values over route-based feature flag resolution', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => [ + TransactionPayStrategy.Test, + ], + getStrategyRouteContext: (): TransactionPayRouteContext => ({ + chainId: '0xa4b1' as Hex, + tokenAddress: '0xabc' as Hex, + transactionType: 'perpsDeposit', + }), + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Test); + expect(getStrategyOrderForRouteMock).not.toHaveBeenCalled(); + }); + it('returns default strategy order when no callbacks and no strategy order feature flag', async () => { getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 37ce1a19744..6593eee2904 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -23,6 +23,8 @@ import type { UpdatePaymentTokenRequest, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; +import { getStrategyOrderForRoute } from './utils/strategy-routing'; +import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; @@ -63,10 +65,15 @@ export class TransactionPayController extends BaseController< transaction: TransactionMeta, ) => TransactionPayStrategy[]; + readonly #getStrategyRouteContext?: ( + transaction: TransactionMeta, + ) => TransactionPayRouteContext; + constructor({ getDelegationTransaction, getStrategy, getStrategies, + getStrategyRouteContext, messenger, state, }: TransactionPayControllerOptions) { @@ -80,6 +87,7 @@ export class TransactionPayController extends BaseController< this.#getDelegationTransaction = getDelegationTransaction; this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; + this.#getStrategyRouteContext = getStrategyRouteContext; this.messenger.registerMethodActionHandlers( this, @@ -272,8 +280,21 @@ export class TransactionPayController extends BaseController< isTransactionPayStrategy(strategy), ); - return validStrategies.length - ? validStrategies - : getStrategyOrder(this.messenger); + if (validStrategies.length) { + return validStrategies; + } + + if (this.#getStrategyRouteContext) { + const routeStrategies = getStrategyOrderForRoute( + this.messenger, + this.#getStrategyRouteContext(transaction), + ); + + if (routeStrategies.length) { + return routeStrategies; + } + } + + return getStrategyOrder(this.messenger); } } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 53cc04fa203..cf107390e64 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -29,3 +29,8 @@ export { TransactionPayStrategy } from './constants'; export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; +export type { TransactionPayRouteContext } from './utils/strategy-routing'; +export { + getStrategyOrderForRoute, + getStrategyOrderForRouteFromFeatureFlags, +} from './utils/strategy-routing'; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 320f07327dc..677e861ac69 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -39,6 +39,7 @@ import type { Draft } from 'immer'; import type { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types'; +import type { TransactionPayRouteContext } from './utils/strategy-routing'; export type AllowedActions = | AccountTrackerControllerGetStateAction @@ -140,6 +141,14 @@ export type TransactionPayControllerOptions = { /** Callback to select ordered PayStrategies for a transaction. */ getStrategies?: (transaction: TransactionMeta) => TransactionPayStrategy[]; + /** + * Callback to derive route context for generic feature-flag based strategy selection. + * Used when no custom strategy callback returns a valid ordered strategy list. + */ + getStrategyRouteContext?: ( + transaction: TransactionMeta, + ) => TransactionPayRouteContext; + /** Controller messenger. */ messenger: TransactionPayControllerMessenger; 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 18e287ffe6b..af7825fa211 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -26,6 +26,10 @@ import { import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; +import { + getStrategyOrderForRoute, + getStrategyOrderForRouteFromFeatureFlags, +} from './strategy-routing'; const GAS_FALLBACK_ESTIMATE_MOCK = 123; const GAS_FALLBACK_MAX_MOCK = 456; @@ -774,5 +778,305 @@ describe('Feature Flags Utils', () => { expect(strategyOrder).toStrictEqual(DEFAULT_STRATEGY_ORDER); }); + + it('prefers local overrides over remote flags', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + localOverrides: { + confirmations_pay: { + strategyOrder: [TransactionPayStrategy.Across], + }, + }, + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: [TransactionPayStrategy.Relay], + }, + }, + }); + + expect(getStrategyOrder(messenger)).toStrictEqual([ + TransactionPayStrategy.Across, + ]); + }); + }); + + describe('getStrategyOrderForRouteFromFeatureFlags', () => { + it('uses default routing config when raw feature flags are undefined', () => { + expect( + getStrategyOrderForRouteFromFeatureFlags(undefined, {}), + ).toStrictEqual([TransactionPayStrategy.Relay]); + }); + + it('filters invalid routing config and dedupes strategies', () => { + expect( + getStrategyOrderForRouteFromFeatureFlags( + { + strategyOrder: [123, 'relay', 'relay'], + payStrategies: { + across: { enabled: true }, + relay: { enabled: false }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + default: [123, 'invalid'], + chains: { + '0xa4b1': [123], + '0xa4b2': ['relay'], + }, + tokens: { + '0xa4b1': undefined, + '0xa4b2': { + '0xabc': [123], + '0xdef': ['across'], + }, + }, + }, + }, + }, + }, + { + chainId: '0xa4b2', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }, + ), + ).toStrictEqual([TransactionPayStrategy.Across]); + }); + + it('resolves routing overrides in token, chain, default, global order precedence', () => { + const featureFlags = { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + default: ['relay'], + chains: { + '0xa4b1': ['across'], + }, + tokens: { + '0xa4b1': { + '0xabc': ['relay', 'across'], + }, + }, + }, + }, + }, + strategyOrder: ['relay', 'across'], + }; + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0xa4b1', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0x1', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Relay]); + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0x1', + tokenAddress: '0xdef', + }), + ).toStrictEqual([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + }); + + it('matches mixed-case route context hex values against normalized overrides', () => { + const featureFlags = { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + default: ['relay'], + chains: { + '0xa4b1': ['across'], + }, + tokens: { + '0xa4b1': { + '0xabc': ['relay'], + }, + }, + }, + }, + }, + strategyOrder: ['relay', 'across'], + }; + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0xA4B1', + tokenAddress: '0xAbC', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Relay]); + + expect( + getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + chainId: '0xA4B1', + tokenAddress: '0xDef', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + }); + + it('falls back when overridden strategies are disabled', () => { + expect( + getStrategyOrderForRouteFromFeatureFlags( + { + payStrategies: { + across: { enabled: false }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + chains: { + '0xa4b1': ['across'], + }, + default: ['relay'], + }, + }, + }, + strategyOrder: ['across', 'relay'], + }, + { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }, + ), + ).toStrictEqual([TransactionPayStrategy.Relay]); + }); + + it('ignores empty override entries and falls back to the global order', () => { + expect( + getStrategyOrderForRouteFromFeatureFlags( + { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: undefined, + }, + }, + strategyOrder: ['relay'], + }, + { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }, + ), + ).toStrictEqual([TransactionPayStrategy.Relay]); + }); + + it('returns an empty strategy list when no enabled strategies remain', () => { + expect( + getStrategyOrderForRouteFromFeatureFlags( + { + payStrategies: { + across: { enabled: false }, + relay: { enabled: false }, + }, + strategyOrder: ['relay', 'across'], + }, + {}, + ), + ).toStrictEqual([]); + }); + }); + + describe('getStrategyOrderForRoute', () => { + it('falls back to defaults when remote feature flag maps are undefined', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + localOverrides: undefined as never, + remoteFeatureFlags: undefined as never, + }); + + expect(getStrategyOrderForRoute(messenger, {})).toStrictEqual([ + TransactionPayStrategy.Relay, + ]); + }); + + it('applies local overrides from the remote feature flag controller state', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + localOverrides: { + confirmations_pay: { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + chains: { + '0xa4b1': ['across'], + }, + }, + }, + }, + strategyOrder: ['across'], + }, + }, + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { enabled: false }, + relay: { enabled: true }, + }, + routingOverrides: { + overrides: { + perpsDeposit: { + chains: { + '0xa4b1': ['relay'], + }, + }, + }, + }, + strategyOrder: ['relay'], + }, + }, + }); + + expect( + getStrategyOrderForRoute(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index b8528a8904c..bb490247771 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -2,7 +2,6 @@ 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 { @@ -10,6 +9,7 @@ import { RELAY_POLLING_INTERVAL, RELAY_QUOTE_URL, } from '../strategy/relay/constants'; +import type { TransactionPayControllerMessenger } from '../types'; const log = createModuleLogger(projectLogger, 'feature-flags'); @@ -46,12 +46,21 @@ type FeatureFlagsRaw = { max?: number; }; relayQuoteUrl?: string; + routingOverrides?: { + overrides?: Record; + }; slippage?: number; slippageTokens?: Record>; strategyOrder?: string[]; payStrategies?: PayStrategiesConfigRaw; }; +type RoutingOverrideRaw = { + default?: unknown; + chains?: Record; + tokens?: Record>; +}; + export type FeatureFlags = { relayDisabledGasStationChains: Hex[]; relayExecuteUrl: string; @@ -108,7 +117,8 @@ export type PayStrategiesConfig = { export function getStrategyOrder( messenger: TransactionPayControllerMessenger, ): StrategyOrder { - const { strategyOrder: strategyPriority } = getFeatureFlagsRaw(messenger); + const { strategyOrder: strategyPriority } = + getConfirmationsPayFeatureFlags(messenger) as FeatureFlagsRaw; if (!Array.isArray(strategyPriority)) { return [...DEFAULT_STRATEGY_ORDER]; @@ -136,7 +146,9 @@ export function getStrategyOrder( export function getFeatureFlags( messenger: TransactionPayControllerMessenger, ): FeatureFlags { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; const estimate = featureFlags.relayFallbackGas?.estimate ?? DEFAULT_FALLBACK_GAS_ESTIMATE; @@ -178,7 +190,9 @@ export function getFeatureFlags( export function getPayStrategiesConfig( messenger: TransactionPayControllerMessenger, ): PayStrategiesConfig { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; const payStrategies = featureFlags.payStrategies ?? {}; const acrossRaw = payStrategies.across ?? {}; @@ -213,7 +227,9 @@ export function getPayStrategiesConfig( export function isRelayExecuteEnabled( messenger: TransactionPayControllerMessenger, ): boolean { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; return featureFlags.payStrategies?.relay?.executeEnabled ?? false; } @@ -227,7 +243,9 @@ export function isRelayExecuteEnabled( export function getRelayOriginGasOverhead( messenger: TransactionPayControllerMessenger, ): string { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; return ( featureFlags.payStrategies?.relay?.originGasOverhead ?? DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD @@ -244,7 +262,9 @@ export function getRelayOriginGasOverhead( export function getRelayPollingInterval( messenger: TransactionPayControllerMessenger, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; return ( featureFlags.payStrategies?.relay?.pollingInterval ?? RELAY_POLLING_INTERVAL ); @@ -260,7 +280,9 @@ export function getRelayPollingInterval( export function getRelayPollingTimeout( messenger: TransactionPayControllerMessenger, ): number | undefined { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; return featureFlags.payStrategies?.relay?.pollingTimeout; } @@ -287,7 +309,9 @@ export function getGasBuffer( messenger: TransactionPayControllerMessenger, chainId: Hex, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; return ( featureFlags.gasBuffer?.perChainConfig?.[chainId]?.buffer ?? @@ -310,7 +334,9 @@ export function getSlippage( chainId: Hex, tokenAddress: Hex, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const featureFlags = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; const { slippageTokens } = featureFlags; const tokenMap = getCaseInsensitive(slippageTokens, chainId); @@ -402,14 +428,20 @@ export function isEIP7702Chain( } /** - * Get the raw feature flags from the remote feature flag controller. + * Get the raw confirmations_pay feature flags from the remote feature flag + * controller. * * @param messenger - Controller messenger. - * @returns Raw feature flags. + * @returns Raw confirmations_pay feature flags. */ -function getFeatureFlagsRaw( +export function getConfirmationsPayFeatureFlags( messenger: TransactionPayControllerMessenger, -): FeatureFlagsRaw { +): unknown { const state = messenger.call('RemoteFeatureFlagController:getState'); - return (state.remoteFeatureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; + const featureFlags = { + ...(state.remoteFeatureFlags ?? {}), + ...(state.localOverrides ?? {}), + }; + + return (featureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; } diff --git a/packages/transaction-pay-controller/src/utils/strategy-routing.ts b/packages/transaction-pay-controller/src/utils/strategy-routing.ts new file mode 100644 index 00000000000..10295f60833 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/strategy-routing.ts @@ -0,0 +1,247 @@ +import type { Hex } from '@metamask/utils'; +import { uniq } from 'lodash'; + +import { TransactionPayStrategy, isTransactionPayStrategy } from '../constants'; +import type { TransactionPayControllerMessenger } from '../types'; +import { + DEFAULT_STRATEGY_ORDER, + getConfirmationsPayFeatureFlags, +} from './feature-flags'; + +export type TransactionPayRouteContext = { + chainId?: Hex; + tokenAddress?: Hex; + transactionType?: string; +}; + +type RoutingOverrideRaw = { + default?: unknown; + chains?: Record; + tokens?: Record>; +}; + +type RoutingOverride = { + chains: Record; + default?: TransactionPayStrategy[]; + tokens: Record>; +}; + +type RawStrategyRoutingFlags = { + payStrategies?: { + across?: { + enabled?: boolean; + }; + relay?: { + enabled?: boolean; + }; + }; + routingOverrides?: { + overrides?: Record; + }; + strategyOrder?: string[]; +}; + +type StrategyRoutingConfig = { + payStrategies: { + across: { + enabled: boolean; + }; + relay: { + enabled: boolean; + }; + }; + routingOverrides: { + overrides: Record; + }; + strategyOrder: TransactionPayStrategy[]; +}; + +function normalizeHex(value: string | undefined): Hex | undefined { + return value?.toLowerCase() as Hex | undefined; +} + +function normalizeStrategy( + strategy: unknown, +): TransactionPayStrategy | undefined { + if (typeof strategy !== 'string') { + return undefined; + } + + const normalizedStrategy = strategy.toLowerCase() as TransactionPayStrategy; + + return isTransactionPayStrategy(normalizedStrategy) + ? normalizedStrategy + : undefined; +} + +function normalizeStrategyList(strategies: unknown): TransactionPayStrategy[] { + if (!Array.isArray(strategies)) { + return []; + } + + return uniq( + strategies + .map((strategy) => normalizeStrategy(strategy)) + .filter( + (strategy): strategy is TransactionPayStrategy => + strategy !== undefined, + ), + ); +} + +function normalizeRoutingOverride( + override: RoutingOverrideRaw | undefined, +): RoutingOverride { + const chains = Object.entries(override?.chains ?? {}).reduce< + Record + >((result, [chainId, strategies]) => { + const normalizedStrategies = normalizeStrategyList(strategies); + + if (normalizedStrategies.length) { + result[normalizeHex(chainId) as Hex] = normalizedStrategies; + } + + return result; + }, {}); + + const tokens = Object.entries(override?.tokens ?? {}).reduce< + Record> + >((result, [chainId, tokenOverrides]) => { + const normalizedTokenOverrides = Object.entries( + tokenOverrides ?? {}, + ).reduce>( + (tokenResult, [tokenAddress, strategies]) => { + const normalizedStrategies = normalizeStrategyList(strategies); + + if (normalizedStrategies.length) { + tokenResult[normalizeHex(tokenAddress) as Hex] = normalizedStrategies; + } + + return tokenResult; + }, + {}, + ); + + if (Object.keys(normalizedTokenOverrides).length) { + result[normalizeHex(chainId) as Hex] = normalizedTokenOverrides; + } + + return result; + }, {}); + + const defaultStrategies = normalizeStrategyList(override?.default); + + return { + chains, + default: defaultStrategies.length ? defaultStrategies : undefined, + tokens, + }; +} + +function normalizeStrategyRoutingConfig( + rawFeatureFlags: unknown, +): StrategyRoutingConfig { + const featureFlags = (rawFeatureFlags ?? {}) as RawStrategyRoutingFlags; + const strategyOrder = normalizeStrategyList(featureFlags.strategyOrder); + + return { + payStrategies: { + across: { + enabled: featureFlags.payStrategies?.across?.enabled ?? false, + }, + relay: { + enabled: featureFlags.payStrategies?.relay?.enabled ?? true, + }, + }, + routingOverrides: { + overrides: Object.entries( + featureFlags.routingOverrides?.overrides ?? {}, + ).reduce>((result, [type, override]) => { + result[type] = normalizeRoutingOverride(override); + return result; + }, {}), + }, + strategyOrder: + strategyOrder.length > 0 ? strategyOrder : [...DEFAULT_STRATEGY_ORDER], + }; +} + +function filterEnabledStrategies( + strategies: readonly TransactionPayStrategy[] | undefined, + routingConfig: StrategyRoutingConfig, +): TransactionPayStrategy[] { + if (!strategies?.length) { + return []; + } + + return strategies.filter( + (strategy) => + (strategy === TransactionPayStrategy.Across && + routingConfig.payStrategies.across.enabled) || + (strategy === TransactionPayStrategy.Relay && + routingConfig.payStrategies.relay.enabled), + ); +} + +/** + * Get ordered strategies for a route using generic confirmations_pay routing + * flags. + * + * @param messenger - Controller messenger. + * @param routeContext - Route context used to match transaction type, chain, + * and token overrides. + * @returns Ordered strategy list for the route. + */ +export function getStrategyOrderForRoute( + messenger: TransactionPayControllerMessenger, + routeContext: TransactionPayRouteContext, +): TransactionPayStrategy[] { + return getStrategyOrderForRouteFromFeatureFlags( + getConfirmationsPayFeatureFlags(messenger), + routeContext, + ); +} + +/** + * Resolve ordered strategies for a route from raw confirmations_pay feature + * flags. + * + * @param rawFeatureFlags - Raw confirmations_pay flag block. + * @param routeContext - Route context used to match transaction type, chain, + * and token overrides. + * @returns Ordered strategy list for the route. + */ +export function getStrategyOrderForRouteFromFeatureFlags( + rawFeatureFlags: unknown, + routeContext: TransactionPayRouteContext, +): TransactionPayStrategy[] { + const routingConfig = normalizeStrategyRoutingConfig(rawFeatureFlags); + const { chainId, tokenAddress, transactionType } = routeContext; + const normalizedChainId = normalizeHex(chainId); + const normalizedTokenAddress = normalizeHex(tokenAddress); + const override = transactionType + ? routingConfig.routingOverrides.overrides[transactionType] + : undefined; + + const candidates: (readonly TransactionPayStrategy[] | undefined)[] = [ + normalizedChainId && normalizedTokenAddress + ? override?.tokens[normalizedChainId]?.[normalizedTokenAddress] + : undefined, + normalizedChainId ? override?.chains[normalizedChainId] : undefined, + override?.default, + routingConfig.strategyOrder, + ]; + + for (const strategies of candidates) { + const resolvedStrategies = filterEnabledStrategies( + strategies, + routingConfig, + ); + + if (resolvedStrategies.length) { + return resolvedStrategies; + } + } + + return []; +} From 8aa9b2dc251326697567bb2b252418eb7001f102 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 25 Mar 2026 14:54:02 +0000 Subject: [PATCH 2/4] refactor(transaction-pay-controller): isolate route strategy helper --- .../src/TransactionPayController.test.ts | 4 ++-- .../src/TransactionPayController.ts | 4 ++-- .../src/utils/feature-flags.test.ts | 6 +++--- .../src/utils/feature-flags.ts | 14 +++----------- .../src/utils/strategy-routing.ts | 4 ++-- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 22e5ed78c00..eac15d69dbc 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -13,10 +13,10 @@ import type { TransactionPaySourceAmount, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; -import type { TransactionPayRouteContext } from './utils/strategy-routing'; -import { getStrategyOrderForRoute } from './utils/strategy-routing'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; +import { getStrategyOrderForRoute } from './utils/strategy-routing'; +import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { pollTransactionChanges } from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 6593eee2904..522a6883a01 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -23,10 +23,10 @@ import type { UpdatePaymentTokenRequest, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; -import { getStrategyOrderForRoute } from './utils/strategy-routing'; -import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; +import { getStrategyOrderForRoute } from './utils/strategy-routing'; +import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ 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 af7825fa211..0fdd7b0aa1e 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -23,13 +23,13 @@ import { 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'; import { getStrategyOrderForRoute, getStrategyOrderForRouteFromFeatureFlags, } from './strategy-routing'; +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; const GAS_FALLBACK_MAX_MOCK = 456; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index bb490247771..dcfdd4f9c16 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -46,21 +46,12 @@ type FeatureFlagsRaw = { max?: number; }; relayQuoteUrl?: string; - routingOverrides?: { - overrides?: Record; - }; slippage?: number; slippageTokens?: Record>; strategyOrder?: string[]; payStrategies?: PayStrategiesConfigRaw; }; -type RoutingOverrideRaw = { - default?: unknown; - chains?: Record; - tokens?: Record>; -}; - export type FeatureFlags = { relayDisabledGasStationChains: Hex[]; relayExecuteUrl: string; @@ -117,8 +108,9 @@ export type PayStrategiesConfig = { export function getStrategyOrder( messenger: TransactionPayControllerMessenger, ): StrategyOrder { - const { strategyOrder: strategyPriority } = - getConfirmationsPayFeatureFlags(messenger) as FeatureFlagsRaw; + const { strategyOrder: strategyPriority } = getConfirmationsPayFeatureFlags( + messenger, + ) as FeatureFlagsRaw; if (!Array.isArray(strategyPriority)) { return [...DEFAULT_STRATEGY_ORDER]; diff --git a/packages/transaction-pay-controller/src/utils/strategy-routing.ts b/packages/transaction-pay-controller/src/utils/strategy-routing.ts index 10295f60833..d08f51a10f5 100644 --- a/packages/transaction-pay-controller/src/utils/strategy-routing.ts +++ b/packages/transaction-pay-controller/src/utils/strategy-routing.ts @@ -1,12 +1,12 @@ import type { Hex } from '@metamask/utils'; import { uniq } from 'lodash'; -import { TransactionPayStrategy, isTransactionPayStrategy } from '../constants'; -import type { TransactionPayControllerMessenger } from '../types'; import { DEFAULT_STRATEGY_ORDER, getConfirmationsPayFeatureFlags, } from './feature-flags'; +import { TransactionPayStrategy, isTransactionPayStrategy } from '../constants'; +import type { TransactionPayControllerMessenger } from '../types'; export type TransactionPayRouteContext = { chainId?: Hex; From 7570b0503f910a808599d7f57712bc36b2b88d51 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 26 Mar 2026 13:50:22 +0000 Subject: [PATCH 3/4] Simplify transaction pay route strategy resolution --- .../transaction-pay-controller/CHANGELOG.md | 5 +- .../src/TransactionPayController.test.ts | 116 ------ .../src/TransactionPayController.ts | 19 - .../transaction-pay-controller/src/index.ts | 5 +- .../transaction-pay-controller/src/types.ts | 9 - .../utils/confirmations-pay-feature-flags.ts | 22 + .../src/utils/feature-flags.test.ts | 386 +++++++++++++----- .../src/utils/feature-flags.ts | 61 ++- .../src/utils/strategy-routing.ts | 151 ++++--- 9 files changed, 440 insertions(+), 334 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 32988b9c0a4..477f6a94e9f 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] +### Changed + +- Add route-based `confirmations_pay` strategy resolution ([#8282](https://github.com/MetaMask/core/pull/8282)) + ## [19.0.0] ### Added @@ -41,7 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `FiatStrategy` to retrieve quotes via `RampsController` ([#8121](https://github.com/MetaMask/core/pull/8121)) -- Add route-based `confirmations_pay` strategy resolution via the `getStrategyRouteContext` controller option and `getStrategyOrderForRouteFromFeatureFlags` helper ([#8282](https://github.com/MetaMask/core/pull/8282)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index eac15d69dbc..f19bac3064e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -15,8 +15,6 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { getStrategyOrderForRoute } from './utils/strategy-routing'; -import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { pollTransactionChanges } from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); @@ -25,7 +23,6 @@ jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); jest.mock('./utils/feature-flags'); -jest.mock('./utils/strategy-routing'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -39,7 +36,6 @@ describe('TransactionPayController', () => { const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); - const getStrategyOrderForRouteMock = jest.mocked(getStrategyOrderForRoute); let messenger: TransactionPayControllerMessenger; /** @@ -59,9 +55,6 @@ describe('TransactionPayController', () => { messenger = getMessengerMock({ skipRegister: true }).messenger; getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); - getStrategyOrderForRouteMock.mockReturnValue([ - TransactionPayStrategy.Relay, - ]); updateQuotesMock.mockResolvedValue(true); }); @@ -344,115 +337,6 @@ describe('TransactionPayController', () => { ).toBe(TransactionPayStrategy.Bridge); }); - it('uses route-based feature flag resolution when getStrategyRouteContext is provided', async () => { - const getStrategyRouteContext = jest.fn(() => ({ - chainId: '0xa4b1' as Hex, - tokenAddress: '0xabc' as Hex, - transactionType: 'perpsDeposit', - })); - - getStrategyOrderForRouteMock.mockReturnValue([ - TransactionPayStrategy.Across, - ]); - - new TransactionPayController({ - getDelegationTransaction: jest.fn(), - getStrategyRouteContext, - messenger, - }); - - expect( - messenger.call( - 'TransactionPayController:getStrategy', - TRANSACTION_META_MOCK, - ), - ).toBe(TransactionPayStrategy.Across); - expect(getStrategyRouteContext).toHaveBeenCalledWith( - TRANSACTION_META_MOCK, - ); - expect(getStrategyOrderForRouteMock).toHaveBeenCalledWith(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }); - }); - - it('falls back to default strategy order when route-based resolution returns no strategies', async () => { - getStrategyOrderForRouteMock.mockReturnValue([]); - getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); - - new TransactionPayController({ - getDelegationTransaction: jest.fn(), - getStrategyRouteContext: (): TransactionPayRouteContext => ({ - chainId: '0xa4b1' as Hex, - tokenAddress: '0xabc' as Hex, - transactionType: 'perpsDeposit', - }), - messenger, - }); - - expect( - messenger.call( - 'TransactionPayController:getStrategy', - TRANSACTION_META_MOCK, - ), - ).toBe(TransactionPayStrategy.Relay); - expect(getStrategyOrderForRouteMock).toHaveBeenCalledWith(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }); - expect(getStrategyOrderMock).toHaveBeenCalledWith(messenger); - }); - - it('falls back to default strategy order for quote refresh when route-based resolution returns no strategies', async () => { - getStrategyOrderForRouteMock.mockReturnValue([]); - getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); - - const controller = new TransactionPayController({ - getDelegationTransaction: jest.fn(), - getStrategyRouteContext: (): TransactionPayRouteContext => ({ - chainId: '0xa4b1' as Hex, - tokenAddress: '0xabc' as Hex, - transactionType: 'perpsDeposit', - }), - messenger, - }); - - controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { - config.isPostQuote = true; - }); - - const { getStrategies } = updateQuotesMock.mock.calls[0][0]; - - expect(getStrategies(TRANSACTION_META_MOCK)).toStrictEqual([ - TransactionPayStrategy.Relay, - ]); - }); - - it('prefers explicit getStrategies values over route-based feature flag resolution', async () => { - new TransactionPayController({ - getDelegationTransaction: jest.fn(), - getStrategies: (): TransactionPayStrategy[] => [ - TransactionPayStrategy.Test, - ], - getStrategyRouteContext: (): TransactionPayRouteContext => ({ - chainId: '0xa4b1' as Hex, - tokenAddress: '0xabc' as Hex, - transactionType: 'perpsDeposit', - }), - messenger, - }); - - expect( - messenger.call( - 'TransactionPayController:getStrategy', - TRANSACTION_META_MOCK, - ), - ).toBe(TransactionPayStrategy.Test); - expect(getStrategyOrderForRouteMock).not.toHaveBeenCalled(); - }); - it('returns default strategy order when no callbacks and no strategy order feature flag', async () => { getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 522a6883a01..8ba14b27b7f 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -25,8 +25,6 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { getStrategyOrderForRoute } from './utils/strategy-routing'; -import type { TransactionPayRouteContext } from './utils/strategy-routing'; import { pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ @@ -65,15 +63,10 @@ export class TransactionPayController extends BaseController< transaction: TransactionMeta, ) => TransactionPayStrategy[]; - readonly #getStrategyRouteContext?: ( - transaction: TransactionMeta, - ) => TransactionPayRouteContext; - constructor({ getDelegationTransaction, getStrategy, getStrategies, - getStrategyRouteContext, messenger, state, }: TransactionPayControllerOptions) { @@ -87,7 +80,6 @@ export class TransactionPayController extends BaseController< this.#getDelegationTransaction = getDelegationTransaction; this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; - this.#getStrategyRouteContext = getStrategyRouteContext; this.messenger.registerMethodActionHandlers( this, @@ -284,17 +276,6 @@ export class TransactionPayController extends BaseController< return validStrategies; } - if (this.#getStrategyRouteContext) { - const routeStrategies = getStrategyOrderForRoute( - this.messenger, - this.#getStrategyRouteContext(transaction), - ); - - if (routeStrategies.length) { - return routeStrategies; - } - } - return getStrategyOrder(this.messenger); } } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index cf107390e64..cde0c26469d 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -30,7 +30,4 @@ export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; export type { TransactionPayRouteContext } from './utils/strategy-routing'; -export { - getStrategyOrderForRoute, - getStrategyOrderForRouteFromFeatureFlags, -} from './utils/strategy-routing'; +export { getStrategiesForRoute } from './utils/strategy-routing'; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 677e861ac69..320f07327dc 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -39,7 +39,6 @@ import type { Draft } from 'immer'; import type { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types'; -import type { TransactionPayRouteContext } from './utils/strategy-routing'; export type AllowedActions = | AccountTrackerControllerGetStateAction @@ -141,14 +140,6 @@ export type TransactionPayControllerOptions = { /** Callback to select ordered PayStrategies for a transaction. */ getStrategies?: (transaction: TransactionMeta) => TransactionPayStrategy[]; - /** - * Callback to derive route context for generic feature-flag based strategy selection. - * Used when no custom strategy callback returns a valid ordered strategy list. - */ - getStrategyRouteContext?: ( - transaction: TransactionMeta, - ) => TransactionPayRouteContext; - /** Controller messenger. */ messenger: TransactionPayControllerMessenger; diff --git a/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts b/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts new file mode 100644 index 00000000000..15e423bfa18 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts @@ -0,0 +1,22 @@ +import type { TransactionPayControllerMessenger } from '../types'; + +/** + * Get the merged confirmations_pay feature flags from the remote feature flag + * controller state. + * + * Local overrides take precedence over remote feature flags. + * + * @param messenger - Controller messenger. + * @returns Merged confirmations_pay feature flags. + */ +export function getConfirmationsPayFeatureFlags( + messenger: TransactionPayControllerMessenger, +): unknown { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = { + ...(state.remoteFeatureFlags ?? {}), + ...(state.localOverrides ?? {}), + }; + + return featureFlags.confirmations_pay; +} 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 0fdd7b0aa1e..7220c6699a4 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -23,10 +23,9 @@ import { getSlippage, getStrategyOrder, } from './feature-flags'; -import { - getStrategyOrderForRoute, - getStrategyOrderForRouteFromFeatureFlags, -} from './strategy-routing'; +import * as featureFlagsModule from './feature-flags'; +import * as strategyRoutingModule from './strategy-routing'; +import { getStrategiesForRoute } from './strategy-routing'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; @@ -56,6 +55,26 @@ describe('Feature Flags Utils', () => { }); }); + describe('module surface', () => { + it('does not expose raw confirmations_pay feature flags', () => { + expect(featureFlagsModule).not.toHaveProperty( + 'getConfirmationsPayFeatureFlags', + ); + }); + + it('does not expose route resolution from raw feature flags', () => { + expect(strategyRoutingModule).not.toHaveProperty( + 'getStrategyOrderForRouteFromFeatureFlags', + ); + }); + + it('does not expose the old route helper name', () => { + expect(strategyRoutingModule).not.toHaveProperty( + 'getStrategyOrderForRoute', + ); + }); + }); + describe('getFeatureFlags', () => { it('returns default feature flags when none are set', () => { const featureFlags = getFeatureFlags(messenger); @@ -798,26 +817,59 @@ describe('Feature Flags Utils', () => { TransactionPayStrategy.Across, ]); }); + + it('supports undefined remote feature flags when local overrides provide strategy order', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + localOverrides: { + confirmations_pay: { + strategyOrder: [TransactionPayStrategy.Across], + }, + }, + remoteFeatureFlags: undefined as never, + }); + + expect(getStrategyOrder(messenger)).toStrictEqual([ + TransactionPayStrategy.Across, + ]); + }); + + it('supports undefined local overrides when remote feature flags provide strategy order', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + localOverrides: undefined as never, + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: [TransactionPayStrategy.Relay], + }, + }, + }); + + expect(getStrategyOrder(messenger)).toStrictEqual([ + TransactionPayStrategy.Relay, + ]); + }); }); - describe('getStrategyOrderForRouteFromFeatureFlags', () => { - it('uses default routing config when raw feature flags are undefined', () => { - expect( - getStrategyOrderForRouteFromFeatureFlags(undefined, {}), - ).toStrictEqual([TransactionPayStrategy.Relay]); + describe('getStrategiesForRoute routing resolution', () => { + it('uses default routing config when confirmations_pay flags are absent', () => { + expect(getStrategiesForRoute(messenger, {})).toStrictEqual([ + TransactionPayStrategy.Relay, + ]); }); - it('filters invalid routing config and dedupes strategies', () => { - expect( - getStrategyOrderForRouteFromFeatureFlags( - { + it('filters invalid strategy override config and dedupes strategies', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { strategyOrder: [123, 'relay', 'relay'], payStrategies: { across: { enabled: true }, relay: { enabled: false }, }, - routingOverrides: { - overrides: { + strategyOverrides: { + transactionTypes: { perpsDeposit: { default: [123, 'invalid'], chains: { @@ -835,41 +887,60 @@ describe('Feature Flags Utils', () => { }, }, }, - { - chainId: '0xa4b2', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }, - ), + }, + }); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0xa4b2', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }), ).toStrictEqual([TransactionPayStrategy.Across]); }); - it('resolves routing overrides in token, chain, default, global order precedence', () => { - const featureFlags = { - payStrategies: { - across: { enabled: true }, - relay: { enabled: true }, - }, - routingOverrides: { - overrides: { - perpsDeposit: { - default: ['relay'], - chains: { - '0xa4b1': ['across'], + it('resolves strategy overrides in transaction-type token, chain, global token, global chain, transaction-type default, global default precedence', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + strategyOverrides: { + default: { + default: ['across'], + chains: { + '0x89': ['across'], + }, + tokens: { + '0x1': { + '0xdef': ['relay', 'across'], + }, + }, }, - tokens: { - '0xa4b1': { - '0xabc': ['relay', 'across'], + transactionTypes: { + perpsDeposit: { + default: ['relay'], + chains: { + '0xa4b1': ['across'], + }, + tokens: { + '0xa4b1': { + '0xabc': ['relay', 'across'], + }, + }, }, }, }, + strategyOrder: ['relay', 'across'], }, }, - strategyOrder: ['relay', 'across'], - }; + }); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0xa4b1', tokenAddress: '0xabc', transactionType: 'perpsDeposit', @@ -880,7 +951,7 @@ describe('Feature Flags Utils', () => { ]); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0xa4b1', tokenAddress: '0xdef', transactionType: 'perpsDeposit', @@ -888,15 +959,34 @@ describe('Feature Flags Utils', () => { ).toStrictEqual([TransactionPayStrategy.Across]); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0x1', tokenAddress: '0xdef', transactionType: 'perpsDeposit', }), + ).toStrictEqual([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0x89', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0x2', + tokenAddress: '0xdef', + transactionType: 'perpsDeposit', + }), ).toStrictEqual([TransactionPayStrategy.Relay]); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0x1', tokenAddress: '0xdef', }), @@ -904,34 +994,126 @@ describe('Feature Flags Utils', () => { TransactionPayStrategy.Relay, TransactionPayStrategy.Across, ]); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0x2', + tokenAddress: '0xabc', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); }); - it('matches mixed-case route context hex values against normalized overrides', () => { - const featureFlags = { - payStrategies: { - across: { enabled: true }, - relay: { enabled: true }, + it('uses default override scope when no transaction-type-specific override matches', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + strategyOverrides: { + default: { + chains: { + '0xa4b1': ['across'], + }, + }, + transactionTypes: { + perpsDeposit: { + default: ['relay'], + }, + }, + }, + strategyOrder: ['relay', 'across'], + }, }, - routingOverrides: { - overrides: { - perpsDeposit: { - default: ['relay'], - chains: { - '0xa4b1': ['across'], + }); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0x1', + tokenAddress: '0xabc', + transactionType: 'unknownType', + }), + ).toStrictEqual([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + }); + + it('lets blanket global chain overrides beat transaction-type defaults', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + strategyOverrides: { + default: { + chains: { + '0xa4b1': ['across'], + }, + }, + transactionTypes: { + perpsDeposit: { + default: ['relay'], + }, }, - tokens: { - '0xa4b1': { - '0xabc': ['relay'], + }, + strategyOrder: ['relay'], + }, + }, + }); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([TransactionPayStrategy.Across]); + }); + + it('matches mixed-case route context hex values against normalized overrides', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { enabled: true }, + relay: { enabled: true }, + }, + strategyOverrides: { + transactionTypes: { + perpsDeposit: { + default: ['relay'], + chains: { + '0xa4b1': ['across'], + }, + tokens: { + '0xa4b1': { + '0xabc': ['relay'], + }, + }, }, }, }, + strategyOrder: ['relay', 'across'], }, }, - strategyOrder: ['relay', 'across'], - }; + }); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0xA4B1', tokenAddress: '0xAbC', transactionType: 'perpsDeposit', @@ -939,7 +1121,7 @@ describe('Feature Flags Utils', () => { ).toStrictEqual([TransactionPayStrategy.Relay]); expect( - getStrategyOrderForRouteFromFeatureFlags(featureFlags, { + getStrategiesForRoute(messenger, { chainId: '0xA4B1', tokenAddress: '0xDef', transactionType: 'perpsDeposit', @@ -947,16 +1129,17 @@ describe('Feature Flags Utils', () => { ).toStrictEqual([TransactionPayStrategy.Across]); }); - it('falls back when overridden strategies are disabled', () => { - expect( - getStrategyOrderForRouteFromFeatureFlags( - { + it('does not fall back when a matched override resolves only to disabled strategies', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { payStrategies: { across: { enabled: false }, relay: { enabled: true }, }, - routingOverrides: { - overrides: { + strategyOverrides: { + transactionTypes: { perpsDeposit: { chains: { '0xa4b1': ['across'], @@ -967,56 +1150,65 @@ describe('Feature Flags Utils', () => { }, strategyOrder: ['across', 'relay'], }, - { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }, - ), - ).toStrictEqual([TransactionPayStrategy.Relay]); + }, + }); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }), + ).toStrictEqual([]); }); it('ignores empty override entries and falls back to the global order', () => { - expect( - getStrategyOrderForRouteFromFeatureFlags( - { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { payStrategies: { across: { enabled: true }, relay: { enabled: true }, }, - routingOverrides: { - overrides: { + strategyOverrides: { + transactionTypes: { perpsDeposit: undefined, }, }, strategyOrder: ['relay'], }, - { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }, - ), + }, + }); + + expect( + getStrategiesForRoute(messenger, { + chainId: '0xa4b1', + tokenAddress: '0xabc', + transactionType: 'perpsDeposit', + }), ).toStrictEqual([TransactionPayStrategy.Relay]); }); it('returns an empty strategy list when no enabled strategies remain', () => { - expect( - getStrategyOrderForRouteFromFeatureFlags( - { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { payStrategies: { across: { enabled: false }, relay: { enabled: false }, }, strategyOrder: ['relay', 'across'], }, - {}, - ), - ).toStrictEqual([]); + }, + }); + + expect(getStrategiesForRoute(messenger, {})).toStrictEqual([]); }); }); - describe('getStrategyOrderForRoute', () => { + describe('getStrategiesForRoute', () => { it('falls back to defaults when remote feature flag maps are undefined', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), @@ -1024,7 +1216,7 @@ describe('Feature Flags Utils', () => { remoteFeatureFlags: undefined as never, }); - expect(getStrategyOrderForRoute(messenger, {})).toStrictEqual([ + expect(getStrategiesForRoute(messenger, {})).toStrictEqual([ TransactionPayStrategy.Relay, ]); }); @@ -1038,8 +1230,8 @@ describe('Feature Flags Utils', () => { across: { enabled: true }, relay: { enabled: true }, }, - routingOverrides: { - overrides: { + strategyOverrides: { + transactionTypes: { perpsDeposit: { chains: { '0xa4b1': ['across'], @@ -1056,8 +1248,8 @@ describe('Feature Flags Utils', () => { across: { enabled: false }, relay: { enabled: true }, }, - routingOverrides: { - overrides: { + strategyOverrides: { + transactionTypes: { perpsDeposit: { chains: { '0xa4b1': ['relay'], @@ -1071,7 +1263,7 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategyOrderForRoute(messenger, { + getStrategiesForRoute(messenger, { chainId: '0xa4b1', tokenAddress: '0xabc', transactionType: 'perpsDeposit', diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index dcfdd4f9c16..bef8c0933cc 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -2,6 +2,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { uniq } from 'lodash'; +import { getConfirmationsPayFeatureFlags } from './confirmations-pay-feature-flags'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import { @@ -49,9 +50,21 @@ type FeatureFlagsRaw = { slippage?: number; slippageTokens?: Record>; strategyOrder?: string[]; + strategyOverrides?: StrategyOverridesRaw; payStrategies?: PayStrategiesConfigRaw; }; +type StrategyOverrideRaw = { + default?: unknown; + chains?: Record; + tokens?: Record>; +}; + +type StrategyOverridesRaw = { + default?: StrategyOverrideRaw; + transactionTypes?: Record; +}; + export type FeatureFlags = { relayDisabledGasStationChains: Hex[]; relayExecuteUrl: string; @@ -108,9 +121,7 @@ export type PayStrategiesConfig = { export function getStrategyOrder( messenger: TransactionPayControllerMessenger, ): StrategyOrder { - const { strategyOrder: strategyPriority } = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const { strategyOrder: strategyPriority } = getFeatureFlagsRaw(messenger); if (!Array.isArray(strategyPriority)) { return [...DEFAULT_STRATEGY_ORDER]; @@ -138,9 +149,7 @@ export function getStrategyOrder( export function getFeatureFlags( messenger: TransactionPayControllerMessenger, ): FeatureFlags { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); const estimate = featureFlags.relayFallbackGas?.estimate ?? DEFAULT_FALLBACK_GAS_ESTIMATE; @@ -182,9 +191,7 @@ export function getFeatureFlags( export function getPayStrategiesConfig( messenger: TransactionPayControllerMessenger, ): PayStrategiesConfig { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); const payStrategies = featureFlags.payStrategies ?? {}; const acrossRaw = payStrategies.across ?? {}; @@ -219,9 +226,7 @@ export function getPayStrategiesConfig( export function isRelayExecuteEnabled( messenger: TransactionPayControllerMessenger, ): boolean { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); return featureFlags.payStrategies?.relay?.executeEnabled ?? false; } @@ -235,9 +240,7 @@ export function isRelayExecuteEnabled( export function getRelayOriginGasOverhead( messenger: TransactionPayControllerMessenger, ): string { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); return ( featureFlags.payStrategies?.relay?.originGasOverhead ?? DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD @@ -254,9 +257,7 @@ export function getRelayOriginGasOverhead( export function getRelayPollingInterval( messenger: TransactionPayControllerMessenger, ): number { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); return ( featureFlags.payStrategies?.relay?.pollingInterval ?? RELAY_POLLING_INTERVAL ); @@ -272,9 +273,7 @@ export function getRelayPollingInterval( export function getRelayPollingTimeout( messenger: TransactionPayControllerMessenger, ): number | undefined { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); return featureFlags.payStrategies?.relay?.pollingTimeout; } @@ -301,9 +300,7 @@ export function getGasBuffer( messenger: TransactionPayControllerMessenger, chainId: Hex, ): number { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); return ( featureFlags.gasBuffer?.perChainConfig?.[chainId]?.buffer ?? @@ -326,9 +323,7 @@ export function getSlippage( chainId: Hex, tokenAddress: Hex, ): number { - const featureFlags = getConfirmationsPayFeatureFlags( - messenger, - ) as FeatureFlagsRaw; + const featureFlags = getFeatureFlagsRaw(messenger); const { slippageTokens } = featureFlags; const tokenMap = getCaseInsensitive(slippageTokens, chainId); @@ -426,14 +421,8 @@ export function isEIP7702Chain( * @param messenger - Controller messenger. * @returns Raw confirmations_pay feature flags. */ -export function getConfirmationsPayFeatureFlags( +function getFeatureFlagsRaw( messenger: TransactionPayControllerMessenger, -): unknown { - const state = messenger.call('RemoteFeatureFlagController:getState'); - const featureFlags = { - ...(state.remoteFeatureFlags ?? {}), - ...(state.localOverrides ?? {}), - }; - - return (featureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; +): FeatureFlagsRaw { + return (getConfirmationsPayFeatureFlags(messenger) as FeatureFlagsRaw) ?? {}; } diff --git a/packages/transaction-pay-controller/src/utils/strategy-routing.ts b/packages/transaction-pay-controller/src/utils/strategy-routing.ts index d08f51a10f5..ca6507de7ee 100644 --- a/packages/transaction-pay-controller/src/utils/strategy-routing.ts +++ b/packages/transaction-pay-controller/src/utils/strategy-routing.ts @@ -1,10 +1,8 @@ import type { Hex } from '@metamask/utils'; import { uniq } from 'lodash'; -import { - DEFAULT_STRATEGY_ORDER, - getConfirmationsPayFeatureFlags, -} from './feature-flags'; +import { getConfirmationsPayFeatureFlags } from './confirmations-pay-feature-flags'; +import { DEFAULT_STRATEGY_ORDER } from './feature-flags'; import { TransactionPayStrategy, isTransactionPayStrategy } from '../constants'; import type { TransactionPayControllerMessenger } from '../types'; @@ -14,18 +12,23 @@ export type TransactionPayRouteContext = { transactionType?: string; }; -type RoutingOverrideRaw = { +type StrategyOverrideRaw = { default?: unknown; chains?: Record; tokens?: Record>; }; -type RoutingOverride = { +type StrategyOverride = { chains: Record; default?: TransactionPayStrategy[]; tokens: Record>; }; +type StrategyOverrides = { + default?: StrategyOverride; + transactionTypes: Record; +}; + type RawStrategyRoutingFlags = { payStrategies?: { across?: { @@ -35,8 +38,9 @@ type RawStrategyRoutingFlags = { enabled?: boolean; }; }; - routingOverrides?: { - overrides?: Record; + strategyOverrides?: { + default?: StrategyOverrideRaw; + transactionTypes?: Record; }; strategyOrder?: string[]; }; @@ -50,9 +54,7 @@ type StrategyRoutingConfig = { enabled: boolean; }; }; - routingOverrides: { - overrides: Record; - }; + strategyOverrides: StrategyOverrides; strategyOrder: TransactionPayStrategy[]; }; @@ -89,9 +91,9 @@ function normalizeStrategyList(strategies: unknown): TransactionPayStrategy[] { ); } -function normalizeRoutingOverride( - override: RoutingOverrideRaw | undefined, -): RoutingOverride { +function normalizeStrategyOverride( + override: StrategyOverrideRaw | undefined, +): StrategyOverride { const chains = Object.entries(override?.chains ?? {}).reduce< Record >((result, [chainId, strategies]) => { @@ -153,11 +155,14 @@ function normalizeStrategyRoutingConfig( enabled: featureFlags.payStrategies?.relay?.enabled ?? true, }, }, - routingOverrides: { - overrides: Object.entries( - featureFlags.routingOverrides?.overrides ?? {}, - ).reduce>((result, [type, override]) => { - result[type] = normalizeRoutingOverride(override); + strategyOverrides: { + default: featureFlags.strategyOverrides?.default + ? normalizeStrategyOverride(featureFlags.strategyOverrides.default) + : undefined, + transactionTypes: Object.entries( + featureFlags.strategyOverrides?.transactionTypes ?? {}, + ).reduce>((result, [type, override]) => { + result[type] = normalizeStrategyOverride(override); return result; }, {}), }, @@ -166,14 +171,18 @@ function normalizeStrategyRoutingConfig( }; } +function getStrategyRoutingConfig( + messenger: TransactionPayControllerMessenger, +): StrategyRoutingConfig { + return normalizeStrategyRoutingConfig( + getConfirmationsPayFeatureFlags(messenger), + ); +} + function filterEnabledStrategies( - strategies: readonly TransactionPayStrategy[] | undefined, + strategies: readonly TransactionPayStrategy[], routingConfig: StrategyRoutingConfig, ): TransactionPayStrategy[] { - if (!strategies?.length) { - return []; - } - return strategies.filter( (strategy) => (strategy === TransactionPayStrategy.Across && @@ -183,6 +192,35 @@ function filterEnabledStrategies( ); } +function getTokenOverrideStrategies( + override: StrategyOverride | undefined, + normalizedChainId: Hex | undefined, + normalizedTokenAddress: Hex | undefined, +): readonly TransactionPayStrategy[] | undefined { + if (!override || !normalizedChainId || !normalizedTokenAddress) { + return undefined; + } + + return override.tokens[normalizedChainId]?.[normalizedTokenAddress]; +} + +function getChainOverrideStrategies( + override: StrategyOverride | undefined, + normalizedChainId: Hex | undefined, +): readonly TransactionPayStrategy[] | undefined { + if (!override || !normalizedChainId) { + return undefined; + } + + return override.chains[normalizedChainId]; +} + +function getDefaultOverrideStrategies( + override: StrategyOverride | undefined, +): readonly TransactionPayStrategy[] | undefined { + return override?.default; +} + /** * Get ordered strategies for a route using generic confirmations_pay routing * flags. @@ -192,56 +230,65 @@ function filterEnabledStrategies( * and token overrides. * @returns Ordered strategy list for the route. */ -export function getStrategyOrderForRoute( +export function getStrategiesForRoute( messenger: TransactionPayControllerMessenger, routeContext: TransactionPayRouteContext, ): TransactionPayStrategy[] { - return getStrategyOrderForRouteFromFeatureFlags( - getConfirmationsPayFeatureFlags(messenger), + return resolveStrategyOrderForRoute( + getStrategyRoutingConfig(messenger), routeContext, ); } /** - * Resolve ordered strategies for a route from raw confirmations_pay feature - * flags. + * Resolve ordered strategies for a route from the normalized strategy routing + * config. * - * @param rawFeatureFlags - Raw confirmations_pay flag block. + * @param routingConfig - Normalized routing config. * @param routeContext - Route context used to match transaction type, chain, * and token overrides. * @returns Ordered strategy list for the route. */ -export function getStrategyOrderForRouteFromFeatureFlags( - rawFeatureFlags: unknown, +function resolveStrategyOrderForRoute( + routingConfig: StrategyRoutingConfig, routeContext: TransactionPayRouteContext, ): TransactionPayStrategy[] { - const routingConfig = normalizeStrategyRoutingConfig(rawFeatureFlags); - const { chainId, tokenAddress, transactionType } = routeContext; - const normalizedChainId = normalizeHex(chainId); - const normalizedTokenAddress = normalizeHex(tokenAddress); - const override = transactionType - ? routingConfig.routingOverrides.overrides[transactionType] + const normalizedChainId = normalizeHex(routeContext.chainId); + const normalizedTokenAddress = normalizeHex(routeContext.tokenAddress); + const transactionTypeOverride = routeContext.transactionType + ? routingConfig.strategyOverrides.transactionTypes[ + routeContext.transactionType + ] : undefined; const candidates: (readonly TransactionPayStrategy[] | undefined)[] = [ - normalizedChainId && normalizedTokenAddress - ? override?.tokens[normalizedChainId]?.[normalizedTokenAddress] - : undefined, - normalizedChainId ? override?.chains[normalizedChainId] : undefined, - override?.default, - routingConfig.strategyOrder, + getTokenOverrideStrategies( + transactionTypeOverride, + normalizedChainId, + normalizedTokenAddress, + ), + getChainOverrideStrategies(transactionTypeOverride, normalizedChainId), + getTokenOverrideStrategies( + routingConfig.strategyOverrides.default, + normalizedChainId, + normalizedTokenAddress, + ), + getChainOverrideStrategies( + routingConfig.strategyOverrides.default, + normalizedChainId, + ), + getDefaultOverrideStrategies(transactionTypeOverride), + getDefaultOverrideStrategies(routingConfig.strategyOverrides.default), ]; + // Overrides are authoritative. Once a route matches a specific override + // scope, disabled strategies do not inherit candidates from lower-precedence + // scopes. for (const strategies of candidates) { - const resolvedStrategies = filterEnabledStrategies( - strategies, - routingConfig, - ); - - if (resolvedStrategies.length) { - return resolvedStrategies; + if (strategies) { + return filterEnabledStrategies(strategies, routingConfig); } } - return []; + return filterEnabledStrategies(routingConfig.strategyOrder, routingConfig); } From 190d2ceb721abba646fdd8b6ba66e7f1d1f37714 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 27 Mar 2026 14:10:11 +0000 Subject: [PATCH 4/4] Address PR comment --- .../src/TransactionPayController.test.ts | 39 ++ .../src/TransactionPayController.ts | 10 +- .../transaction-pay-controller/src/index.ts | 2 - .../utils/confirmations-pay-feature-flags.ts | 22 -- .../src/utils/feature-flags.test.ts | 201 +++-------- .../src/utils/feature-flags.ts | 333 ++++++++++++++++-- .../src/utils/strategy-routing.ts | 294 ---------------- 7 files changed, 392 insertions(+), 509 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts delete mode 100644 packages/transaction-pay-controller/src/utils/strategy-routing.ts diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index f19bac3064e..7f669956e50 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -365,6 +365,45 @@ describe('TransactionPayController', () => { ), ).toBe(TransactionPayStrategy.Test); }); + + it('passes payment token route args into feature flag fallback', async () => { + const controller = createController(); + + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.paymentToken = { + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '1', + balanceHuman: '1', + balanceRaw: '1', + balanceUsd: '1', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'USDC', + }; + }); + + const transactionMeta = { + id: TRANSACTION_ID_MOCK, + type: 'perpsDeposit', + } as TransactionMeta; + + messenger.call('TransactionPayController:getStrategy', transactionMeta); + + expect(getStrategyOrderMock).toHaveBeenCalledWith( + messenger, + CHAIN_ID_MOCK, + TOKEN_ADDRESS_MOCK, + 'perpsDeposit', + ); + }); }); describe('transaction data update', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 8ba14b27b7f..a1e48b3a2ca 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -276,6 +276,14 @@ export class TransactionPayController extends BaseController< return validStrategies; } - return getStrategyOrder(this.messenger); + const paymentToken = + this.state.transactionData[transaction.id]?.paymentToken; + + return getStrategyOrder( + this.messenger, + paymentToken?.chainId, + paymentToken?.address, + transaction.type, + ); } } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index cde0c26469d..53cc04fa203 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -29,5 +29,3 @@ export { TransactionPayStrategy } from './constants'; export { TransactionPayController } from './TransactionPayController'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; export type { TransactionPayBridgeQuote } from './strategy/bridge/types'; -export type { TransactionPayRouteContext } from './utils/strategy-routing'; -export { getStrategiesForRoute } from './utils/strategy-routing'; diff --git a/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts b/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts deleted file mode 100644 index 15e423bfa18..00000000000 --- a/packages/transaction-pay-controller/src/utils/confirmations-pay-feature-flags.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { TransactionPayControllerMessenger } from '../types'; - -/** - * Get the merged confirmations_pay feature flags from the remote feature flag - * controller state. - * - * Local overrides take precedence over remote feature flags. - * - * @param messenger - Controller messenger. - * @returns Merged confirmations_pay feature flags. - */ -export function getConfirmationsPayFeatureFlags( - messenger: TransactionPayControllerMessenger, -): unknown { - const state = messenger.call('RemoteFeatureFlagController:getState'); - const featureFlags = { - ...(state.remoteFeatureFlags ?? {}), - ...(state.localOverrides ?? {}), - }; - - return featureFlags.confirmations_pay; -} 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 7220c6699a4..2d7554743ad 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -8,7 +8,6 @@ import { DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, - DEFAULT_STRATEGY_ORDER, getAssetsUnifyStateFeature, getFallbackGas, DEFAULT_RELAY_EXECUTE_URL, @@ -21,11 +20,10 @@ import { getGasBuffer, getPayStrategiesConfig, getSlippage, + getStrategy, getStrategyOrder, } from './feature-flags'; import * as featureFlagsModule from './feature-flags'; -import * as strategyRoutingModule from './strategy-routing'; -import { getStrategiesForRoute } from './strategy-routing'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; @@ -63,15 +61,13 @@ describe('Feature Flags Utils', () => { }); it('does not expose route resolution from raw feature flags', () => { - expect(strategyRoutingModule).not.toHaveProperty( + expect(featureFlagsModule).not.toHaveProperty( 'getStrategyOrderForRouteFromFeatureFlags', ); }); it('does not expose the old route helper name', () => { - expect(strategyRoutingModule).not.toHaveProperty( - 'getStrategyOrderForRoute', - ); + expect(featureFlagsModule).not.toHaveProperty('getStrategiesForRoute'); }); }); @@ -731,10 +727,10 @@ describe('Feature Flags Utils', () => { }); describe('getStrategyOrder', () => { - it('returns default strategy order when none is set', () => { + it('returns enabled default strategy order when none is set', () => { const strategyOrder = getStrategyOrder(messenger); - expect(strategyOrder).toStrictEqual(DEFAULT_STRATEGY_ORDER); + expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Relay]); }); it('returns strategy order from feature flags', () => { @@ -783,7 +779,7 @@ describe('Feature Flags Utils', () => { ]); }); - it('falls back to default strategy order when all entries are invalid', () => { + it('falls back to the enabled default strategy order when all entries are invalid', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { @@ -795,43 +791,7 @@ describe('Feature Flags Utils', () => { const strategyOrder = getStrategyOrder(messenger); - expect(strategyOrder).toStrictEqual(DEFAULT_STRATEGY_ORDER); - }); - - it('prefers local overrides over remote flags', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - localOverrides: { - confirmations_pay: { - strategyOrder: [TransactionPayStrategy.Across], - }, - }, - remoteFeatureFlags: { - confirmations_pay: { - strategyOrder: [TransactionPayStrategy.Relay], - }, - }, - }); - - expect(getStrategyOrder(messenger)).toStrictEqual([ - TransactionPayStrategy.Across, - ]); - }); - - it('supports undefined remote feature flags when local overrides provide strategy order', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - localOverrides: { - confirmations_pay: { - strategyOrder: [TransactionPayStrategy.Across], - }, - }, - remoteFeatureFlags: undefined as never, - }); - - expect(getStrategyOrder(messenger)).toStrictEqual([ - TransactionPayStrategy.Across, - ]); + expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Relay]); }); it('supports undefined local overrides when remote feature flags provide strategy order', () => { @@ -851,9 +811,9 @@ describe('Feature Flags Utils', () => { }); }); - describe('getStrategiesForRoute routing resolution', () => { + describe('getStrategyOrder route-aware resolution', () => { it('uses default routing config when confirmations_pay flags are absent', () => { - expect(getStrategiesForRoute(messenger, {})).toStrictEqual([ + expect(getStrategyOrder(messenger)).toStrictEqual([ TransactionPayStrategy.Relay, ]); }); @@ -891,11 +851,7 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b2', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b2', '0xdef', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Across]); }); @@ -940,67 +896,39 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b1', '0xabc', 'perpsDeposit'), ).toStrictEqual([ TransactionPayStrategy.Relay, TransactionPayStrategy.Across, ]); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b1', '0xdef', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Across]); expect( - getStrategiesForRoute(messenger, { - chainId: '0x1', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0x1', '0xdef', 'perpsDeposit'), ).toStrictEqual([ TransactionPayStrategy.Relay, TransactionPayStrategy.Across, ]); expect( - getStrategiesForRoute(messenger, { - chainId: '0x89', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0x89', '0xdef', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Across]); expect( - getStrategiesForRoute(messenger, { - chainId: '0x2', - tokenAddress: '0xdef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0x2', '0xdef', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Relay]); - expect( - getStrategiesForRoute(messenger, { - chainId: '0x1', - tokenAddress: '0xdef', - }), - ).toStrictEqual([ + expect(getStrategyOrder(messenger, '0x1', '0xdef')).toStrictEqual([ TransactionPayStrategy.Relay, TransactionPayStrategy.Across, ]); - expect( - getStrategiesForRoute(messenger, { - chainId: '0x2', - tokenAddress: '0xabc', - }), - ).toStrictEqual([TransactionPayStrategy.Across]); + expect(getStrategyOrder(messenger, '0x2', '0xabc')).toStrictEqual([ + TransactionPayStrategy.Across, + ]); }); it('uses default override scope when no transaction-type-specific override matches', () => { @@ -1029,19 +957,12 @@ describe('Feature Flags Utils', () => { }, }); - expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - }), - ).toStrictEqual([TransactionPayStrategy.Across]); + expect(getStrategyOrder(messenger, '0xa4b1', '0xabc')).toStrictEqual([ + TransactionPayStrategy.Across, + ]); expect( - getStrategiesForRoute(messenger, { - chainId: '0x1', - tokenAddress: '0xabc', - transactionType: 'unknownType', - }), + getStrategyOrder(messenger, '0x1', '0xabc', 'unknownType'), ).toStrictEqual([ TransactionPayStrategy.Relay, TransactionPayStrategy.Across, @@ -1075,11 +996,7 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b1', '0xabc', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Across]); }); @@ -1113,19 +1030,11 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xA4B1', - tokenAddress: '0xAbC', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xA4B1', '0xAbC', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Relay]); expect( - getStrategiesForRoute(messenger, { - chainId: '0xA4B1', - tokenAddress: '0xDef', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xA4B1', '0xDef', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Across]); }); @@ -1154,11 +1063,7 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b1', '0xabc', 'perpsDeposit'), ).toStrictEqual([]); }); @@ -1182,11 +1087,7 @@ describe('Feature Flags Utils', () => { }); expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }), + getStrategyOrder(messenger, '0xa4b1', '0xabc', 'perpsDeposit'), ).toStrictEqual([TransactionPayStrategy.Relay]); }); @@ -1204,11 +1105,11 @@ describe('Feature Flags Utils', () => { }, }); - expect(getStrategiesForRoute(messenger, {})).toStrictEqual([]); + expect(getStrategyOrder(messenger)).toStrictEqual([]); }); }); - describe('getStrategiesForRoute', () => { + describe('getStrategyOrder with remote feature flag controller state', () => { it('falls back to defaults when remote feature flag maps are undefined', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), @@ -1216,15 +1117,17 @@ describe('Feature Flags Utils', () => { remoteFeatureFlags: undefined as never, }); - expect(getStrategiesForRoute(messenger, {})).toStrictEqual([ + expect(getStrategyOrder(messenger)).toStrictEqual([ TransactionPayStrategy.Relay, ]); }); + }); - it('applies local overrides from the remote feature flag controller state', () => { + describe('getStrategy', () => { + it('returns the first applicable strategy for a route', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), - localOverrides: { + remoteFeatureFlags: { confirmations_pay: { payStrategies: { across: { enabled: true }, @@ -1234,41 +1137,35 @@ describe('Feature Flags Utils', () => { transactionTypes: { perpsDeposit: { chains: { - '0xa4b1': ['across'], + '0xa4b1': ['across', 'relay'], }, }, }, }, - strategyOrder: ['across'], }, }, + }); + + expect(getStrategy(messenger, '0xa4b1', '0xabc', 'perpsDeposit')).toBe( + TransactionPayStrategy.Across, + ); + }); + + it('returns undefined when no enabled strategy remains', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay: { payStrategies: { across: { enabled: false }, - relay: { enabled: true }, - }, - strategyOverrides: { - transactionTypes: { - perpsDeposit: { - chains: { - '0xa4b1': ['relay'], - }, - }, - }, + relay: { enabled: false }, }, - strategyOrder: ['relay'], + strategyOrder: ['relay', 'across'], }, }, }); - expect( - getStrategiesForRoute(messenger, { - chainId: '0xa4b1', - tokenAddress: '0xabc', - transactionType: 'perpsDeposit', - }), - ).toStrictEqual([TransactionPayStrategy.Across]); + expect(getStrategy(messenger)).toBeUndefined(); }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index bef8c0933cc..0ae78aadfa8 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -2,7 +2,6 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { uniq } from 'lodash'; -import { getConfirmationsPayFeatureFlags } from './confirmations-pay-feature-flags'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import { @@ -14,7 +13,7 @@ import type { TransactionPayControllerMessenger } from '../types'; const log = createModuleLogger(projectLogger, 'feature-flags'); -type StrategyOrder = [TransactionPayStrategy, ...TransactionPayStrategy[]]; +type StrategyOrder = TransactionPayStrategy[]; export const DEFAULT_GAS_BUFFER = 1.0; export const DEFAULT_FALLBACK_GAS_ESTIMATE = 900000; @@ -65,6 +64,30 @@ type StrategyOverridesRaw = { transactionTypes?: Record; }; +type StrategyOverride = { + chains: Record; + default?: TransactionPayStrategy[]; + tokens: Record>; +}; + +type StrategyOverrides = { + default?: StrategyOverride; + transactionTypes: Record; +}; + +type StrategyRoutingConfig = { + payStrategies: { + across: { + enabled: boolean; + }; + relay: { + enabled: boolean; + }; + }; + strategyOverrides: StrategyOverrides; + strategyOrder: TransactionPayStrategy[]; +}; + export type FeatureFlags = { relayDisabledGasStationChains: Hex[]; relayExecuteUrl: string; @@ -112,32 +135,247 @@ export type PayStrategiesConfig = { }; }; +function normalizeHex(value: string | undefined): Hex | undefined { + return value?.toLowerCase() as Hex | undefined; +} + +function normalizeStrategy( + strategy: unknown, +): TransactionPayStrategy | undefined { + if (typeof strategy !== 'string') { + return undefined; + } + + const normalizedStrategy = strategy.toLowerCase() as TransactionPayStrategy; + + return isTransactionPayStrategy(normalizedStrategy) + ? normalizedStrategy + : undefined; +} + +function normalizeStrategyList(strategies: unknown): TransactionPayStrategy[] { + if (!Array.isArray(strategies)) { + return []; + } + + return uniq( + strategies + .map((strategy) => normalizeStrategy(strategy)) + .filter( + (strategy): strategy is TransactionPayStrategy => + strategy !== undefined, + ), + ); +} + +function normalizeStrategyOverride( + override: StrategyOverrideRaw | undefined, +): StrategyOverride { + const chains = Object.entries(override?.chains ?? {}).reduce< + Record + >((result, [chainId, strategies]) => { + const normalizedStrategies = normalizeStrategyList(strategies); + + if (normalizedStrategies.length) { + result[normalizeHex(chainId) as Hex] = normalizedStrategies; + } + + return result; + }, {}); + + const tokens = Object.entries(override?.tokens ?? {}).reduce< + Record> + >((result, [chainId, tokenOverrides]) => { + const normalizedTokenOverrides = Object.entries( + tokenOverrides ?? {}, + ).reduce>( + (tokenResult, [tokenAddress, strategies]) => { + const normalizedStrategies = normalizeStrategyList(strategies); + + if (normalizedStrategies.length) { + tokenResult[normalizeHex(tokenAddress) as Hex] = normalizedStrategies; + } + + return tokenResult; + }, + {}, + ); + + if (Object.keys(normalizedTokenOverrides).length) { + result[normalizeHex(chainId) as Hex] = normalizedTokenOverrides; + } + + return result; + }, {}); + + const defaultStrategies = normalizeStrategyList(override?.default); + + return { + chains, + default: defaultStrategies.length ? defaultStrategies : undefined, + tokens, + }; +} + +function normalizeStrategyRoutingConfig( + featureFlags: FeatureFlagsRaw, +): StrategyRoutingConfig { + const strategyOrder = normalizeStrategyList(featureFlags.strategyOrder); + + return { + payStrategies: { + across: { + enabled: featureFlags.payStrategies?.across?.enabled ?? false, + }, + relay: { + enabled: featureFlags.payStrategies?.relay?.enabled ?? true, + }, + }, + strategyOverrides: { + default: featureFlags.strategyOverrides?.default + ? normalizeStrategyOverride(featureFlags.strategyOverrides.default) + : undefined, + transactionTypes: Object.entries( + featureFlags.strategyOverrides?.transactionTypes ?? {}, + ).reduce>((result, [type, override]) => { + result[type] = normalizeStrategyOverride(override); + return result; + }, {}), + }, + strategyOrder: + strategyOrder.length > 0 ? strategyOrder : [...DEFAULT_STRATEGY_ORDER], + }; +} + +function getStrategyRoutingConfig( + messenger: TransactionPayControllerMessenger, +): StrategyRoutingConfig { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined; + + return normalizeStrategyRoutingConfig(featureFlags ?? {}); +} + +function filterEnabledStrategies( + strategies: readonly TransactionPayStrategy[], + routingConfig: StrategyRoutingConfig, +): TransactionPayStrategy[] { + return strategies.filter((strategy) => { + if (strategy === TransactionPayStrategy.Across) { + return routingConfig.payStrategies.across.enabled; + } + + if (strategy === TransactionPayStrategy.Relay) { + return routingConfig.payStrategies.relay.enabled; + } + + return true; + }); +} + +function getTokenOverrideStrategies( + override: StrategyOverride | undefined, + normalizedChainId: Hex | undefined, + normalizedTokenAddress: Hex | undefined, +): readonly TransactionPayStrategy[] | undefined { + if (!override || !normalizedChainId || !normalizedTokenAddress) { + return undefined; + } + + return override.tokens[normalizedChainId]?.[normalizedTokenAddress]; +} + +function getChainOverrideStrategies( + override: StrategyOverride | undefined, + normalizedChainId: Hex | undefined, +): readonly TransactionPayStrategy[] | undefined { + if (!override || !normalizedChainId) { + return undefined; + } + + return override.chains[normalizedChainId]; +} + +function getDefaultOverrideStrategies( + override: StrategyOverride | undefined, +): readonly TransactionPayStrategy[] | undefined { + return override?.default; +} + /** - * Get ordered list of strategies to try. + * Get ordered list of strategies to try for a route. * * @param messenger - Controller messenger. + * @param chainId - Optional chain ID used to match route overrides. + * @param tokenAddress - Optional token address used to match route overrides. + * @param transactionType - Optional transaction type used to match route + * overrides. * @returns Ordered strategy list. */ export function getStrategyOrder( messenger: TransactionPayControllerMessenger, + chainId?: Hex, + tokenAddress?: Hex, + transactionType?: string, ): 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), + const routingConfig = getStrategyRoutingConfig(messenger); + const normalizedChainId = normalizeHex(chainId); + const normalizedTokenAddress = normalizeHex(tokenAddress); + const transactionTypeOverride = transactionType + ? routingConfig.strategyOverrides.transactionTypes[transactionType] + : undefined; + + const candidates: (readonly TransactionPayStrategy[] | undefined)[] = [ + getTokenOverrideStrategies( + transactionTypeOverride, + normalizedChainId, + normalizedTokenAddress, ), - ); - - if (!validStrategyPriority.length) { - return [...DEFAULT_STRATEGY_ORDER]; + getChainOverrideStrategies(transactionTypeOverride, normalizedChainId), + getTokenOverrideStrategies( + routingConfig.strategyOverrides.default, + normalizedChainId, + normalizedTokenAddress, + ), + getChainOverrideStrategies( + routingConfig.strategyOverrides.default, + normalizedChainId, + ), + getDefaultOverrideStrategies(transactionTypeOverride), + getDefaultOverrideStrategies(routingConfig.strategyOverrides.default), + ]; + + // Overrides are authoritative. Once a route matches a specific override + // scope, disabled strategies do not inherit candidates from lower-precedence + // scopes. + for (const strategies of candidates) { + if (strategies) { + return filterEnabledStrategies(strategies, routingConfig); + } } - return validStrategyPriority as StrategyOrder; + return filterEnabledStrategies(routingConfig.strategyOrder, routingConfig); +} + +/** + * Get the preferred strategy for a route. + * + * @param messenger - Controller messenger. + * @param chainId - Optional chain ID used to match route overrides. + * @param tokenAddress - Optional token address used to match route overrides. + * @param transactionType - Optional transaction type used to match route + * overrides. + * @returns The preferred strategy, if any. + */ +export function getStrategy( + messenger: TransactionPayControllerMessenger, + chainId?: Hex, + tokenAddress?: Hex, + transactionType?: string, +): TransactionPayStrategy | undefined { + return getStrategyOrder(messenger, chainId, tokenAddress, transactionType)[0]; } /** @@ -149,7 +387,11 @@ export function getStrategyOrder( export function getFeatureFlags( messenger: TransactionPayControllerMessenger, ): FeatureFlags { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; const estimate = featureFlags.relayFallbackGas?.estimate ?? DEFAULT_FALLBACK_GAS_ESTIMATE; @@ -191,7 +433,11 @@ export function getFeatureFlags( export function getPayStrategiesConfig( messenger: TransactionPayControllerMessenger, ): PayStrategiesConfig { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; const payStrategies = featureFlags.payStrategies ?? {}; const acrossRaw = payStrategies.across ?? {}; @@ -226,7 +472,11 @@ export function getPayStrategiesConfig( export function isRelayExecuteEnabled( messenger: TransactionPayControllerMessenger, ): boolean { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; return featureFlags.payStrategies?.relay?.executeEnabled ?? false; } @@ -240,7 +490,11 @@ export function isRelayExecuteEnabled( export function getRelayOriginGasOverhead( messenger: TransactionPayControllerMessenger, ): string { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; return ( featureFlags.payStrategies?.relay?.originGasOverhead ?? DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD @@ -257,7 +511,11 @@ export function getRelayOriginGasOverhead( export function getRelayPollingInterval( messenger: TransactionPayControllerMessenger, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; return ( featureFlags.payStrategies?.relay?.pollingInterval ?? RELAY_POLLING_INTERVAL ); @@ -273,7 +531,11 @@ export function getRelayPollingInterval( export function getRelayPollingTimeout( messenger: TransactionPayControllerMessenger, ): number | undefined { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; return featureFlags.payStrategies?.relay?.pollingTimeout; } @@ -300,7 +562,11 @@ export function getGasBuffer( messenger: TransactionPayControllerMessenger, chainId: Hex, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; return ( featureFlags.gasBuffer?.perChainConfig?.[chainId]?.buffer ?? @@ -323,7 +589,11 @@ export function getSlippage( chainId: Hex, tokenAddress: Hex, ): number { - const featureFlags = getFeatureFlagsRaw(messenger); + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined) ?? {}; const { slippageTokens } = featureFlags; const tokenMap = getCaseInsensitive(slippageTokens, chainId); @@ -413,16 +683,3 @@ export function isEIP7702Chain( (supported) => supported.toLowerCase() === chainId.toLowerCase(), ); } - -/** - * Get the raw confirmations_pay feature flags from the remote feature flag - * controller. - * - * @param messenger - Controller messenger. - * @returns Raw confirmations_pay feature flags. - */ -function getFeatureFlagsRaw( - messenger: TransactionPayControllerMessenger, -): FeatureFlagsRaw { - return (getConfirmationsPayFeatureFlags(messenger) as FeatureFlagsRaw) ?? {}; -} diff --git a/packages/transaction-pay-controller/src/utils/strategy-routing.ts b/packages/transaction-pay-controller/src/utils/strategy-routing.ts deleted file mode 100644 index ca6507de7ee..00000000000 --- a/packages/transaction-pay-controller/src/utils/strategy-routing.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { Hex } from '@metamask/utils'; -import { uniq } from 'lodash'; - -import { getConfirmationsPayFeatureFlags } from './confirmations-pay-feature-flags'; -import { DEFAULT_STRATEGY_ORDER } from './feature-flags'; -import { TransactionPayStrategy, isTransactionPayStrategy } from '../constants'; -import type { TransactionPayControllerMessenger } from '../types'; - -export type TransactionPayRouteContext = { - chainId?: Hex; - tokenAddress?: Hex; - transactionType?: string; -}; - -type StrategyOverrideRaw = { - default?: unknown; - chains?: Record; - tokens?: Record>; -}; - -type StrategyOverride = { - chains: Record; - default?: TransactionPayStrategy[]; - tokens: Record>; -}; - -type StrategyOverrides = { - default?: StrategyOverride; - transactionTypes: Record; -}; - -type RawStrategyRoutingFlags = { - payStrategies?: { - across?: { - enabled?: boolean; - }; - relay?: { - enabled?: boolean; - }; - }; - strategyOverrides?: { - default?: StrategyOverrideRaw; - transactionTypes?: Record; - }; - strategyOrder?: string[]; -}; - -type StrategyRoutingConfig = { - payStrategies: { - across: { - enabled: boolean; - }; - relay: { - enabled: boolean; - }; - }; - strategyOverrides: StrategyOverrides; - strategyOrder: TransactionPayStrategy[]; -}; - -function normalizeHex(value: string | undefined): Hex | undefined { - return value?.toLowerCase() as Hex | undefined; -} - -function normalizeStrategy( - strategy: unknown, -): TransactionPayStrategy | undefined { - if (typeof strategy !== 'string') { - return undefined; - } - - const normalizedStrategy = strategy.toLowerCase() as TransactionPayStrategy; - - return isTransactionPayStrategy(normalizedStrategy) - ? normalizedStrategy - : undefined; -} - -function normalizeStrategyList(strategies: unknown): TransactionPayStrategy[] { - if (!Array.isArray(strategies)) { - return []; - } - - return uniq( - strategies - .map((strategy) => normalizeStrategy(strategy)) - .filter( - (strategy): strategy is TransactionPayStrategy => - strategy !== undefined, - ), - ); -} - -function normalizeStrategyOverride( - override: StrategyOverrideRaw | undefined, -): StrategyOverride { - const chains = Object.entries(override?.chains ?? {}).reduce< - Record - >((result, [chainId, strategies]) => { - const normalizedStrategies = normalizeStrategyList(strategies); - - if (normalizedStrategies.length) { - result[normalizeHex(chainId) as Hex] = normalizedStrategies; - } - - return result; - }, {}); - - const tokens = Object.entries(override?.tokens ?? {}).reduce< - Record> - >((result, [chainId, tokenOverrides]) => { - const normalizedTokenOverrides = Object.entries( - tokenOverrides ?? {}, - ).reduce>( - (tokenResult, [tokenAddress, strategies]) => { - const normalizedStrategies = normalizeStrategyList(strategies); - - if (normalizedStrategies.length) { - tokenResult[normalizeHex(tokenAddress) as Hex] = normalizedStrategies; - } - - return tokenResult; - }, - {}, - ); - - if (Object.keys(normalizedTokenOverrides).length) { - result[normalizeHex(chainId) as Hex] = normalizedTokenOverrides; - } - - return result; - }, {}); - - const defaultStrategies = normalizeStrategyList(override?.default); - - return { - chains, - default: defaultStrategies.length ? defaultStrategies : undefined, - tokens, - }; -} - -function normalizeStrategyRoutingConfig( - rawFeatureFlags: unknown, -): StrategyRoutingConfig { - const featureFlags = (rawFeatureFlags ?? {}) as RawStrategyRoutingFlags; - const strategyOrder = normalizeStrategyList(featureFlags.strategyOrder); - - return { - payStrategies: { - across: { - enabled: featureFlags.payStrategies?.across?.enabled ?? false, - }, - relay: { - enabled: featureFlags.payStrategies?.relay?.enabled ?? true, - }, - }, - strategyOverrides: { - default: featureFlags.strategyOverrides?.default - ? normalizeStrategyOverride(featureFlags.strategyOverrides.default) - : undefined, - transactionTypes: Object.entries( - featureFlags.strategyOverrides?.transactionTypes ?? {}, - ).reduce>((result, [type, override]) => { - result[type] = normalizeStrategyOverride(override); - return result; - }, {}), - }, - strategyOrder: - strategyOrder.length > 0 ? strategyOrder : [...DEFAULT_STRATEGY_ORDER], - }; -} - -function getStrategyRoutingConfig( - messenger: TransactionPayControllerMessenger, -): StrategyRoutingConfig { - return normalizeStrategyRoutingConfig( - getConfirmationsPayFeatureFlags(messenger), - ); -} - -function filterEnabledStrategies( - strategies: readonly TransactionPayStrategy[], - routingConfig: StrategyRoutingConfig, -): TransactionPayStrategy[] { - return strategies.filter( - (strategy) => - (strategy === TransactionPayStrategy.Across && - routingConfig.payStrategies.across.enabled) || - (strategy === TransactionPayStrategy.Relay && - routingConfig.payStrategies.relay.enabled), - ); -} - -function getTokenOverrideStrategies( - override: StrategyOverride | undefined, - normalizedChainId: Hex | undefined, - normalizedTokenAddress: Hex | undefined, -): readonly TransactionPayStrategy[] | undefined { - if (!override || !normalizedChainId || !normalizedTokenAddress) { - return undefined; - } - - return override.tokens[normalizedChainId]?.[normalizedTokenAddress]; -} - -function getChainOverrideStrategies( - override: StrategyOverride | undefined, - normalizedChainId: Hex | undefined, -): readonly TransactionPayStrategy[] | undefined { - if (!override || !normalizedChainId) { - return undefined; - } - - return override.chains[normalizedChainId]; -} - -function getDefaultOverrideStrategies( - override: StrategyOverride | undefined, -): readonly TransactionPayStrategy[] | undefined { - return override?.default; -} - -/** - * Get ordered strategies for a route using generic confirmations_pay routing - * flags. - * - * @param messenger - Controller messenger. - * @param routeContext - Route context used to match transaction type, chain, - * and token overrides. - * @returns Ordered strategy list for the route. - */ -export function getStrategiesForRoute( - messenger: TransactionPayControllerMessenger, - routeContext: TransactionPayRouteContext, -): TransactionPayStrategy[] { - return resolveStrategyOrderForRoute( - getStrategyRoutingConfig(messenger), - routeContext, - ); -} - -/** - * Resolve ordered strategies for a route from the normalized strategy routing - * config. - * - * @param routingConfig - Normalized routing config. - * @param routeContext - Route context used to match transaction type, chain, - * and token overrides. - * @returns Ordered strategy list for the route. - */ -function resolveStrategyOrderForRoute( - routingConfig: StrategyRoutingConfig, - routeContext: TransactionPayRouteContext, -): TransactionPayStrategy[] { - const normalizedChainId = normalizeHex(routeContext.chainId); - const normalizedTokenAddress = normalizeHex(routeContext.tokenAddress); - const transactionTypeOverride = routeContext.transactionType - ? routingConfig.strategyOverrides.transactionTypes[ - routeContext.transactionType - ] - : undefined; - - const candidates: (readonly TransactionPayStrategy[] | undefined)[] = [ - getTokenOverrideStrategies( - transactionTypeOverride, - normalizedChainId, - normalizedTokenAddress, - ), - getChainOverrideStrategies(transactionTypeOverride, normalizedChainId), - getTokenOverrideStrategies( - routingConfig.strategyOverrides.default, - normalizedChainId, - normalizedTokenAddress, - ), - getChainOverrideStrategies( - routingConfig.strategyOverrides.default, - normalizedChainId, - ), - getDefaultOverrideStrategies(transactionTypeOverride), - getDefaultOverrideStrategies(routingConfig.strategyOverrides.default), - ]; - - // Overrides are authoritative. Once a route matches a specific override - // scope, disabled strategies do not inherit candidates from lower-precedence - // scopes. - for (const strategies of candidates) { - if (strategies) { - return filterEnabledStrategies(strategies, routingConfig); - } - } - - return filterEnabledStrategies(routingConfig.strategyOrder, routingConfig); -}