diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 7c31c190875..999d0131779 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Gate the server pay strategy per transaction type so flows can be enabled individually ([#10312](https://github.com/MetaMask/core/pull/10312)) + - `ServerStrategy.supports` now also requires a transaction type listed in the new `payStrategies.server.enabledTransactionTypes` remote feature flag, which defaults to empty. + - `ServerStrategy.supports` now declines flows using capabilities the strategy does not implement yet, regardless of the flag. - Support subsidized max Relay deposits using atomic `EXACT_OUTPUT` quotes with transaction calls embedded, gated by `payStrategies.relay.atomicMaxEnabled` (disabled by default, with per-transaction-type overrides). ([#10224](https://github.com/MetaMask/core/pull/10224)) - Honor the client's `atomic` hint: atomic max quotes use the source-token budget adjusted to destination decimals for 1:1 subsidized stablecoin routes, without a discovery quote or reusing the original deposit amount. Unsubsidized responses are re-quoted non-atomically. - Non-atomic hints start with `EXACT_INPUT` and upgrade to atomic execution when subsidized. diff --git a/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.test.ts index ea705d35182..b58934797f4 100644 --- a/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.test.ts @@ -1,7 +1,12 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionPayControllerMessenger } from '../../index.js'; -import type { TransactionPayQuote } from '../../types.js'; +import type { + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types.js'; import { getPayStrategiesConfig } from '../../utils/feature-flags.js'; import { getServerQuotes } from './server-quotes.js'; import { submitServerQuotes } from './server-submit.js'; @@ -16,6 +21,23 @@ const QUOTE_MOCK = { estimatedDuration: 5, } as TransactionPayQuote; +const QUOTE_REQUEST_MOCK = { + from: '0x1234567890123456789012345678901234567890', + sourceBalanceRaw: '1000000', + sourceChainId: '0x1', + sourceTokenAddress: '0x0000000000000000000000000000000000000001', + sourceTokenAmount: '1000', + targetAmountMinimum: '900', + targetChainId: '0xa4b1', + targetTokenAddress: '0x0000000000000000000000000000000000000002', +} as QuoteRequest; + +const SUPPORTS_REQUEST_MOCK = { + messenger: {} as TransactionPayControllerMessenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { type: TransactionType.perpsDeposit } as TransactionMeta, +} as PayStrategyGetQuotesRequest; + describe('ServerStrategy', () => { const getServerQuotesMock = jest.mocked(getServerQuotes); const submitServerQuotesMock = jest.mocked(submitServerQuotes); @@ -25,30 +47,49 @@ describe('ServerStrategy', () => { jest.resetAllMocks(); getServerQuotesMock.mockResolvedValue([QUOTE_MOCK]); getPayStrategiesConfigMock.mockReturnValue({ - server: { enabled: true }, + server: { + enabled: true, + enabledTransactionTypes: [TransactionType.perpsDeposit], + }, relay: { enabled: false }, } as ReturnType); }); describe('supports', () => { - it('returns true when server strategy is enabled', () => { - expect( - new ServerStrategy().supports({ - messenger: {} as TransactionPayControllerMessenger, - } as never), - ).toBe(true); + it('returns true when enabled and the flow is allowlisted', () => { + expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(true); }); it('returns false when server strategy is disabled', () => { getPayStrategiesConfigMock.mockReturnValue({ - server: { enabled: false }, + server: { + enabled: false, + enabledTransactionTypes: [TransactionType.perpsDeposit], + }, relay: { enabled: true }, } as ReturnType); + expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(false); + }); + + it('returns false when the flow is not allowlisted', () => { + getPayStrategiesConfigMock.mockReturnValue({ + server: { + enabled: true, + enabledTransactionTypes: [] as TransactionType[], + }, + relay: { enabled: false }, + } as ReturnType); + + expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(false); + }); + + it('returns false when a request uses an unsupported capability', () => { expect( new ServerStrategy().supports({ - messenger: {} as TransactionPayControllerMessenger, - } as never), + ...SUPPORTS_REQUEST_MOCK, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + }), ).toBe(false); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.ts b/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.ts index 398ed12fc91..197c96191d1 100644 --- a/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/server/ServerStrategy.ts @@ -1,3 +1,6 @@ +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger.js'; import type { PayStrategy, PayStrategyExecuteRequest, @@ -7,18 +10,42 @@ import type { import { getPayStrategiesConfig } from '../../utils/feature-flags.js'; import { getServerQuotes } from './server-quotes.js'; import { submitServerQuotes } from './server-submit.js'; +import { getServerUnsupportedReason } from './server-support.js'; import type { ServerQuote } from './types.js'; +const log = createModuleLogger(projectLogger, 'server-strategy'); + /** * Pay strategy that routes quote, submit, and status requests through the * MetaMask intents API. Supports both gasless (delegated server-execute) and * non-gasless (TransactionController-submitted) execution paths. Gated by the - * `payStrategies.server.enabled` remote feature flag. + * `payStrategies.server.enabled` remote feature flag, then narrowed per flow by + * `payStrategies.server.enabledTransactionTypes`. */ export class ServerStrategy implements PayStrategy { supports(request: PayStrategyGetQuotesRequest): boolean { const config = getPayStrategiesConfig(request.messenger); - return config.server.enabled; + + if (!config.server.enabled) { + return false; + } + + const reason = getServerUnsupportedReason({ + enabledTransactionTypes: config.server.enabledTransactionTypes, + requests: request.requests, + transaction: request.transaction, + }); + + if (reason) { + log('Unsupported request', { + reason, + transactionType: request.transaction?.type, + }); + + return false; + } + + return true; } async getQuotes( diff --git a/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts new file mode 100644 index 00000000000..886351a66a4 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts @@ -0,0 +1,181 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +import type { QuoteRequest } from '../../types.js'; +import { + DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES, + getServerUnsupportedReason, + ServerUnsupportedReason, +} from './server-support.js'; + +const REQUEST_MOCK: QuoteRequest = { + from: '0x1234567890123456789012345678901234567890', + sourceBalanceRaw: '1000000', + sourceChainId: '0x1', + sourceTokenAddress: '0x0000000000000000000000000000000000000001', + sourceTokenAmount: '1000', + targetAmountMinimum: '900', + targetChainId: '0xa4b1', + targetTokenAddress: '0x0000000000000000000000000000000000000002', +}; + +const TRANSACTION_MOCK = { + type: TransactionType.perpsDeposit, +} as TransactionMeta; + +/** + * Invoke the util with sensible defaults for a supported perps deposit. + * + * @param overrides - Values to override on the default arguments. + * @param overrides.enabledTransactionTypes - Allowlisted transaction types. + * @param overrides.requests - Quote requests. + * @param overrides.transaction - Parent transaction. + * @returns The unsupported reason, if any. + */ +function getReason({ + enabledTransactionTypes = [TransactionType.perpsDeposit], + requests = [REQUEST_MOCK], + transaction = TRANSACTION_MOCK, +}: { + enabledTransactionTypes?: TransactionType[]; + requests?: QuoteRequest[]; + transaction?: TransactionMeta; +} = {}): ServerUnsupportedReason | undefined { + return getServerUnsupportedReason({ + enabledTransactionTypes, + requests, + transaction, + }); +} + +describe('server-support', () => { + describe('DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES', () => { + it('is empty so enabling the strategy alone diverts no traffic', () => { + expect(DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES).toStrictEqual([]); + }); + }); + + describe('getServerUnsupportedReason', () => { + it('returns undefined when the flow is allowlisted and uses no unsupported capability', () => { + expect(getReason()).toBeUndefined(); + }); + + it('returns undefined when there are no requests', () => { + expect(getReason({ requests: [] })).toBeUndefined(); + }); + + it('supports a flow only once it is added to the allowlist', () => { + expect(getReason({ enabledTransactionTypes: [] })).toBe( + ServerUnsupportedReason.DisabledTransactionType, + ); + + expect( + getReason({ enabledTransactionTypes: [TransactionType.perpsDeposit] }), + ).toBeUndefined(); + }); + + it('returns disabled transaction type when the flow is not allowlisted', () => { + expect( + getReason({ + enabledTransactionTypes: [TransactionType.predictDeposit], + }), + ).toBe(ServerUnsupportedReason.DisabledTransactionType); + }); + + it('returns disabled transaction type when the transaction has no type', () => { + expect(getReason({ transaction: {} as TransactionMeta })).toBe( + ServerUnsupportedReason.DisabledTransactionType, + ); + }); + + it('returns undefined when a nested transaction has an allowlisted type', () => { + expect( + getReason({ + transaction: { + nestedTransactions: [{ type: TransactionType.perpsDeposit }], + type: TransactionType.batch, + } as TransactionMeta, + }), + ).toBeUndefined(); + }); + + it.each([ + TransactionType.perpsDepositAndOrder, + TransactionType.predictDepositAndOrder, + ])( + 'returns exact output for %s even when explicitly allowlisted', + (transactionType) => { + expect( + getReason({ + enabledTransactionTypes: [transactionType], + transaction: { type: transactionType } as TransactionMeta, + }), + ).toBe(ServerUnsupportedReason.ExactOutput); + }, + ); + + it('returns exact output when a nested transaction requires it', () => { + expect( + getReason({ + transaction: { + nestedTransactions: [ + { type: TransactionType.perpsDepositAndOrder }, + ], + type: TransactionType.perpsDeposit, + } as TransactionMeta, + }), + ).toBe(ServerUnsupportedReason.ExactOutput); + }); + + it.each([ + ['atomic', { atomic: false }, ServerUnsupportedReason.NonAtomic], + [ + 'hyperliquidActivationFeeUsd', + { hyperliquidActivationFeeUsd: '1' }, + ServerUnsupportedReason.HyperliquidActivationFee, + ], + [ + 'isDirectMusdMoneyAccount', + { isDirectMusdMoneyAccount: true }, + ServerUnsupportedReason.DirectMusdMoneyAccount, + ], + ['isMaxAmount', { isMaxAmount: true }, ServerUnsupportedReason.MaxAmount], + [ + 'isPolymarketDepositWallet', + { isPolymarketDepositWallet: true }, + ServerUnsupportedReason.PolymarketDepositWallet, + ], + ] as const)( + 'returns %s reason for an unsupported capability', + (_name, overrides, expected) => { + expect( + getReason({ requests: [{ ...REQUEST_MOCK, ...overrides }] }), + ).toBe(expected); + }, + ); + + it('returns a reason when any request is unsupported', () => { + expect( + getReason({ + requests: [REQUEST_MOCK, { ...REQUEST_MOCK, isMaxAmount: true }], + }), + ).toBe(ServerUnsupportedReason.MaxAmount); + }); + + it('ignores capability flags that are explicitly false or undefined', () => { + expect( + getReason({ + requests: [ + { + ...REQUEST_MOCK, + atomic: true, + isDirectMusdMoneyAccount: false, + isMaxAmount: false, + isPolymarketDepositWallet: false, + }, + ], + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-support.ts b/packages/transaction-pay-controller/src/strategy/server/server-support.ts new file mode 100644 index 00000000000..e0b4e33d177 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/server/server-support.ts @@ -0,0 +1,144 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + hasTransactionType, + TransactionType, +} from '@metamask/transaction-controller'; + +import type { QuoteRequest } from '../../types.js'; + +/** + * Parent transaction types enabled for the server strategy by default. + * + * Intentionally empty so that turning on `payStrategies.server.enabled` alone + * never diverts live traffic away from the relay strategy. Flows are opted in + * one at a time via the `payStrategies.server.enabledTransactionTypes` remote + * feature flag, which lets a single flow be validated in isolation before the + * next one is added. + */ +export const DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES: TransactionType[] = []; + +/** + * Transaction types whose quote must be priced as an exact output. + * + * The transaction embeds a call that transfers exactly `targetAmountMinimum`, + * so a quote priced on expected output underfunds the transfer and reverts. + * The server strategy cannot express this yet because {@link ServerTradeType} + * has no exact-output member, and these flows route to HyperCore, which + * suppresses the embedded calls that would otherwise let the backend infer it. + * + * Enforced independently of the remote flag so the flag cannot enable a flow + * that is known to misprice. + */ +const EXACT_OUTPUT_TRANSACTION_TYPES: TransactionType[] = [ + TransactionType.perpsDepositAndOrder, + TransactionType.predictDepositAndOrder, +]; + +/** Why the server strategy declined to handle a request. */ +export enum ServerUnsupportedReason { + /** The direct mUSD Money Account fiat flow is not implemented. */ + DirectMusdMoneyAccount = 'directMusdMoneyAccount', + + /** The parent transaction type is not in the remote-flag allowlist. */ + DisabledTransactionType = 'disabledTransactionType', + + /** The flow requires exact-output pricing, which is not implemented. */ + ExactOutput = 'exactOutput', + + /** Reserving the HyperLiquid activation fee is not implemented. */ + HyperliquidActivationFee = 'hyperliquidActivationFee', + + /** Two-phase max-amount gas station probing is not implemented. */ + MaxAmount = 'maxAmount', + + /** Non-atomic multi-leg submission is not implemented. */ + NonAtomic = 'nonAtomic', + + /** Polymarket deposit-wallet routing is not implemented. */ + PolymarketDepositWallet = 'polymarketDepositWallet', +} + +/** + * Determine whether the server strategy can handle a set of quote requests. + * + * Gating has two independent layers: + * + * 1. A remote-flag allowlist of transaction types, so flows can be rolled out + * and rolled back one at a time. + * 2. Code-level capability guards for request shapes the server strategy does + * not implement yet. These cannot be overridden by the flag; each one is + * deleted as the corresponding capability lands. + * + * A transaction can carry several types across its nested transactions, so + * both layers match against the transaction and its nested transactions. + * + * @param options - Options bag. + * @param options.enabledTransactionTypes - Transaction types opted in via + * remote feature flag. + * @param options.requests - Quote requests for required tokens. + * @param options.transaction - Metadata of the original target transaction. + * @returns The first reason the requests are unsupported, or `undefined` if + * every request is supported. + */ +export function getServerUnsupportedReason({ + enabledTransactionTypes, + requests, + transaction, +}: { + enabledTransactionTypes: TransactionType[]; + requests: QuoteRequest[]; + transaction?: TransactionMeta; +}): ServerUnsupportedReason | undefined { + if (!hasTransactionType(transaction, enabledTransactionTypes)) { + return ServerUnsupportedReason.DisabledTransactionType; + } + + // Checked after the allowlist so that a transaction combining an allowlisted + // type with an exact-output type is still declined. + if (hasTransactionType(transaction, EXACT_OUTPUT_TRANSACTION_TYPES)) { + return ServerUnsupportedReason.ExactOutput; + } + + for (const request of requests) { + const reason = getRequestUnsupportedReason(request); + + if (reason) { + return reason; + } + } + + return undefined; +} + +/** + * Determine whether a single quote request uses a capability the server + * strategy has not implemented yet. + * + * @param request - Quote request for a required token. + * @returns The reason the request is unsupported, or `undefined`. + */ +function getRequestUnsupportedReason( + request: QuoteRequest, +): ServerUnsupportedReason | undefined { + if (request.atomic === false) { + return ServerUnsupportedReason.NonAtomic; + } + + if (request.hyperliquidActivationFeeUsd !== undefined) { + return ServerUnsupportedReason.HyperliquidActivationFee; + } + + if (request.isDirectMusdMoneyAccount === true) { + return ServerUnsupportedReason.DirectMusdMoneyAccount; + } + + if (request.isMaxAmount === true) { + return ServerUnsupportedReason.MaxAmount; + } + + if (request.isPolymarketDepositWallet === true) { + return ServerUnsupportedReason.PolymarketDepositWallet; + } + + return undefined; +} 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 79acd41b901..2fe672bcb6c 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -1113,12 +1113,51 @@ describe('Feature Flags Utils', () => { expect(config.server).toStrictEqual({ enabled: false, + enabledTransactionTypes: [], baseUrl: DEFAULT_SERVER_BASE_URL, pollingInterval: 2000, pollingTimeout: undefined, }); }); + it.each([ + ['the flag is absent', undefined, []], + ['the flag is not an array', 'perpsDeposit', []], + [ + 'the flag lists known types', + ['perpsDeposit', 'perpsWithdraw'], + [TransactionType.perpsDeposit, TransactionType.perpsWithdraw], + ], + [ + 'the flag lists unknown types', + ['perpsDeposit', 'notATransactionType'], + [TransactionType.perpsDeposit], + ], + [ + 'the flag lists duplicates', + ['perpsDeposit', 'perpsDeposit'], + [TransactionType.perpsDeposit], + ], + ])( + 'returns enabled transaction types when %s', + (_name, enabledTransactionTypes, expected) => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + server: { enabledTransactionTypes }, + }, + }, + }, + }); + + expect( + getPayStrategiesConfig(messenger).server.enabledTransactionTypes, + ).toStrictEqual(expected); + }, + ); + it('returns enabled: true when the flag enables server', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index f88d26e8dd8..61eb679831a 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -29,6 +29,7 @@ import { SERVER_POLLING_INTERVAL, SERVER_URL_BASE, } from '../strategy/server/constants.js'; +import { DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES } from '../strategy/server/server-support.js'; import type { TransactionPayControllerMessenger } from '../types.js'; const log = createModuleLogger(projectLogger, 'feature-flags'); @@ -210,6 +211,7 @@ type FeatureFlagsExtendedRaw = { }; server?: { enabled?: boolean; + enabledTransactionTypes?: string[]; baseUrl?: string; pollingInterval?: number; pollingTimeout?: number; @@ -226,6 +228,7 @@ export type PayStrategiesConfig = { across: AcrossConfig; server: { enabled: boolean; + enabledTransactionTypes: TransactionType[]; baseUrl: string; pollingInterval: number; pollingTimeout?: number; @@ -239,6 +242,28 @@ function normalizeHex(value: string | undefined): Hex | undefined { return value?.toLowerCase() as Hex | undefined; } +/** + * Convert raw remote-flag transaction type strings into known transaction + * types, discarding any value the client does not recognise. + * + * @param values - Raw transaction type strings from the remote feature flag. + * @returns Recognised transaction types, or the default allowlist when the + * flag is absent. + */ +function normalizeTransactionTypes( + values: string[] | undefined, +): TransactionType[] { + if (!Array.isArray(values)) { + return DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES; + } + + const knownTypes = Object.values(TransactionType) as string[]; + + return uniq(values.filter((value) => knownTypes.includes(value))).map( + (value) => value as TransactionType, + ); +} + function normalizeStrategy( strategy: unknown, ): TransactionPayStrategy | undefined { @@ -618,6 +643,9 @@ export function getPayStrategiesConfig( const server = { enabled: serverRaw.enabled ?? false, + enabledTransactionTypes: normalizeTransactionTypes( + serverRaw.enabledTransactionTypes, + ), baseUrl: serverRaw.baseUrl ?? DEFAULT_SERVER_BASE_URL, pollingInterval: serverRaw.pollingInterval ?? SERVER_POLLING_INTERVAL, pollingTimeout: serverRaw.pollingTimeout,