From 103ddbe05f74a9f209b4cb6f776271af6e34c679 Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 13:59:38 +0300 Subject: [PATCH 01/15] feat(transaction-pay-controller): atomic quoting for subsidized max Money Account deposits --- .../transaction-pay-controller/CHANGELOG.md | 3 + .../src/strategy/relay/relay-quotes.test.ts | 385 ++++++++++++++++++ .../src/strategy/relay/relay-quotes.ts | 282 ++++++++++++- .../relay/relay-submit-execute.test.ts | 27 +- .../strategy/relay/relay-submit-execute.ts | 2 +- .../src/strategy/relay/relay-submit.test.ts | 147 +++++++ .../transaction-pay-controller/src/types.ts | 1 + .../src/utils/quotes.test.ts | 332 +++++++++++++++ .../src/utils/quotes.ts | 15 + 9 files changed, 1189 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4b350c3b6d1..741cce402ca 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 +- Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. + - A new `atomic-promotion-failed` quote error reason is added; failed atomic promotions are terminal and will block rather than silently fall back. + - Non-subsidized max behavior is unchanged. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) ## [28.0.2] diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 78e2a71c80a..029372c9def 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -22,6 +22,7 @@ import type { GetDelegationTransactionCallback, QuoteRequest, } from '../../types.js'; +import type { TransactionPayControllerGetAmountDataAction } from '../../TransactionPayController-method-action-types.js'; import { DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, @@ -177,6 +178,9 @@ describe('Relay Quotes Utils', () => { const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); const getGasBufferMock = jest.mocked(getGasBuffer); const getSlippageMock = jest.mocked(getSlippage); + const getAmountDataMock: jest.MockedFn< + TransactionPayControllerGetAmountDataAction['handler'] + > = jest.fn(); const { messenger, @@ -192,6 +196,11 @@ describe('Relay Quotes Utils', () => { polymarketGetDepositWalletAddressMock, } = getMessengerMock(); + messenger.registerActionHandler( + 'TransactionPayController:getAmountData', + getAmountDataMock, + ); + beforeEach(() => { jest.resetAllMocks(); @@ -3791,6 +3800,382 @@ describe('Relay Quotes Utils', () => { }); }); + describe('subsidized max Money Account promotion', () => { + const SOURCE_CAP_RAW = '100000000'; + const OLD_DEPOSIT_RAW = '50000000'; + const DISCOVERY_OUTPUT_RAW = '99000000'; + const UPDATED_DEPOSIT_DATA = '0xfeed' as Hex; + + const MONEY_ACCOUNT_MAX_REQUEST: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + atomic: false, + isMaxAmount: true, + sourceBalanceRaw: SOURCE_CAP_RAW, + sourceTokenAmount: SOURCE_CAP_RAW, + targetAmountMinimum: '0', + }; + + const MONEY_ACCOUNT_MAX_TRANSACTION = { + ...TRANSACTION_META_MOCK, + id: 'money-account-max-tx-1', + chainId: QUOTE_REQUEST_MOCK.targetChainId, + isGasFeeSponsored: true, + nestedTransactions: [ + { + data: '0xaaaa' as Hex, + type: TransactionType.moneyAccountDeposit, + }, + ], + requiredAssets: [{ amount: toHex(OLD_DEPOSIT_RAW) }], + txParams: { + data: '0xaaaa' as Hex, + from: FROM_MOCK, + to: '0xbb00000000000000000000000000000000000001' as Hex, + }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + function buildRelayQuote({ + amountIn = SOURCE_CAP_RAW, + amountOut = DISCOVERY_OUTPUT_RAW, + isExecute = true, + requestId = '0x1', + subsidizedAmountUsd = '1', + tradeType = 'EXACT_INPUT', + }: { + amountIn?: string; + amountOut?: string; + isExecute?: boolean; + requestId?: string; + subsidizedAmountUsd?: string; + tradeType?: 'EXACT_INPUT' | 'EXACT_OUTPUT'; + } = {}): RelayQuote { + return { + ...cloneDeep(QUOTE_MOCK), + details: { + ...QUOTE_MOCK.details, + currencyIn: { + ...QUOTE_MOCK.details.currencyIn, + amount: amountIn, + amountFormatted: amountIn, + amountUsd: '1', + }, + currencyOut: { + ...QUOTE_MOCK.details.currencyOut, + amount: amountOut, + amountFormatted: amountOut, + amountUsd: '1', + minimumAmount: amountOut, + }, + }, + fees: { + ...QUOTE_MOCK.fees, + subsidized: { + amount: subsidizedAmountUsd, + amountFormatted: subsidizedAmountUsd, + amountUsd: subsidizedAmountUsd, + currency: { + address: QUOTE_REQUEST_MOCK.sourceTokenAddress, + chainId: 1, + decimals: 6, + }, + minimumAmount: subsidizedAmountUsd, + }, + }, + metamask: { + ...QUOTE_MOCK.metamask, + isExecute, + }, + request: { + amount: amountIn, + destinationChainId: Number(QUOTE_REQUEST_MOCK.targetChainId), + destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, + originChainId: Number(QUOTE_REQUEST_MOCK.sourceChainId), + originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, + recipient: FROM_MOCK, + tradeType, + user: FROM_MOCK, + }, + steps: [{ ...STEP_MOCK, requestId }], + }; + } + + function mockRelayResponses( + discoveryQuote = buildRelayQuote(), + promotionQuote = buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ): void { + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => discoveryQuote, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => promotionQuote, + } as never); + } + + beforeEach(() => { + getAmountDataMock.mockResolvedValue({ + updates: [ + { + data: UPDATED_DEPOSIT_DATA, + nestedTransactionIndex: 0, + }, + ], + }); + }); + + it('promotes a subsidized non-atomic max Money Account quote to an atomic exact-output quote', async () => { + mockRelayResponses(); + const originalRequest = cloneDeep(MONEY_ACCOUNT_MAX_REQUEST); + const originalTransaction = cloneDeep(MONEY_ACCOUNT_MAX_TRANSACTION); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(getAmountDataMock).toHaveBeenCalledWith({ + amount: DISCOVERY_OUTPUT_RAW, + transaction: expect.objectContaining({ + nestedTransactions: [ + expect.objectContaining({ data: '0xaaaa' }), + ], + requiredAssets: [expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) })], + }), + }); + + const promotionBody = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(promotionBody).toStrictEqual( + expect.objectContaining({ + amount: DISCOVERY_OUTPUT_RAW, + tradeType: 'EXACT_OUTPUT', + txs: [ + expect.objectContaining({ + data: expect.stringContaining( + BigInt(DISCOVERY_OUTPUT_RAW).toString(16).padStart(64, '0'), + ), + }), + expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), + ], + }), + ); + + expect(getDelegationTransactionMock).toHaveBeenLastCalledWith({ + transaction: expect.objectContaining({ + nestedTransactions: [ + expect.objectContaining({ data: UPDATED_DEPOSIT_DATA }), + ], + requiredAssets: [ + expect.objectContaining({ amount: toHex(DISCOVERY_OUTPUT_RAW) }), + ], + }), + }); + expect(result[0].request).toStrictEqual( + expect.objectContaining({ + atomic: true, + isMaxAmount: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }), + ); + expect(result[0].isInputBased).toBe(false); + expect(result[0].sourceAmount.raw).toBe(DISCOVERY_OUTPUT_RAW); + expect(MONEY_ACCOUNT_MAX_REQUEST).toStrictEqual(originalRequest); + expect(MONEY_ACCOUNT_MAX_TRANSACTION).toStrictEqual(originalTransaction); + }); + + it('does not attempt promotion when the discovery quote is not subsidized', async () => { + const unsubsidizedQuote = buildRelayQuote({ subsidizedAmountUsd: '0' }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => unsubsidizedQuote, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(getAmountDataMock).not.toHaveBeenCalled(); + expect(result[0].request.atomic).toBe(false); + expect(result[0].original.request.amount).toBe( + unsubsidizedQuote.request.amount, + ); + }); + + it('throws atomic-promotion-failed when promotion loses subsidy after positive subsidy detection', async () => { + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + requestId: '0x2', + subsidizedAmountUsd: '0', + tradeType: 'EXACT_OUTPUT', + }), + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when promoted source cost exceeds the original max spend budget', async () => { + const nativeRequest = { + ...MONEY_ACCOUNT_MAX_REQUEST, + sourceTokenAddress: NATIVE_TOKEN_ADDRESS, + }; + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + amountIn: '100000001', + isExecute: false, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ); + calculateGasCostMock.mockImplementation(({ gas }) => { + const base = Number(gas); + return { + fiat: String(base), + human: String(base), + raw: String(base), + usd: String(base), + } as never; + }); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [nativeRequest], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when same-token gas pushes an ERC-20 promotion over the spend cap', async () => { + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + amountIn: SOURCE_CAP_RAW, + isExecute: false, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('promotes an ERC-20 max quote when same-token gas stays within the spend cap', async () => { + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + isExecute: false, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + calculateGasFeeTokenCostMock.mockReturnValue({ + fiat: '1', + human: '1', + raw: '1000000', + usd: '1', + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, + }); + + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); + }); + + it('throws atomic-promotion-failed when same-token gas pushes a native promotion over the spend cap', async () => { + const nativeRequest = { + ...MONEY_ACCOUNT_MAX_REQUEST, + sourceTokenAddress: NATIVE_TOKEN_ADDRESS, + }; + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + amountIn: '99900000', + isExecute: false, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ); + getGasFeeTokensMock.mockResolvedValue([ + { ...GAS_FEE_TOKEN_MOCK, tokenAddress: NATIVE_TOKEN_ADDRESS }, + ]); + calculateGasFeeTokenCostMock.mockReturnValue({ + fiat: '2', + human: '2', + raw: '2000000', + usd: '2', + }); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [nativeRequest], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + }); + describe('HyperLiquid source (isHyperliquidSource)', () => { const HL_REQUEST: QuoteRequest = { ...QUOTE_REQUEST_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 6dddd612b69..11d7a5388fc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -56,6 +56,8 @@ import { normalizeTokenAddress, TokenAddressTarget, } from '../../utils/token.js'; +import { QuoteError } from '../../utils/validation.js'; +import { isMoneyAccountDepositTransaction } from '../fiat/utils.js'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants.js'; import { applyHyperliquidActivationFee } from './hyperliquid-activation.js'; import { @@ -65,6 +67,7 @@ import { } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; +import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -107,6 +110,8 @@ export async function getRelayQuotes( log('Fetching quotes', requests); + let quotes: TransactionPayQuote[] = []; + try { const normalizedRequests = await Promise.all( requests @@ -134,7 +139,7 @@ export async function getRelayQuotes( log('Normalized requests', normalizedRequests); - const quotes = await Promise.all( + quotes = await Promise.all( normalizedRequests.map((singleRequest) => getQuoteWithMaxAmountHandling(singleRequest, request), ), @@ -150,6 +155,11 @@ export async function getRelayQuotes( return quotes; } catch (error) { log('Error fetching quotes', { error }); + + if (quotes.some(isPromotedSubsidizedMaxMoneyAccountQuote)) { + throwAtomicPromotionFailed(error); + } + throw error; } } @@ -164,7 +174,275 @@ async function getQuoteWithMaxAmountHandling( return getQuoteWithPostQuoteGasHandling(request, fullRequest); } - return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote); + const discoveryQuote = await getRelayMaxGasStationQuote( + request, + fullRequest, + getSingleQuote, + ); + + return maybePromoteSubsidizedMaxMoneyAccountQuote({ + discoveryQuote, + fullRequest, + request, + }); +} + +async function maybePromoteSubsidizedMaxMoneyAccountQuote({ + discoveryQuote, + fullRequest, + request, +}: { + discoveryQuote: TransactionPayQuote; + fullRequest: PayStrategyGetQuotesRequest; + request: QuoteRequest; +}): Promise> { + if (!shouldAttemptAtomicPromotion(request, fullRequest.transaction, discoveryQuote)) { + return discoveryQuote; + } + + try { + const targetAmount = validatePositiveIntegerString( + discoveryQuote.original.details.currencyOut.amount, + ); + + const transactionClone = cloneTransactionForPromotion(fullRequest.transaction); + const promotionTransaction = await applyAmountDataUpdates({ + amount: targetAmount, + messenger: fullRequest.messenger, + transaction: transactionClone, + }); + + const promotedQuote = await getSingleQuote( + { + ...request, + atomic: true, + isMaxAmount: false, + targetAmountMinimum: targetAmount, + }, + { + ...fullRequest, + transaction: promotionTransaction, + }, + ); + + if ( + request.sourceTokenAddress.toLowerCase() === + getNativeToken(request.sourceChainId).toLowerCase() && + new BigNumber(promotedQuote.fees.sourceNetwork.max.raw) + .plus(new BigNumber(promotedQuote.sourceAmount.raw)) + .isGreaterThan(new BigNumber(request.sourceTokenAmount)) + ) { + throw new Error('Promoted quote exceeds max spend budget'); + } + + assertAtomicPromotionIsValid({ + discoveryQuote, + promotedQuote, + request, + targetAmount, + }); + + return { + ...promotedQuote, + request: { + ...promotedQuote.request, + atomic: true, + isMaxAmount: true, + }, + }; + } catch (error) { + return throwAtomicPromotionFailed(error); + } +} + +function throwAtomicPromotionFailed(error: unknown): never { + throw new QuoteError({ + detail: [error instanceof Error ? error.message : String(error)], + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }); +} + +function isPromotedSubsidizedMaxMoneyAccountQuote( + quote: TransactionPayQuote, +): boolean { + return ( + quote.request.isMaxAmount === true && + quote.request.atomic === true && + isSubsidizedRelayQuote(quote.original) + ); +} + +function shouldAttemptAtomicPromotion( + request: QuoteRequest, + transaction: TransactionMeta, + discoveryQuote: TransactionPayQuote, +): boolean { + return ( + isMoneyAccountDepositTransaction(transaction) && + request.isMaxAmount === true && + request.isPostQuote !== true && + request.atomic !== true && + isSubsidizedRelayQuote(discoveryQuote.original) + ); +} + +function validatePositiveIntegerString(value: string): string { + const amount = new BigNumber(value); + + if (!amount.isFinite() || !amount.isInteger() || !amount.isGreaterThan(0)) { + throw new Error(`Invalid target amount: ${value}`); + } + + return amount.toFixed(0); +} + +function cloneTransactionForPromotion( + transaction: TransactionMeta, +): TransactionMeta { + return { + ...transaction, + nestedTransactions: transaction.nestedTransactions?.map( + (nestedTransaction) => ({ + ...nestedTransaction, + }), + ), + requiredAssets: transaction.requiredAssets?.map((requiredAsset) => ({ + ...requiredAsset, + })), + }; +} + +async function applyAmountDataUpdates({ + amount, + messenger, + transaction, +}: { + amount: string; + messenger: TransactionPayControllerMessenger; + transaction: TransactionMeta; +}): Promise { + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount, + transaction, + }, + ); + + if (!updates.length) { + throw new Error('getAmountData returned no updates for atomic promotion'); + } + + const nestedTransactions = transaction.nestedTransactions?.map( + (nestedTransaction) => ({ + ...nestedTransaction, + }), + ); + + if (!nestedTransactions?.length) { + throw new Error('Missing nested transactions for atomic promotion'); + } + + for (const { nestedTransactionIndex, data } of updates) { + if (!nestedTransactions[nestedTransactionIndex]) { + throw new Error( + 'getAmountData returned an unusable nested transaction update', + ); + } + + nestedTransactions[nestedTransactionIndex].data = data; + } + + const requiredAssets = transaction.requiredAssets?.map((requiredAsset) => ({ + ...requiredAsset, + })); + + if (!requiredAssets?.[0]) { + throw new Error('Missing required assets for atomic promotion'); + } + + requiredAssets[0].amount = toHex(BigInt(amount)); + + return { + ...transaction, + nestedTransactions, + requiredAssets, + }; +} + +function assertAtomicPromotionIsValid({ + discoveryQuote, + promotedQuote, + request, + targetAmount, +}: { + discoveryQuote: TransactionPayQuote; + promotedQuote: TransactionPayQuote; + request: QuoteRequest; + targetAmount: string; +}): void { + if (!isSubsidizedRelayQuote(promotedQuote.original)) { + throw new Error('Promoted quote lost subsidy'); + } + + if (promotedQuote.original.request.tradeType !== 'EXACT_OUTPUT') { + throw new Error('Promoted quote did not return EXACT_OUTPUT'); + } + + if (!promotedQuote.original.request.txs?.length) { + throw new Error('Promoted quote is missing embedded calls'); + } + + const sourceCost = getPromotedSourceCost({ promotedQuote, request }); + + const budget = new BigNumber(request.sourceTokenAmount); + + if (sourceCost.isGreaterThan(budget)) { + throw new Error('Promoted quote exceeds max spend budget'); + } + + if (promotedQuote.request.isMaxAmount !== false) { + throw new Error( + 'Promoted quote request isMaxAmount was not cleared before restore', + ); + } + + if (promotedQuote.request.atomic !== true) { + throw new Error('Promoted quote request atomic flag was not set'); + } + + if (promotedQuote.request.targetAmountMinimum !== targetAmount) { + throw new Error('Promoted quote target amount minimum was not preserved'); + } + + if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) { + throw new Error('Discovery quote target amount changed before promotion'); + } +} + +function getPromotedSourceCost({ + promotedQuote, + request, +}: { + promotedQuote: TransactionPayQuote; + request: QuoteRequest; +}): BigNumber { + const sourceAmount = new BigNumber(promotedQuote.sourceAmount.raw); + const sourceTokenIsNative = + request.sourceTokenAddress.toLowerCase() === + getNativeToken(request.sourceChainId).toLowerCase(); + + if ( + !sourceTokenIsNative && + !promotedQuote.fees.isSourceGasFeeToken + ) { + return sourceAmount; + } + + return sourceAmount.plus( + new BigNumber(promotedQuote.fees.sourceNetwork.max.raw), + ); } /** diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts index c0719865cdc..5f12f9ca5e7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts @@ -1,5 +1,5 @@ import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { TransactionMeta, TransactionParams } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash-es'; @@ -12,6 +12,7 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags.js'; import { + isSubsidizedRelayQuote, getRelayExecuteRequest, submitViaRelayExecute, } from './relay-submit-execute.js'; @@ -109,7 +110,7 @@ describe('Relay Submit Execute', () => { let successfulFetchMock: jest.SpyInstance; let quote: TransactionPayQuote; let transaction: TransactionMeta; - let allParams: { to?: Hex; data?: Hex; value?: Hex }[]; + let allParams: TransactionParams[]; beforeEach(() => { jest.resetAllMocks(); @@ -157,6 +158,7 @@ describe('Relay Submit Execute', () => { allParams = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: '0x1234' as Hex, value: '0x4d2' as Hex, @@ -168,6 +170,20 @@ describe('Relay Submit Execute', () => { successfulFetchMock.mockRestore(); }); + describe('isSubsidizedRelayQuote', () => { + it.each([ + [{ fees: { subsidized: { amountUsd: 1 } } }, true], + [{ fees: { subsidized: { amountUsd: 0.000001 } } }, true], + [{ fees: { subsidized: { amountUsd: 0 } } }, false], + [{ fees: { subsidized: { amountUsd: -1 } } }, false], + [{}, false], + [{ fees: {} }, false], + [{ fees: { subsidized: {} } }, false], + ])('returns %s for %p', (testQuote, expected) => { + expect(isSubsidizedRelayQuote(testQuote as RelayQuote)).toBe(expected); + }); + }); + describe('submitViaRelayExecute', () => { beforeEach(() => { quote.original.metamask.isExecute = true; @@ -216,11 +232,13 @@ describe('Relay Submit Execute', () => { const multiParams = [ { + from: FROM_MOCK, to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, data: '0x1111' as Hex, value: '0x1' as Hex, }, { + from: FROM_MOCK, to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, data: '0x2222' as Hex, value: '0x2' as Hex, @@ -332,6 +350,7 @@ describe('Relay Submit Execute', () => { it('uses fallback values for missing data and value in source params', async () => { const paramsWithoutDataOrValue = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: undefined, value: undefined, @@ -406,6 +425,7 @@ describe('Relay Submit Execute', () => { allParams = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: '0xa9059cbb000000000000000000000000abcdef1234567890abcdef1234567890abcdef120000000000000000000000000000000000000000000000000000000000989680' as Hex, value: '0x4d2' as Hex, @@ -542,6 +562,7 @@ describe('Relay Submit Execute', () => { it('uses 0x fallback when params.data is undefined', async () => { const paramsWithoutData = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: undefined, value: '0x4d2' as Hex, @@ -660,11 +681,13 @@ describe('Relay Submit Execute', () => { const multiParams = [ { + from: FROM_MOCK, to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, data: '0x1111' as Hex, value: '0x1' as Hex, }, { + from: FROM_MOCK, to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, data: '0x2222' as Hex, value: '0x2' as Hex, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts index 7f7ed0f5fb9..097a9cdc4b8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -75,7 +75,7 @@ async function submitViaRelayExecuteInternal( return FALLBACK_HASH; } -function isSubsidizedRelayQuote(quote: RelayQuote): boolean { +export function isSubsidizedRelayQuote(quote: RelayQuote): boolean { return Number(quote.fees?.subsidized?.amountUsd ?? '0') > 0; } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 9a6da6422ee..0e22dd6328f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -2280,5 +2280,152 @@ describe('Relay Submit Utils', () => { }); }); }); + + describe('promoted subsidized max Money Account routing', () => { + const PROMOTED_TARGET_RAW_MOCK = '99000000'; + const PROMOTED_EMBEDDED_DATA_MOCK = '0xembedded' as Hex; + const PROMOTED_EMBEDDED_TO_MOCK = + '0xteller00000000000000000000000000000001' as Hex; + const RECIPIENT_MOCK = '0xrecip0000000000000000000000000000000001' as Hex; + const TARGET_HASH_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; + const NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK = '535000'; + const NON_ATOMIC_MINIMUM_AMOUNT_MOCK = '530000'; + const VAULT_HASH_MOCK = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; + const NON_ATOMIC_TARGET_CHAIN_ID_MOCK = '0x2797' as Hex; + const NON_ATOMIC_TARGET_TOKEN_ADDRESS_MOCK = + '0xtoken000000000000000000000000000000001' as Hex; + + /** + * Configure `request.quotes[0]` as an atomic promoted subsidized max + * Money Account quote, matching the T4 promotion output: request carries + * `atomic: true`, `isMaxAmount: true`, subsidy `amountUsd: 1`, and the + * embedded second-leg call for `99000000` is inlined into the Relay + * steps. + */ + const configureAtomicPromotedMaQuote = (): void => { + const quote = request.quotes[0]; + quote.request.atomic = true; + quote.request.isMaxAmount = true; + quote.original.metamask.isExecute = true; + quote.original.details.currencyOut.amount = PROMOTED_TARGET_RAW_MOCK; + quote.original.fees = { + ...(quote.original.fees ?? {}), + subsidized: { amountUsd: '1' }, + } as RelayQuote['fees']; + // The atomic promotion embeds the final MA-vault call directly in the + // Relay quote steps, so submission must forward *these* calls (not + // the stale parent calldata) to the Relay execute helper. + quote.original.steps[0].items[0].data = { + ...quote.original.steps[0].items[0].data, + data: PROMOTED_EMBEDDED_DATA_MOCK, + to: PROMOTED_EMBEDDED_TO_MOCK, + value: '0', + }; + }; + + /** + * Configure `request.quotes[0]` as a non-subsidized non-atomic max + * Money Account quote — the pre-existing behaviour where the vault + * helper is invoked once after Relay settlement. + */ + const configureNonAtomicMaxMaQuote = (): void => { + const quote = request.quotes[0]; + quote.request.atomic = false; + quote.request.isMaxAmount = true; + quote.request.recipient = RECIPIENT_MOCK; + quote.request.targetChainId = NON_ATOMIC_TARGET_CHAIN_ID_MOCK; + quote.request.targetTokenAddress = + NON_ATOMIC_TARGET_TOKEN_ADDRESS_MOCK; + quote.original.details.currencyOut = { + ...quote.original.details.currencyOut, + currency: { + ...quote.original.details.currencyOut.currency, + decimals: 6, + }, + minimumAmount: NON_ATOMIC_MINIMUM_AMOUNT_MOCK, + }; + }; + + it('atomic success: submits Relay execution once with the promoted embedded calls and skips the vault helper', async () => { + configureAtomicPromotedMaQuote(); + submitViaRelayExecuteMock.mockResolvedValue(FALLBACK_HASH); + + await submitRelayQuotes(request); + + // Vault helper is NOT called separately - the vault calls are already + // embedded in the promoted Relay quote's steps and submitted atomically. + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(0); + + // Relay execute is invoked exactly once with the promoted quote's + // embedded calldata, not any stale parent calldata. + expect(submitViaRelayExecuteMock).toHaveBeenCalledTimes(1); + const [ + executedQuote, + , + , + executedAllParams, + ] = submitViaRelayExecuteMock.mock.calls[0]; + expect(executedQuote.request.atomic).toBe(true); + expect(executedQuote.request.isMaxAmount).toBe(true); + expect(executedAllParams).toStrictEqual([ + expect.objectContaining({ + data: PROMOTED_EMBEDDED_DATA_MOCK, + to: PROMOTED_EMBEDDED_TO_MOCK, + from: FROM_MOCK, + }), + ]); + }); + + it('non-atomic success: still invokes the vault helper exactly once with the settled amount', async () => { + configureNonAtomicMaxMaQuote(); + submitMoneyAccountVaultDepositMock.mockResolvedValue({ + transactionHash: VAULT_HASH_MOCK, + }); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [TARGET_HASH_MOCK], + }), + } as Response); + + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(1); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAmountRaw: NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK, + moneyAccountAddress: RECIPIENT_MOCK, + }), + ); + }); + + it('non-atomic failure: does not invoke the vault helper when Relay reports a failed status', async () => { + configureNonAtomicMaxMaQuote(); + submitMoneyAccountVaultDepositMock.mockResolvedValue({ + transactionHash: VAULT_HASH_MOCK, + }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'failure', + inTxHashes: [SOURCE_HASH_MOCK], + }), + } as Response); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Relay: Request failed with status: failure', + ); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(0); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d029e1e85b7..89b0b879f9a 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -496,6 +496,7 @@ export type TransactionPaySourceAmount = { * and `detail` on {@link QuoteErrorInfo}. */ export type QuoteErrorReason = + | 'atomic-promotion-failed' | 'balance-unavailable' | 'insufficient-source-balance' | 'insufficient-transfer-balance' diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 71c582f8fe8..e5c20bcc2f8 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -483,6 +483,182 @@ describe('Quotes Utils', () => { expect(secondStrategy.getQuotes).toHaveBeenCalled(); }); + it('short-circuits and does not try next strategy when QuoteError has terminal atomic-promotion-failed reason', async () => { + const terminalError = new QuoteError({ + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }); + + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue(terminalError), + execute: jest.fn(), + }; + + const secondStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Across, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Across) { + return firstStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return secondStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(secondStrategy.getQuotes).not.toHaveBeenCalled(); + + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quoteError: { + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }, + isLoading: false, + }); + }); + + it('preserves fallback for ordinary no-quotes error before successful next strategy', async () => { + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue( + new QuoteError({ + message: 'No quotes returned', + reason: 'no-quotes', + }), + ), + execute: jest.fn(), + }; + + const secondStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Across, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Across) { + return firstStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return secondStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(secondStrategy.getQuotes).toHaveBeenCalledTimes(1); + + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [QUOTE_MOCK], + }); + }); + + it('terminal atomic-promotion-failed wins over an earlier ordinary no-quotes error', async () => { + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue( + new QuoteError({ + message: 'No quotes returned', + reason: 'no-quotes', + }), + ), + execute: jest.fn(), + }; + + const secondStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue( + new QuoteError({ + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }), + ), + execute: jest.fn(), + }; + + const thirdStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Across, + TransactionPayStrategy.Relay, + TransactionPayStrategy.None, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Across) { + return firstStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return secondStrategy as never; + } + + if (name === TransactionPayStrategy.None) { + return thirdStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(secondStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(thirdStrategy.getQuotes).not.toHaveBeenCalled(); + + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quoteError: { + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }, + }); + }); + it('falls back to next strategy when batch transactions fail', async () => { const firstStrategy = { supports: jest.fn().mockReturnValue(true), @@ -1394,6 +1570,162 @@ describe('Quotes Utils', () => { expect(resultA).toBe(true); expect(resultB).toBe(true); }); + + it('publishes fail-closed state (empty quotes, terminal error, isLoading false) and skips subsequent strategies when atomic promotion fails on a refresh with a prior executable quote in state', async () => { + // Seed a prior executable quote in state so this call is a refresh, not + // an initial fetch — proving the block cannot leave the previous quote + // selected once a terminal atomic-promotion-failed surfaces. + const priorQuote = { + ...QUOTE_MOCK, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + + const promotionStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue( + new QuoteError({ + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }), + ), + getBatchTransactions: jest.fn(), + execute: jest.fn(), + }; + + const fallbackStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Relay) { + return promotionStrategy as never; + } + + if (name === TransactionPayStrategy.Across) { + return fallbackStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + const result = await run({ + transactionData: { + ...cloneDeep(TRANSACTION_DATA_MOCK), + quotes: [priorQuote], + quotesLastUpdated: Date.now() - 60_000, + }, + }); + + expect(result).toBe(true); + + // The terminal error must abort remaining strategies — the fallback + // must never be tried. + expect(promotionStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(fallbackStrategy.getQuotes).not.toHaveBeenCalled(); + expect(promotionStrategy.getBatchTransactions).not.toHaveBeenCalled(); + expect(fallbackStrategy.getBatchTransactions).not.toHaveBeenCalled(); + + // Fold every state update onto a single object to observe the final + // published state — same technique the T1 tests use for state assertions. + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quoteError: { + message: 'Atomic quote promotion failed', + reason: 'atomic-promotion-failed', + }, + isLoading: false, + }); + + // The transaction metadata sync must reflect an empty batch: a stale + // batch from the prior executable quote cannot survive the block. + expect(updateTransactionMock).toHaveBeenCalledTimes(1); + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + expect(transactionMetaMock).toMatchObject({ + batchTransactions: [], + batchTransactionsOptions: {}, + }); + }); + + it('does not let a superseded stale refresh overwrite the newer refresh state or write late transaction/config from its aborted signal', async () => { + // Refresh A starts first and its strategy fetch is held open via a + // deferred promise. Refresh B then starts, races to completion with a + // fresh non-subsidized non-atomic quote, and must own final state. + // When A's deferred fetch finally resolves, its signal is already + // aborted so nothing from A can be published. + const deferredA = deferred[]>(); + let firstSignal: AbortSignal | undefined; + let secondSignal: AbortSignal | undefined; + + const finalBQuote = { + ...QUOTE_MOCK, + strategy: TransactionPayStrategy.Across, + } as TransactionPayQuote; + + // A: capture signal and hold the fetch open. + getQuotesMock.mockImplementationOnce( + (req: { signal?: AbortSignal }) => { + firstSignal = req.signal; + return deferredA; + }, + ); + + // B: capture signal and resolve immediately with a fresh quote. + getQuotesMock.mockImplementationOnce( + (req: { signal?: AbortSignal }) => { + secondSignal = req.signal; + return Promise.resolve([finalBQuote]); + }, + ); + + const firstPromise = run(); + // Let A's controller register itself so B's start aborts it. + await Promise.resolve(); + await Promise.resolve(); + + const secondPromise = run(); + + // B finishes first and owns the state. + const secondResult = await secondPromise; + expect(secondResult).toBe(true); + + // Now release A. Its signal is already aborted, so the pipeline must + // return false and publish nothing from A. + deferredA.resolve([QUOTE_MOCK]); + const firstResult = await firstPromise; + + expect(firstResult).toBe(false); + expect(firstSignal?.aborted).toBe(true); + expect(secondSignal?.aborted).toBe(false); + + // Only B's quotes should ever land in state — never A's stale QUOTE_MOCK. + const quoteWrites = updateTransactionDataMock.mock.calls + .map(([, fn]) => { + const data: Record = {}; + (fn as (d: Record) => void)(data); + return data; + }) + .filter((data) => Array.isArray(data.quotes)); + + expect(quoteWrites).toHaveLength(1); + expect(quoteWrites[0].quotes).toStrictEqual([finalBQuote]); + + // A must not write to the transaction meta (batchTransactions / + // metamaskPay config): only B's sync call is allowed. + expect(updateTransactionMock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index b12e45816ca..1917d0470a0 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -758,6 +758,21 @@ async function getQuotes( ? caughtError.info : { message: (caughtError as Error).message, reason: 'no-quotes' }; + if ( + isQuoteError(caughtError) && + caughtError.info.reason === 'atomic-promotion-failed' + ) { + log('Terminal atomic-promotion-failed, aborting remaining strategies', { + strategy: name, + transactionId, + }); + return { + batchTransactions: [], + error: caughtError.info, + quotes: [], + }; + } + if ( isQuoteError(caughtError) && caughtError.info.reason === 'insufficient-source-balance' && From b5837e335c578bbc5536ea587ef97c46d067f9fd Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 14:02:44 +0300 Subject: [PATCH 02/15] style(transaction-pay-controller): fix formatting --- .../src/strategy/relay/relay-quotes.test.ts | 12 +++++++----- .../src/strategy/relay/relay-quotes.ts | 17 +++++++++++------ .../strategy/relay/relay-submit-execute.test.ts | 5 ++++- .../src/strategy/relay/relay-submit.test.ts | 11 +++-------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 029372c9def..7164939ed61 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -18,11 +18,11 @@ import { POLYGON_USDCE_ADDRESS, } from '../../constants.js'; import { getMessengerMock } from '../../tests/messenger-mock.js'; +import type { TransactionPayControllerGetAmountDataAction } from '../../TransactionPayController-method-action-types.js'; import type { GetDelegationTransactionCallback, QuoteRequest, } from '../../types.js'; -import type { TransactionPayControllerGetAmountDataAction } from '../../TransactionPayController-method-action-types.js'; import { DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, @@ -3946,10 +3946,10 @@ describe('Relay Quotes Utils', () => { expect(getAmountDataMock).toHaveBeenCalledWith({ amount: DISCOVERY_OUTPUT_RAW, transaction: expect.objectContaining({ - nestedTransactions: [ - expect.objectContaining({ data: '0xaaaa' }), + nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], + requiredAssets: [ + expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) }), ], - requiredAssets: [expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) })], }), }); @@ -3991,7 +3991,9 @@ describe('Relay Quotes Utils', () => { expect(result[0].isInputBased).toBe(false); expect(result[0].sourceAmount.raw).toBe(DISCOVERY_OUTPUT_RAW); expect(MONEY_ACCOUNT_MAX_REQUEST).toStrictEqual(originalRequest); - expect(MONEY_ACCOUNT_MAX_TRANSACTION).toStrictEqual(originalTransaction); + expect(MONEY_ACCOUNT_MAX_TRANSACTION).toStrictEqual( + originalTransaction, + ); }); it('does not attempt promotion when the discovery quote is not subsidized', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 11d7a5388fc..c6ef7bc035d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -196,7 +196,13 @@ async function maybePromoteSubsidizedMaxMoneyAccountQuote({ fullRequest: PayStrategyGetQuotesRequest; request: QuoteRequest; }): Promise> { - if (!shouldAttemptAtomicPromotion(request, fullRequest.transaction, discoveryQuote)) { + if ( + !shouldAttemptAtomicPromotion( + request, + fullRequest.transaction, + discoveryQuote, + ) + ) { return discoveryQuote; } @@ -205,7 +211,9 @@ async function maybePromoteSubsidizedMaxMoneyAccountQuote({ discoveryQuote.original.details.currencyOut.amount, ); - const transactionClone = cloneTransactionForPromotion(fullRequest.transaction); + const transactionClone = cloneTransactionForPromotion( + fullRequest.transaction, + ); const promotionTransaction = await applyAmountDataUpdates({ amount: targetAmount, messenger: fullRequest.messenger, @@ -433,10 +441,7 @@ function getPromotedSourceCost({ request.sourceTokenAddress.toLowerCase() === getNativeToken(request.sourceChainId).toLowerCase(); - if ( - !sourceTokenIsNative && - !promotedQuote.fees.isSourceGasFeeToken - ) { + if (!sourceTokenIsNative && !promotedQuote.fees.isSourceGasFeeToken) { return sourceAmount; } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts index 5f12f9ca5e7..0a1fb75b17d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts @@ -1,5 +1,8 @@ import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; -import type { TransactionMeta, TransactionParams } from '@metamask/transaction-controller'; +import type { + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash-es'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 0e22dd6328f..e73aaf75432 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -2336,8 +2336,7 @@ describe('Relay Submit Utils', () => { quote.request.isMaxAmount = true; quote.request.recipient = RECIPIENT_MOCK; quote.request.targetChainId = NON_ATOMIC_TARGET_CHAIN_ID_MOCK; - quote.request.targetTokenAddress = - NON_ATOMIC_TARGET_TOKEN_ADDRESS_MOCK; + quote.request.targetTokenAddress = NON_ATOMIC_TARGET_TOKEN_ADDRESS_MOCK; quote.original.details.currencyOut = { ...quote.original.details.currencyOut, currency: { @@ -2361,12 +2360,8 @@ describe('Relay Submit Utils', () => { // Relay execute is invoked exactly once with the promoted quote's // embedded calldata, not any stale parent calldata. expect(submitViaRelayExecuteMock).toHaveBeenCalledTimes(1); - const [ - executedQuote, - , - , - executedAllParams, - ] = submitViaRelayExecuteMock.mock.calls[0]; + const [executedQuote, , , executedAllParams] = + submitViaRelayExecuteMock.mock.calls[0]; expect(executedQuote.request.atomic).toBe(true); expect(executedQuote.request.isMaxAmount).toBe(true); expect(executedAllParams).toStrictEqual([ From 5747aa9013a00aa7ba4959a3662473aa8eaa1ba2 Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 14:12:18 +0300 Subject: [PATCH 03/15] test(transaction-pay-controller): cover atomic promotion guards and same-token gas budget --- .../src/strategy/relay/relay-quotes.test.ts | 195 ++++++++++++++++++ .../src/strategy/relay/relay-quotes.ts | 5 + 2 files changed, 200 insertions(+) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 7164939ed61..9d8586b09da 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -39,6 +39,7 @@ import { getTokenFiatRate, } from '../../utils/token.js'; import { getRelayQuotes } from './relay-quotes.js'; +import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, RelayTransactionStep } from './types.js'; jest.mock('../../utils/token', () => ({ @@ -3930,6 +3931,8 @@ describe('Relay Quotes Utils', () => { }); }); + const validateRelayQuotesMock = jest.mocked(validateRelayQuotes); + it('promotes a subsidized non-atomic max Money Account quote to an atomic exact-output quote', async () => { mockRelayResponses(); const originalRequest = cloneDeep(MONEY_ACCOUNT_MAX_REQUEST); @@ -4176,6 +4179,198 @@ describe('Relay Quotes Utils', () => { info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), }); }); + + it('wraps validation failure on a promoted batch as atomic-promotion-failed', async () => { + mockRelayResponses(); + validateRelayQuotesMock.mockRejectedValueOnce( + new Error('validation boom'), + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('rethrows validation failure unwrapped when no quote was promoted', async () => { + mockRelayResponses(buildRelayQuote({ subsidizedAmountUsd: '0' })); + validateRelayQuotesMock.mockRejectedValueOnce( + new Error('validation boom'), + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow('validation boom'); + }); + + it('rethrows validation failure unwrapped for a plain non-max quote', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + validateRelayQuotesMock.mockRejectedValueOnce( + new Error('validation boom'), + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow('validation boom'); + }); + + it('rejects an already-atomic max quote upstream without terminal wrapping', async () => { + mockRelayResponses(buildRelayQuote({ subsidizedAmountUsd: '0' })); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic: true }], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow( + 'Max amount quotes do not support included transactions', + ); + }); + + it('throws atomic-promotion-failed when the discovery target is not a positive integer', async () => { + mockRelayResponses(buildRelayQuote({ amountOut: '0' })); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when amount data returns no updates', async () => { + mockRelayResponses(); + getAmountDataMock.mockResolvedValue({ updates: [] }); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when nested transactions are missing', async () => { + mockRelayResponses(); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + nestedTransactions: [], + }, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when an amount update references a missing nested transaction', async () => { + mockRelayResponses(); + getAmountDataMock.mockResolvedValue({ + updates: [ + { + data: UPDATED_DEPOSIT_DATA, + nestedTransactionIndex: 5, + }, + ], + }); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when required assets are missing', async () => { + mockRelayResponses(); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + requiredAssets: [], + }, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed when the discovery target changes shape before promotion', async () => { + mockRelayResponses(buildRelayQuote({ amountOut: '099000000' })); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + }); + }); + + it('throws atomic-promotion-failed with string detail when promotion fails with a non-error', async () => { + mockRelayResponses(); + getAmountDataMock.mockRejectedValueOnce('boom-string' as never); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toMatchObject({ + info: expect.objectContaining({ + detail: ['boom-string'], + reason: 'atomic-promotion-failed', + }), + }); + }); }); describe('HyperLiquid source (isHyperliquidSource)', () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index c6ef7bc035d..4cd611750f8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -394,10 +394,12 @@ function assertAtomicPromotionIsValid({ throw new Error('Promoted quote lost subsidy'); } + /* istanbul ignore next: atomic re-quotes always request exact-output; defensive guard. */ if (promotedQuote.original.request.tradeType !== 'EXACT_OUTPUT') { throw new Error('Promoted quote did not return EXACT_OUTPUT'); } + /* istanbul ignore next: atomic re-quotes always embed funding and delegation calls; defensive guard. */ if (!promotedQuote.original.request.txs?.length) { throw new Error('Promoted quote is missing embedded calls'); } @@ -410,16 +412,19 @@ function assertAtomicPromotionIsValid({ throw new Error('Promoted quote exceeds max spend budget'); } + /* istanbul ignore next: re-quote hardcodes isMaxAmount false; defensive guard. */ if (promotedQuote.request.isMaxAmount !== false) { throw new Error( 'Promoted quote request isMaxAmount was not cleared before restore', ); } + /* istanbul ignore next: re-quote hardcodes atomic true; defensive guard. */ if (promotedQuote.request.atomic !== true) { throw new Error('Promoted quote request atomic flag was not set'); } + /* istanbul ignore next: re-quote hardcodes the discovery target; defensive guard. */ if (promotedQuote.request.targetAmountMinimum !== targetAmount) { throw new Error('Promoted quote target amount minimum was not preserved'); } From b4296ef47d777f30b4ac03f88901e88e8f94f5b7 Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 14:13:09 +0300 Subject: [PATCH 04/15] docs(transaction-pay-controller): reference PR in changelog --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 741cce402ca..b00667951fa 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. - A new `atomic-promotion-failed` quote error reason is added; failed atomic promotions are terminal and will block rather than silently fall back. - - Non-subsidized max behavior is unchanged. + - Non-subsidized max behavior is unchanged. ([#10224](https://github.com/MetaMask/core/pull/10224)) - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) ## [28.0.2] From e81860e6bd279bdde56c67a62042cf52224c0c85 Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 14:17:45 +0300 Subject: [PATCH 05/15] Fix changelog --- packages/transaction-pay-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index b00667951fa..6762f33b812 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. +- Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. ([#10224](https://github.com/MetaMask/core/pull/10224)) - A new `atomic-promotion-failed` quote error reason is added; failed atomic promotions are terminal and will block rather than silently fall back. - - Non-subsidized max behavior is unchanged. ([#10224](https://github.com/MetaMask/core/pull/10224)) + - Non-subsidized max behavior is unchanged. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) ## [28.0.2] From ada5102a1058568095051e85a89709eaa7a05ce7 Mon Sep 17 00:00:00 2001 From: Repro Date: Wed, 16 Sep 2026 15:21:08 +0300 Subject: [PATCH 06/15] feat(transaction-pay-controller): address review feedback on atomic max promotion --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/strategy/relay/constants.ts | 2 + ...-gas-station.test.ts => relay-max.test.ts} | 4 +- ...{relay-max-gas-station.ts => relay-max.ts} | 257 +++++++- .../src/strategy/relay/relay-quotes.test.ts | 566 ++++++++++++------ .../src/strategy/relay/relay-quotes.ts | 327 ++-------- .../strategy/relay/relay-submit-execute.ts | 8 +- .../transaction-pay-controller/src/types.ts | 1 - .../src/utils/feature-flags.test.ts | 130 ++++ .../src/utils/feature-flags.ts | 60 +- .../src/utils/quotes.test.ts | 30 +- .../src/utils/quotes.ts | 15 +- 12 files changed, 890 insertions(+), 512 deletions(-) rename packages/transaction-pay-controller/src/strategy/relay/{relay-max-gas-station.test.ts => relay-max.test.ts} (99%) rename packages/transaction-pay-controller/src/strategy/relay/{relay-max-gas-station.ts => relay-max.ts} (64%) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6762f33b812..37e77736155 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. ([#10224](https://github.com/MetaMask/core/pull/10224)) - - A new `atomic-promotion-failed` quote error reason is added; failed atomic promotions are terminal and will block rather than silently fall back. + - Failed atomic promotions are terminal `no-quotes` errors prefixed with `Atomic promotion failed`, and will block rather than silently fall back. - Non-subsidized max behavior is unchanged. - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index ab47df2db18..7c5be28761a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -6,6 +6,8 @@ import type { RelayStatus } from './types.js'; export const FALLBACK_HASH = '0x0' as Hex; +export const ATOMIC_PROMOTION_FAILURE_PREFIX = 'Atomic promotion failed: '; + export const RELAY_URL_BASE = 'https://api.relay.link'; export const RELAY_AUTHORIZE_URL = `${RELAY_URL_BASE}/authorize`; export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.test.ts similarity index 99% rename from packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts rename to packages/transaction-pay-controller/src/strategy/relay/relay-max.test.ts index 346d95c4e37..eadc525fe3d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.test.ts @@ -20,7 +20,7 @@ import { getTokenBalance, getTokenInfo, } from '../../utils/token.js'; -import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; +import { getRelayMaxGasStationQuote } from './relay-max.js'; import type { RelayQuote } from './types.js'; jest.mock('../../utils/token'); @@ -153,7 +153,7 @@ function makeFullRequest( }; } -describe('relay-max-gas-station', () => { +describe('relay-max', () => { const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); const getTokenBalanceMock = jest.mocked(getTokenBalance); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts similarity index 64% rename from packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts rename to packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index f66f9c5c28a..f47be37a0f6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max-gas-station.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -1,3 +1,6 @@ +import { toHex } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -8,6 +11,8 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types.js'; +import { prefixError } from '../../utils/error-prefix.js'; +import { isAtomicMaxPromotionEnabled } from '../../utils/feature-flags.js'; import { getGasStationEligibility, getGasStationCostInSourceTokenRaw, @@ -17,9 +22,12 @@ import { getTokenBalance, getTokenInfo, } from '../../utils/token.js'; +import { QuoteError } from '../../utils/validation.js'; +import { ATOMIC_PROMOTION_FAILURE_PREFIX } from './constants.js'; +import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; import type { RelayQuote, RelayTransactionStep } from './types.js'; -const log = createModuleLogger(projectLogger, 'relay-max-gas-station'); +const log = createModuleLogger(projectLogger, 'relay-max'); const PROBE_AMOUNT_PERCENTAGE = 0.25; @@ -466,3 +474,250 @@ function markQuoteAsMaxGasStation( isMaxGasStation: true, }; } + +export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ + discoveryQuote, + fullRequest, + getSingleQuote, + request, +}: { + discoveryQuote: TransactionPayQuote; + fullRequest: PayStrategyGetQuotesRequest; + getSingleQuote: GetSingleQuoteFn; + request: QuoteRequest; +}): Promise> { + if ( + !shouldAttemptAtomicPromotion( + request, + fullRequest.transaction, + discoveryQuote, + fullRequest.messenger, + ) + ) { + return discoveryQuote; + } + + try { + const targetAmount = validatePositiveIntegerString( + discoveryQuote.original.details.currencyOut.amount, + ); + + const transactionClone = cloneTransactionForPromotion( + fullRequest.transaction, + ); + const promotionTransaction = await applyAmountDataUpdates({ + amount: targetAmount, + messenger: fullRequest.messenger, + transaction: transactionClone, + }); + + const promotedQuote = await getSingleQuote( + { + ...request, + atomic: true, + isMaxAmount: false, + targetAmountMinimum: targetAmount, + }, + { + ...fullRequest, + transaction: promotionTransaction, + }, + ); + + assertAtomicPromotionIsValid({ + discoveryQuote, + promotedQuote, + targetAmount, + }); + + return { + ...promotedQuote, + request: { + ...promotedQuote.request, + atomic: true, + isMaxAmount: true, + }, + }; + } catch (error) { + return throwAtomicPromotionFailed(error); + } +} + +export function throwAtomicPromotionFailed(error: unknown): never { + const prefixed = prefixError(error, ATOMIC_PROMOTION_FAILURE_PREFIX); + throw new QuoteError({ + detail: [prefixed.message], + message: prefixed.message, + reason: 'no-quotes', + }); +} + +export function isPromotedSubsidizedMaxMoneyAccountQuote( + quote: TransactionPayQuote, +): boolean { + return ( + quote.request.isMaxAmount === true && + quote.request.atomic === true && + isSubsidizedRelayQuote(quote.original) + ); +} + +function shouldAttemptAtomicPromotion( + request: QuoteRequest, + transaction: TransactionMeta, + discoveryQuote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, +): boolean { + return ( + isAtomicMaxPromotionEnabled(messenger, transaction) && + request.isMaxAmount === true && + request.isPostQuote !== true && + request.atomic !== true && + isSubsidizedRelayQuote(discoveryQuote.original) + ); +} + +function validatePositiveIntegerString(value: string): string { + const amount = new BigNumber(value); + + if (!amount.isFinite() || !amount.isInteger() || !amount.isGreaterThan(0)) { + throw new Error(`Invalid target amount: ${value}`); + } + + return amount.toFixed(0); +} + +function cloneTransactionForPromotion( + transaction: TransactionMeta, +): TransactionMeta { + return { + ...transaction, + nestedTransactions: transaction.nestedTransactions?.map( + (nestedTransaction) => ({ + ...nestedTransaction, + }), + ), + requiredAssets: transaction.requiredAssets?.map((requiredAsset) => ({ + ...requiredAsset, + })), + }; +} + +async function applyAmountDataUpdates({ + amount, + messenger, + transaction, +}: { + amount: string; + messenger: TransactionPayControllerMessenger; + transaction: TransactionMeta; +}): Promise { + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount, + transaction, + }, + ); + + if (!updates.length) { + throw new Error('getAmountData returned no updates for atomic promotion'); + } + + const nestedTransactions = transaction.nestedTransactions?.map( + (nestedTransaction) => ({ + ...nestedTransaction, + }), + ); + + if (!nestedTransactions?.length) { + throw new Error('Missing nested transactions for atomic promotion'); + } + + for (const { nestedTransactionIndex, data } of updates) { + if (!nestedTransactions[nestedTransactionIndex]) { + throw new Error( + 'getAmountData returned an unusable nested transaction update', + ); + } + + nestedTransactions[nestedTransactionIndex].data = data; + } + + const requiredAssets = transaction.requiredAssets?.map((requiredAsset) => ({ + ...requiredAsset, + })); + + if (!requiredAssets?.[0]) { + throw new Error('Missing required assets for atomic promotion'); + } + + requiredAssets[0].amount = toHex(BigInt(amount)); + + return { + ...transaction, + nestedTransactions, + requiredAssets, + }; +} + +function assertAtomicPromotionIsValid({ + discoveryQuote, + promotedQuote, + targetAmount, +}: { + discoveryQuote: TransactionPayQuote; + promotedQuote: TransactionPayQuote; + targetAmount: string; +}): void { + if (!isSubsidizedRelayQuote(promotedQuote.original)) { + throw new Error('Promoted quote lost subsidy'); + } + + if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) { + throw new Error('Discovery quote target amount changed before promotion'); + } +} + +export async function maybeDemoteUnsubsidizedAtomicQuote({ + fullRequest, + getSingleQuote, + quote, + request, +}: { + fullRequest: PayStrategyGetQuotesRequest; + getSingleQuote: GetSingleQuoteFn; + quote: TransactionPayQuote; + request: QuoteRequest; +}): Promise> { + if ( + !shouldAttemptAtomicDemotion( + request, + fullRequest.transaction, + fullRequest.messenger, + ) + ) { + return quote; + } + + if (isSubsidizedRelayQuote(quote.original)) { + return quote; + } + + return getSingleQuote({ ...request, atomic: false }, fullRequest); +} + +function shouldAttemptAtomicDemotion( + request: QuoteRequest, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): boolean { + return ( + isAtomicMaxPromotionEnabled(messenger, transaction) && + request.isMaxAmount !== true && + request.isPostQuote !== true && + request.atomic !== false && + transaction.type !== TransactionType.perpsDepositAndOrder && + transaction.type !== TransactionType.predictDepositAndOrder + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 9d8586b09da..780e699a4dc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -3801,125 +3801,127 @@ describe('Relay Quotes Utils', () => { }); }); - describe('subsidized max Money Account promotion', () => { - const SOURCE_CAP_RAW = '100000000'; - const OLD_DEPOSIT_RAW = '50000000'; - const DISCOVERY_OUTPUT_RAW = '99000000'; - const UPDATED_DEPOSIT_DATA = '0xfeed' as Hex; - - const MONEY_ACCOUNT_MAX_REQUEST: QuoteRequest = { - ...QUOTE_REQUEST_MOCK, - atomic: false, - isMaxAmount: true, - sourceBalanceRaw: SOURCE_CAP_RAW, - sourceTokenAmount: SOURCE_CAP_RAW, - targetAmountMinimum: '0', - }; + const SOURCE_CAP_RAW = '100000000'; + const OLD_DEPOSIT_RAW = '50000000'; + const DISCOVERY_OUTPUT_RAW = '99000000'; + const UPDATED_DEPOSIT_DATA = '0xfeed' as Hex; + + const MONEY_ACCOUNT_MAX_REQUEST: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + atomic: false, + isMaxAmount: true, + sourceBalanceRaw: SOURCE_CAP_RAW, + sourceTokenAmount: SOURCE_CAP_RAW, + targetAmountMinimum: '0', + }; - const MONEY_ACCOUNT_MAX_TRANSACTION = { - ...TRANSACTION_META_MOCK, - id: 'money-account-max-tx-1', - chainId: QUOTE_REQUEST_MOCK.targetChainId, - isGasFeeSponsored: true, - nestedTransactions: [ - { - data: '0xaaaa' as Hex, - type: TransactionType.moneyAccountDeposit, - }, - ], - requiredAssets: [{ amount: toHex(OLD_DEPOSIT_RAW) }], - txParams: { + const MONEY_ACCOUNT_MAX_TRANSACTION = { + ...TRANSACTION_META_MOCK, + id: 'money-account-max-tx-1', + chainId: QUOTE_REQUEST_MOCK.targetChainId, + isGasFeeSponsored: true, + nestedTransactions: [ + { data: '0xaaaa' as Hex, - from: FROM_MOCK, - to: '0xbb00000000000000000000000000000000000001' as Hex, + type: TransactionType.moneyAccountDeposit, }, - type: TransactionType.moneyAccountDeposit, - } as TransactionMeta; + ], + requiredAssets: [{ amount: toHex(OLD_DEPOSIT_RAW) }], + txParams: { + data: '0xaaaa' as Hex, + from: FROM_MOCK, + to: '0xbb00000000000000000000000000000000000001' as Hex, + }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; - function buildRelayQuote({ - amountIn = SOURCE_CAP_RAW, - amountOut = DISCOVERY_OUTPUT_RAW, - isExecute = true, - requestId = '0x1', - subsidizedAmountUsd = '1', - tradeType = 'EXACT_INPUT', - }: { - amountIn?: string; - amountOut?: string; - isExecute?: boolean; - requestId?: string; - subsidizedAmountUsd?: string; - tradeType?: 'EXACT_INPUT' | 'EXACT_OUTPUT'; - } = {}): RelayQuote { - return { - ...cloneDeep(QUOTE_MOCK), - details: { - ...QUOTE_MOCK.details, - currencyIn: { - ...QUOTE_MOCK.details.currencyIn, - amount: amountIn, - amountFormatted: amountIn, - amountUsd: '1', - }, - currencyOut: { - ...QUOTE_MOCK.details.currencyOut, - amount: amountOut, - amountFormatted: amountOut, - amountUsd: '1', - minimumAmount: amountOut, - }, - }, - fees: { - ...QUOTE_MOCK.fees, - subsidized: { - amount: subsidizedAmountUsd, - amountFormatted: subsidizedAmountUsd, - amountUsd: subsidizedAmountUsd, - currency: { - address: QUOTE_REQUEST_MOCK.sourceTokenAddress, - chainId: 1, - decimals: 6, - }, - minimumAmount: subsidizedAmountUsd, - }, + function buildRelayQuote({ + amountIn = SOURCE_CAP_RAW, + amountOut = DISCOVERY_OUTPUT_RAW, + isExecute = true, + requestId = '0x1', + subsidizedAmountUsd = '1', + tradeType = 'EXACT_INPUT', + }: { + amountIn?: string; + amountOut?: string; + isExecute?: boolean; + requestId?: string; + subsidizedAmountUsd?: string; + tradeType?: 'EXACT_INPUT' | 'EXACT_OUTPUT'; + } = {}): RelayQuote { + return { + ...cloneDeep(QUOTE_MOCK), + details: { + ...QUOTE_MOCK.details, + currencyIn: { + ...QUOTE_MOCK.details.currencyIn, + amount: amountIn, + amountFormatted: amountIn, + amountUsd: '1', }, - metamask: { - ...QUOTE_MOCK.metamask, - isExecute, + currencyOut: { + ...QUOTE_MOCK.details.currencyOut, + amount: amountOut, + amountFormatted: amountOut, + amountUsd: '1', + minimumAmount: amountOut, }, - request: { - amount: amountIn, - destinationChainId: Number(QUOTE_REQUEST_MOCK.targetChainId), - destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, - originChainId: Number(QUOTE_REQUEST_MOCK.sourceChainId), - originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, - recipient: FROM_MOCK, - tradeType, - user: FROM_MOCK, + }, + fees: { + ...QUOTE_MOCK.fees, + subsidized: { + amount: subsidizedAmountUsd, + amountFormatted: subsidizedAmountUsd, + amountUsd: subsidizedAmountUsd, + currency: { + address: QUOTE_REQUEST_MOCK.sourceTokenAddress, + chainId: 1, + decimals: 6, + }, + minimumAmount: subsidizedAmountUsd, }, - steps: [{ ...STEP_MOCK, requestId }], - }; - } + }, + metamask: { + ...QUOTE_MOCK.metamask, + isExecute, + }, + request: { + amount: amountIn, + destinationChainId: Number(QUOTE_REQUEST_MOCK.targetChainId), + destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, + originChainId: Number(QUOTE_REQUEST_MOCK.sourceChainId), + originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, + recipient: FROM_MOCK, + tradeType, + user: FROM_MOCK, + }, + steps: [{ ...STEP_MOCK, requestId }], + }; + } + + function mockRelayResponses( + discoveryQuote = buildRelayQuote(), + promotionQuote = buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ): void { + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => discoveryQuote, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => promotionQuote, + } as never); + } - function mockRelayResponses( - discoveryQuote = buildRelayQuote(), - promotionQuote = buildRelayQuote({ - amountIn: DISCOVERY_OUTPUT_RAW, - requestId: '0x2', - tradeType: 'EXACT_OUTPUT', - }), - ): void { - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => discoveryQuote, - } as never) - .mockResolvedValueOnce({ - ok: true, - json: async () => promotionQuote, - } as never); - } + const validateRelayQuotesMock = jest.mocked(validateRelayQuotes); + describe('subsidized max Money Account promotion', () => { beforeEach(() => { getAmountDataMock.mockResolvedValue({ updates: [ @@ -3931,8 +3933,6 @@ describe('Relay Quotes Utils', () => { }); }); - const validateRelayQuotesMock = jest.mocked(validateRelayQuotes); - it('promotes a subsidized non-atomic max Money Account quote to an atomic exact-output quote', async () => { mockRelayResponses(); const originalRequest = cloneDeep(MONEY_ACCOUNT_MAX_REQUEST); @@ -3999,6 +3999,30 @@ describe('Relay Quotes Utils', () => { ); }); + it('does not attempt promotion when the atomic max promotion flag is off', async () => { + mockRelayResponses(); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxPromotionEnabled: { default: false } }, + }, + }, + }, + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(result[0].request.atomic).toBe(false); + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + }); + it('does not attempt promotion when the discovery quote is not subsidized', async () => { const unsubsidizedQuote = buildRelayQuote({ subsidizedAmountUsd: '0' }); successfulFetchMock.mockResolvedValue({ @@ -4021,7 +4045,7 @@ describe('Relay Quotes Utils', () => { ); }); - it('throws atomic-promotion-failed when promotion loses subsidy after positive subsidy detection', async () => { + it('fails atomic promotion terminally when promotion loses subsidy after positive subsidy detection', async () => { mockRelayResponses( buildRelayQuote(), buildRelayQuote({ @@ -4039,50 +4063,14 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), - }); - }); - - it('throws atomic-promotion-failed when promoted source cost exceeds the original max spend budget', async () => { - const nativeRequest = { - ...MONEY_ACCOUNT_MAX_REQUEST, - sourceTokenAddress: NATIVE_TOKEN_ADDRESS, - }; - mockRelayResponses( - buildRelayQuote(), - buildRelayQuote({ - amountIn: '100000001', - isExecute: false, - requestId: '0x2', - tradeType: 'EXACT_OUTPUT', - }), - ); - calculateGasCostMock.mockImplementation(({ gas }) => { - const base = Number(gas); - return { - fiat: String(base), - human: String(base), - raw: String(base), - usd: String(base), - } as never; - }); - - await expect( - getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [nativeRequest], - transaction: { - ...MONEY_ACCOUNT_MAX_TRANSACTION, - isGasFeeSponsored: false, - }, + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), }), - ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), }); }); - it('throws atomic-promotion-failed when same-token gas pushes an ERC-20 promotion over the spend cap', async () => { + it('promotes an ERC-20 max quote above the spend cap, leaving balance enforcement to validation', async () => { mockRelayResponses( buildRelayQuote(), buildRelayQuote({ @@ -4094,22 +4082,21 @@ describe('Relay Quotes Utils', () => { ); getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); - await expect( - getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MONEY_ACCOUNT_MAX_REQUEST], - transaction: { - ...MONEY_ACCOUNT_MAX_TRANSACTION, - isGasFeeSponsored: false, - }, - }), - ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, }); + + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); }); - it('promotes an ERC-20 max quote when same-token gas stays within the spend cap', async () => { + it('promotes an ERC-20 max quote when source-token gas is present', async () => { mockRelayResponses( buildRelayQuote(), buildRelayQuote({ @@ -4141,7 +4128,7 @@ describe('Relay Quotes Utils', () => { expect(result[0].request.isMaxAmount).toBe(true); }); - it('throws atomic-promotion-failed when same-token gas pushes a native promotion over the spend cap', async () => { + it('promotes a native max quote above the spend cap, leaving balance enforcement to validation', async () => { const nativeRequest = { ...MONEY_ACCOUNT_MAX_REQUEST, sourceTokenAddress: NATIVE_TOKEN_ADDRESS, @@ -4165,22 +4152,21 @@ describe('Relay Quotes Utils', () => { usd: '2', }); - await expect( - getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [nativeRequest], - transaction: { - ...MONEY_ACCOUNT_MAX_TRANSACTION, - isGasFeeSponsored: false, - }, - }), - ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [nativeRequest], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + isGasFeeSponsored: false, + }, }); + + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); }); - it('wraps validation failure on a promoted batch as atomic-promotion-failed', async () => { + it('wraps validation failure on a promoted batch as terminal prefixed failure', async () => { mockRelayResponses(); validateRelayQuotesMock.mockRejectedValueOnce( new Error('validation boom'), @@ -4194,7 +4180,10 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); @@ -4248,7 +4237,7 @@ describe('Relay Quotes Utils', () => { ); }); - it('throws atomic-promotion-failed when the discovery target is not a positive integer', async () => { + it('fails atomic promotion terminally when the discovery target is not a positive integer', async () => { mockRelayResponses(buildRelayQuote({ amountOut: '0' })); await expect( @@ -4259,11 +4248,14 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed when amount data returns no updates', async () => { + it('fails atomic promotion terminally when amount data returns no updates', async () => { mockRelayResponses(); getAmountDataMock.mockResolvedValue({ updates: [] }); @@ -4275,11 +4267,14 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed when nested transactions are missing', async () => { + it('fails atomic promotion terminally when nested transactions are missing', async () => { mockRelayResponses(); await expect( @@ -4293,11 +4288,14 @@ describe('Relay Quotes Utils', () => { }, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed when an amount update references a missing nested transaction', async () => { + it('fails atomic promotion terminally when an amount update references a missing nested transaction', async () => { mockRelayResponses(); getAmountDataMock.mockResolvedValue({ updates: [ @@ -4316,11 +4314,14 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed when required assets are missing', async () => { + it('fails atomic promotion terminally when required assets are missing', async () => { mockRelayResponses(); await expect( @@ -4334,11 +4335,14 @@ describe('Relay Quotes Utils', () => { }, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed when the discovery target changes shape before promotion', async () => { + it('fails atomic promotion terminally when the discovery target changes shape before promotion', async () => { mockRelayResponses(buildRelayQuote({ amountOut: '099000000' })); await expect( @@ -4349,11 +4353,14 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), ).rejects.toMatchObject({ - info: expect.objectContaining({ reason: 'atomic-promotion-failed' }), + info: expect.objectContaining({ + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), }); }); - it('throws atomic-promotion-failed with string detail when promotion fails with a non-error', async () => { + it('fails atomic promotion terminally with string detail when promotion fails with a non-error', async () => { mockRelayResponses(); getAmountDataMock.mockRejectedValueOnce('boom-string' as never); @@ -4366,10 +4373,177 @@ describe('Relay Quotes Utils', () => { }), ).rejects.toMatchObject({ info: expect.objectContaining({ - detail: ['boom-string'], - reason: 'atomic-promotion-failed', + detail: ['Atomic promotion failed: boom-string'], + reason: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('rethrows validation failure unwrapped when a mixed batch fails on its non-promoted quote', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => buildRelayQuote({ requestId: '0x0' }), + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => buildRelayQuote(), + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + } as never); + validateRelayQuotesMock.mockRejectedValueOnce( + new Error('validation boom'), + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK, MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }), + ).rejects.toThrow('validation boom'); + expect(validateRelayQuotesMock).toHaveBeenCalledTimes(1); + }); + + it('validates the promoted batch carrying the embedded calls', async () => { + mockRelayResponses(); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); + expect(validateRelayQuotesMock).toHaveBeenCalledTimes(1); + const validatedQuotes = validateRelayQuotesMock.mock.calls[0][0].quotes; + expect(validatedQuotes).toHaveLength(1); + expect(validatedQuotes[0].request.atomic).toBe(true); + expect(validatedQuotes[0].request.targetAmountMinimum).toBe( + DISCOVERY_OUTPUT_RAW, + ); + }); + }); + + describe('atomic demote for unsubsidized quotes', () => { + const MA_NON_MAX_REQUEST: QuoteRequest = { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + isMaxAmount: false, + sourceBalanceRaw: '50000000', + sourceTokenAmount: '50000000', + targetAmountMinimum: '49000000', + }; + + it('demotes an unsubsidized atomic quote to a sponsored non-atomic quote', async () => { + mockRelayResponses( + buildRelayQuote({ subsidizedAmountUsd: '0' }), + buildRelayQuote({ requestId: '0x2' }), + ); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MA_NON_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].request.atomic).toBe(false); + }); + + it('stands on a subsidized atomic quote without a second fetch', async () => { + mockRelayResponses(); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MA_NON_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(true); + }); + + it('skips demote when the request is already non-atomic', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...MA_NON_MAX_REQUEST, atomic: false }], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(false); + }); + + it('skips demote for non-Money Account deposits', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...MA_NON_MAX_REQUEST, atomic: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(true); + }); + + it('skips demote for order flows even when the flag allows the nested type', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MA_NON_MAX_REQUEST], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + type: TransactionType.perpsDepositAndOrder, + }, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(true); + }); + + it('propagates demote re-quote failure as an ordinary unwrapped error', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never) + .mockRejectedValueOnce(new Error('demote boom')); + + const error = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MA_NON_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }).catch((caught) => caught); + + expect((error as Error).message).toBe('demote boom'); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 4cd611750f8..eb236bde0b2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -56,8 +56,6 @@ import { normalizeTokenAddress, TokenAddressTarget, } from '../../utils/token.js'; -import { QuoteError } from '../../utils/validation.js'; -import { isMoneyAccountDepositTransaction } from '../fiat/utils.js'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants.js'; import { applyHyperliquidActivationFee } from './hyperliquid-activation.js'; import { @@ -66,8 +64,13 @@ import { isPredictWithdraw, } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; -import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; -import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; +import { + getRelayMaxGasStationQuote, + isPromotedSubsidizedMaxMoneyAccountQuote, + maybeDemoteUnsubsidizedAtomicQuote, + maybePromoteSubsidizedMaxMoneyAccountQuote, + throwAtomicPromotionFailed, +} from './relay-max.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -145,21 +148,38 @@ export async function getRelayQuotes( ), ); - await validateRelayQuotes({ - messenger: request.messenger, - quotes, - signal: request.signal, - transaction: request.transaction, - }); + const promotedQuotes = quotes.filter( + isPromotedSubsidizedMaxMoneyAccountQuote, + ); + const otherQuotes = quotes.filter( + (quote) => !isPromotedSubsidizedMaxMoneyAccountQuote(quote), + ); - return quotes; - } catch (error) { - log('Error fetching quotes', { error }); + if (otherQuotes.length > 0) { + await validateRelayQuotes({ + messenger: request.messenger, + quotes: otherQuotes, + signal: request.signal, + transaction: request.transaction, + }); + } - if (quotes.some(isPromotedSubsidizedMaxMoneyAccountQuote)) { - throwAtomicPromotionFailed(error); + if (promotedQuotes.length > 0) { + try { + await validateRelayQuotes({ + messenger: request.messenger, + quotes: promotedQuotes, + signal: request.signal, + transaction: request.transaction, + }); + } catch (error) { + throwAtomicPromotionFailed(error); + } } + return quotes; + } catch (error) { + log('Error fetching quotes', { error }); throw error; } } @@ -171,7 +191,13 @@ async function getQuoteWithMaxAmountHandling( const { isMaxAmount } = request; if (!isMaxAmount) { - return getQuoteWithPostQuoteGasHandling(request, fullRequest); + const quote = await getQuoteWithPostQuoteGasHandling(request, fullRequest); + return maybeDemoteUnsubsidizedAtomicQuote({ + fullRequest, + getSingleQuote, + quote, + request, + }); } const discoveryQuote = await getRelayMaxGasStationQuote( @@ -183,278 +209,11 @@ async function getQuoteWithMaxAmountHandling( return maybePromoteSubsidizedMaxMoneyAccountQuote({ discoveryQuote, fullRequest, + getSingleQuote, request, }); } -async function maybePromoteSubsidizedMaxMoneyAccountQuote({ - discoveryQuote, - fullRequest, - request, -}: { - discoveryQuote: TransactionPayQuote; - fullRequest: PayStrategyGetQuotesRequest; - request: QuoteRequest; -}): Promise> { - if ( - !shouldAttemptAtomicPromotion( - request, - fullRequest.transaction, - discoveryQuote, - ) - ) { - return discoveryQuote; - } - - try { - const targetAmount = validatePositiveIntegerString( - discoveryQuote.original.details.currencyOut.amount, - ); - - const transactionClone = cloneTransactionForPromotion( - fullRequest.transaction, - ); - const promotionTransaction = await applyAmountDataUpdates({ - amount: targetAmount, - messenger: fullRequest.messenger, - transaction: transactionClone, - }); - - const promotedQuote = await getSingleQuote( - { - ...request, - atomic: true, - isMaxAmount: false, - targetAmountMinimum: targetAmount, - }, - { - ...fullRequest, - transaction: promotionTransaction, - }, - ); - - if ( - request.sourceTokenAddress.toLowerCase() === - getNativeToken(request.sourceChainId).toLowerCase() && - new BigNumber(promotedQuote.fees.sourceNetwork.max.raw) - .plus(new BigNumber(promotedQuote.sourceAmount.raw)) - .isGreaterThan(new BigNumber(request.sourceTokenAmount)) - ) { - throw new Error('Promoted quote exceeds max spend budget'); - } - - assertAtomicPromotionIsValid({ - discoveryQuote, - promotedQuote, - request, - targetAmount, - }); - - return { - ...promotedQuote, - request: { - ...promotedQuote.request, - atomic: true, - isMaxAmount: true, - }, - }; - } catch (error) { - return throwAtomicPromotionFailed(error); - } -} - -function throwAtomicPromotionFailed(error: unknown): never { - throw new QuoteError({ - detail: [error instanceof Error ? error.message : String(error)], - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', - }); -} - -function isPromotedSubsidizedMaxMoneyAccountQuote( - quote: TransactionPayQuote, -): boolean { - return ( - quote.request.isMaxAmount === true && - quote.request.atomic === true && - isSubsidizedRelayQuote(quote.original) - ); -} - -function shouldAttemptAtomicPromotion( - request: QuoteRequest, - transaction: TransactionMeta, - discoveryQuote: TransactionPayQuote, -): boolean { - return ( - isMoneyAccountDepositTransaction(transaction) && - request.isMaxAmount === true && - request.isPostQuote !== true && - request.atomic !== true && - isSubsidizedRelayQuote(discoveryQuote.original) - ); -} - -function validatePositiveIntegerString(value: string): string { - const amount = new BigNumber(value); - - if (!amount.isFinite() || !amount.isInteger() || !amount.isGreaterThan(0)) { - throw new Error(`Invalid target amount: ${value}`); - } - - return amount.toFixed(0); -} - -function cloneTransactionForPromotion( - transaction: TransactionMeta, -): TransactionMeta { - return { - ...transaction, - nestedTransactions: transaction.nestedTransactions?.map( - (nestedTransaction) => ({ - ...nestedTransaction, - }), - ), - requiredAssets: transaction.requiredAssets?.map((requiredAsset) => ({ - ...requiredAsset, - })), - }; -} - -async function applyAmountDataUpdates({ - amount, - messenger, - transaction, -}: { - amount: string; - messenger: TransactionPayControllerMessenger; - transaction: TransactionMeta; -}): Promise { - const { updates } = await messenger.call( - 'TransactionPayController:getAmountData', - { - amount, - transaction, - }, - ); - - if (!updates.length) { - throw new Error('getAmountData returned no updates for atomic promotion'); - } - - const nestedTransactions = transaction.nestedTransactions?.map( - (nestedTransaction) => ({ - ...nestedTransaction, - }), - ); - - if (!nestedTransactions?.length) { - throw new Error('Missing nested transactions for atomic promotion'); - } - - for (const { nestedTransactionIndex, data } of updates) { - if (!nestedTransactions[nestedTransactionIndex]) { - throw new Error( - 'getAmountData returned an unusable nested transaction update', - ); - } - - nestedTransactions[nestedTransactionIndex].data = data; - } - - const requiredAssets = transaction.requiredAssets?.map((requiredAsset) => ({ - ...requiredAsset, - })); - - if (!requiredAssets?.[0]) { - throw new Error('Missing required assets for atomic promotion'); - } - - requiredAssets[0].amount = toHex(BigInt(amount)); - - return { - ...transaction, - nestedTransactions, - requiredAssets, - }; -} - -function assertAtomicPromotionIsValid({ - discoveryQuote, - promotedQuote, - request, - targetAmount, -}: { - discoveryQuote: TransactionPayQuote; - promotedQuote: TransactionPayQuote; - request: QuoteRequest; - targetAmount: string; -}): void { - if (!isSubsidizedRelayQuote(promotedQuote.original)) { - throw new Error('Promoted quote lost subsidy'); - } - - /* istanbul ignore next: atomic re-quotes always request exact-output; defensive guard. */ - if (promotedQuote.original.request.tradeType !== 'EXACT_OUTPUT') { - throw new Error('Promoted quote did not return EXACT_OUTPUT'); - } - - /* istanbul ignore next: atomic re-quotes always embed funding and delegation calls; defensive guard. */ - if (!promotedQuote.original.request.txs?.length) { - throw new Error('Promoted quote is missing embedded calls'); - } - - const sourceCost = getPromotedSourceCost({ promotedQuote, request }); - - const budget = new BigNumber(request.sourceTokenAmount); - - if (sourceCost.isGreaterThan(budget)) { - throw new Error('Promoted quote exceeds max spend budget'); - } - - /* istanbul ignore next: re-quote hardcodes isMaxAmount false; defensive guard. */ - if (promotedQuote.request.isMaxAmount !== false) { - throw new Error( - 'Promoted quote request isMaxAmount was not cleared before restore', - ); - } - - /* istanbul ignore next: re-quote hardcodes atomic true; defensive guard. */ - if (promotedQuote.request.atomic !== true) { - throw new Error('Promoted quote request atomic flag was not set'); - } - - /* istanbul ignore next: re-quote hardcodes the discovery target; defensive guard. */ - if (promotedQuote.request.targetAmountMinimum !== targetAmount) { - throw new Error('Promoted quote target amount minimum was not preserved'); - } - - if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) { - throw new Error('Discovery quote target amount changed before promotion'); - } -} - -function getPromotedSourceCost({ - promotedQuote, - request, -}: { - promotedQuote: TransactionPayQuote; - request: QuoteRequest; -}): BigNumber { - const sourceAmount = new BigNumber(promotedQuote.sourceAmount.raw); - const sourceTokenIsNative = - request.sourceTokenAddress.toLowerCase() === - getNativeToken(request.sourceChainId).toLowerCase(); - - if (!sourceTokenIsNative && !promotedQuote.fees.isSourceGasFeeToken) { - return sourceAmount; - } - - return sourceAmount.plus( - new BigNumber(promotedQuote.fees.sourceNetwork.max.raw), - ); -} - /** * For post-quote flows, fetch an initial quote to compute gas cost in source * token, then re-quote with the source amount reduced by the gas cost. diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts index 097a9cdc4b8..f6302b8ca9d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -22,6 +22,10 @@ import type { RelayExecuteRequest, RelayQuote } from './types.js'; const log = createModuleLogger(projectLogger, 'relay-strategy'); const RELAY_EXECUTE_ERROR_PREFIX = 'Execute: '; +export function isSubsidizedRelayQuote(quote: RelayQuote): boolean { + return Number(quote.fees?.subsidized?.amountUsd ?? '0') > 0; +} + export async function submitViaRelayExecute( quote: TransactionPayQuote, transaction: TransactionMeta, @@ -75,10 +79,6 @@ async function submitViaRelayExecuteInternal( return FALLBACK_HASH; } -export function isSubsidizedRelayQuote(quote: RelayQuote): boolean { - return Number(quote.fees?.subsidized?.amountUsd ?? '0') > 0; -} - function stripRelayExecuteMarker(requestId: string): string { return requestId.includes('#mm') ? requestId.slice(0, requestId.indexOf('#mm')) diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 89b0b879f9a..d029e1e85b7 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -496,7 +496,6 @@ export type TransactionPaySourceAmount = { * and `detail` on {@link QuoteErrorInfo}. */ export type QuoteErrorReason = - | 'atomic-promotion-failed' | 'balance-unavailable' | 'insufficient-source-balance' | 'insufficient-transfer-balance' 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 849bcba09b4..dd6de0003eb 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -37,6 +37,7 @@ import { isEIP7702Chain, getEIP7702UpgradeContractAddress, isRelayExecuteEnabled, + isAtomicMaxPromotionEnabled, isRelayValidationEnabled, getFeatureFlags, getGasBuffer, @@ -768,6 +769,135 @@ describe('Feature Flags Utils', () => { }); }); + describe('isAtomicMaxPromotionEnabled', () => { + it('returns true for a Money Account deposit when no flag is set', () => { + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(true); + }); + + it('returns false for a non-Money Account deposit when no flag is set', () => { + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.perpsDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns false when default is false', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxPromotionEnabled: { default: false } }, + }, + }, + }, + }); + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns true when default is true', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxPromotionEnabled: { default: true } }, + }, + }, + }, + }); + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.perpsDeposit, + } as TransactionMeta), + ).toBe(true); + }); + + it('returns true when per-type override is true and default is false', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { + atomicMaxPromotionEnabled: { + default: false, + transactionTypes: { + [TransactionType.perpsDeposit]: true, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.perpsDeposit, + } as TransactionMeta), + ).toBe(true); + }); + + it('returns false when per-type override is false and default is true', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { + atomicMaxPromotionEnabled: { + default: true, + transactionTypes: { + [TransactionType.moneyAccountDeposit]: false, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns default value for a type with no per-type override', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { + atomicMaxPromotionEnabled: { + default: true, + transactionTypes: { + [TransactionType.perpsDeposit]: false, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxPromotionEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(true); + }); + }); + describe('isChainExcludedFromInfura', () => { it('returns false when no feature flags are set', () => { expect(isChainExcludedFromInfura(messenger, CHAIN_ID_MOCK)).toBe(false); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 349c69941a0..f39da674eb6 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1,8 +1,8 @@ -import { hasTransactionType } from '@metamask/transaction-controller'; -import type { - TransactionMeta, +import { + hasTransactionType, TransactionType, } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -195,10 +195,16 @@ export type RelayValidationEnabledConfig = { transactionTypes?: Partial>; }; +export type AtomicMaxPromotionEnabledConfig = { + default?: boolean; + transactionTypes?: Partial>; +}; + type FeatureFlagsExtendedRaw = { excludeChainIdsFromInfura?: Hex[]; payStrategies?: { relay?: { + atomicMaxPromotionEnabled?: AtomicMaxPromotionEnabledConfig; gaslessEnabled?: boolean; validationEnabled?: RelayValidationEnabledConfig; }; @@ -689,6 +695,54 @@ export function isRelayValidationEnabled( return validationEnabled?.default ?? false; } +/** + * Whether atomic max promotion is enabled for a given transaction. + * + * Gates promotion of subsidized max deposits to atomic exact-output quotes. + * + * Configured via the `payStrategies.relay.atomicMaxPromotionEnabled` flag, an + * object `{ default?: boolean; transactionTypes?: { [type]?: boolean } }`: + * a matching `transactionTypes[type]` entry overrides `default` when the + * transaction, or any nested transaction, has that type. + * + * When the flag is absent, only direct Money Account deposits are eligible, + * preserving the pre-flag promotion scope. + * + * @param messenger - Controller messenger. + * @param transaction - Transaction being quoted. Its top-level and nested + * types are matched against the `transactionTypes` overrides. + * @returns True if atomic max promotion may be attempted. + */ +export function isAtomicMaxPromotionEnabled( + messenger: TransactionPayControllerMessenger, + transaction?: TransactionMeta, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay_extended as + | FeatureFlagsExtendedRaw + | undefined) ?? {}; + + const promotionEnabled = + featureFlags.payStrategies?.relay?.atomicMaxPromotionEnabled; + + const transactionTypes = promotionEnabled?.transactionTypes ?? {}; + + // A per-type override wins over the global `default` toggle. An override + // matches when the transaction, or any nested transaction, has that type. + for (const [type, enabled] of Object.entries(transactionTypes)) { + if (hasTransactionType(transaction, [type as TransactionType])) { + return enabled; + } + } + + // Absent flag preserves the original scope: direct Money Account deposits. + return ( + promotionEnabled?.default ?? + hasTransactionType(transaction, [TransactionType.moneyAccountDeposit]) + ); +} + /** * Whether a chain is excluded from preferring Infura for balance queries. * diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index e5c20bcc2f8..66738cf6f16 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -483,10 +483,10 @@ describe('Quotes Utils', () => { expect(secondStrategy.getQuotes).toHaveBeenCalled(); }); - it('short-circuits and does not try next strategy when QuoteError has terminal atomic-promotion-failed reason', async () => { + it('short-circuits and does not try next strategy when QuoteError has terminal prefixed atomic promotion failure', async () => { const terminalError = new QuoteError({ - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }); const firstStrategy = { @@ -531,8 +531,8 @@ describe('Quotes Utils', () => { expect(transactionDataMock).toMatchObject({ quotes: [], quoteError: { - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }, isLoading: false, }); @@ -588,7 +588,7 @@ describe('Quotes Utils', () => { }); }); - it('terminal atomic-promotion-failed wins over an earlier ordinary no-quotes error', async () => { + it('terminal prefixed atomic promotion failure wins over an earlier ordinary no-quotes error', async () => { const firstStrategy = { supports: jest.fn().mockReturnValue(true), getQuotes: jest.fn().mockRejectedValue( @@ -604,8 +604,8 @@ describe('Quotes Utils', () => { supports: jest.fn().mockReturnValue(true), getQuotes: jest.fn().mockRejectedValue( new QuoteError({ - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }), ), execute: jest.fn(), @@ -653,8 +653,8 @@ describe('Quotes Utils', () => { expect(transactionDataMock).toMatchObject({ quotes: [], quoteError: { - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }, }); }); @@ -1574,7 +1574,7 @@ describe('Quotes Utils', () => { it('publishes fail-closed state (empty quotes, terminal error, isLoading false) and skips subsequent strategies when atomic promotion fails on a refresh with a prior executable quote in state', async () => { // Seed a prior executable quote in state so this call is a refresh, not // an initial fetch — proving the block cannot leave the previous quote - // selected once a terminal atomic-promotion-failed surfaces. + // selected once a terminal prefixed promotion failure surfaces. const priorQuote = { ...QUOTE_MOCK, strategy: TransactionPayStrategy.Relay, @@ -1584,8 +1584,8 @@ describe('Quotes Utils', () => { supports: jest.fn().mockReturnValue(true), getQuotes: jest.fn().mockRejectedValue( new QuoteError({ - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }), ), getBatchTransactions: jest.fn(), @@ -1642,8 +1642,8 @@ describe('Quotes Utils', () => { expect(transactionDataMock).toMatchObject({ quotes: [], quoteError: { - message: 'Atomic quote promotion failed', - reason: 'atomic-promotion-failed', + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', }, isLoading: false, }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 1917d0470a0..b80243f81ad 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -6,6 +6,7 @@ import { createModuleLogger } from '@metamask/utils'; import { PaymentOverride, TransactionPayStrategy } from '../constants.js'; import { projectLogger } from '../logger.js'; +import { ATOMIC_PROMOTION_FAILURE_PREFIX } from '../strategy/relay/constants.js'; import type { QuoteRequest, QuoteErrorInfo, @@ -760,12 +761,16 @@ async function getQuotes( if ( isQuoteError(caughtError) && - caughtError.info.reason === 'atomic-promotion-failed' + caughtError.info.reason === 'no-quotes' && + caughtError.info.message.startsWith(ATOMIC_PROMOTION_FAILURE_PREFIX) ) { - log('Terminal atomic-promotion-failed, aborting remaining strategies', { - strategy: name, - transactionId, - }); + log( + 'Terminal atomic promotion failure, aborting remaining strategies', + { + strategy: name, + transactionId, + }, + ); return { batchTransactions: [], error: caughtError.info, From 793fe598f451f94bf855b0924eb3c8b2ced73924 Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 09:43:24 +0300 Subject: [PATCH 07/15] refactor(transaction-pay-controller): remove non-max atomic demote path --- .../src/strategy/relay/relay-max.ts | 44 ------- .../src/strategy/relay/relay-quotes.test.ts | 114 ------------------ .../src/strategy/relay/relay-quotes.ts | 9 +- 3 files changed, 1 insertion(+), 166 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index f47be37a0f6..7e6b71fdead 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -1,5 +1,4 @@ import { toHex } from '@metamask/controller-utils'; -import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -678,46 +677,3 @@ function assertAtomicPromotionIsValid({ throw new Error('Discovery quote target amount changed before promotion'); } } - -export async function maybeDemoteUnsubsidizedAtomicQuote({ - fullRequest, - getSingleQuote, - quote, - request, -}: { - fullRequest: PayStrategyGetQuotesRequest; - getSingleQuote: GetSingleQuoteFn; - quote: TransactionPayQuote; - request: QuoteRequest; -}): Promise> { - if ( - !shouldAttemptAtomicDemotion( - request, - fullRequest.transaction, - fullRequest.messenger, - ) - ) { - return quote; - } - - if (isSubsidizedRelayQuote(quote.original)) { - return quote; - } - - return getSingleQuote({ ...request, atomic: false }, fullRequest); -} - -function shouldAttemptAtomicDemotion( - request: QuoteRequest, - transaction: TransactionMeta, - messenger: TransactionPayControllerMessenger, -): boolean { - return ( - isAtomicMaxPromotionEnabled(messenger, transaction) && - request.isMaxAmount !== true && - request.isPostQuote !== true && - request.atomic !== false && - transaction.type !== TransactionType.perpsDepositAndOrder && - transaction.type !== TransactionType.predictDepositAndOrder - ); -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index b1944151a37..efedacac4a1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -4433,120 +4433,6 @@ describe('Relay Quotes Utils', () => { }); }); - describe('atomic demote for unsubsidized quotes', () => { - const MA_NON_MAX_REQUEST: QuoteRequest = { - ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: true, - isMaxAmount: false, - sourceBalanceRaw: '50000000', - sourceTokenAmount: '50000000', - targetAmountMinimum: '49000000', - }; - - it('demotes an unsubsidized atomic quote to a sponsored non-atomic quote', async () => { - mockRelayResponses( - buildRelayQuote({ subsidizedAmountUsd: '0' }), - buildRelayQuote({ requestId: '0x2' }), - ); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MA_NON_MAX_REQUEST], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(2); - expect(result[0].request.atomic).toBe(false); - }); - - it('stands on a subsidized atomic quote without a second fetch', async () => { - mockRelayResponses(); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MA_NON_MAX_REQUEST], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(result[0].request.atomic).toBe(true); - }); - - it('skips demote when the request is already non-atomic', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), - } as never); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [{ ...MA_NON_MAX_REQUEST, atomic: false }], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(result[0].request.atomic).toBe(false); - }); - - it('skips demote for non-Money Account deposits', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), - } as never); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [{ ...MA_NON_MAX_REQUEST, atomic: true }], - transaction: TRANSACTION_META_MOCK, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(result[0].request.atomic).toBe(true); - }); - - it('skips demote for order flows even when the flag allows the nested type', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), - } as never); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MA_NON_MAX_REQUEST], - transaction: { - ...MONEY_ACCOUNT_MAX_TRANSACTION, - type: TransactionType.perpsDepositAndOrder, - }, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(result[0].request.atomic).toBe(true); - }); - - it('propagates demote re-quote failure as an ordinary unwrapped error', async () => { - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), - } as never) - .mockRejectedValueOnce(new Error('demote boom')); - - const error = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MA_NON_MAX_REQUEST], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }).catch((caught) => caught); - - expect((error as Error).message).toBe('demote boom'); - }); - }); - describe('HyperLiquid source (isHyperliquidSource)', () => { const HL_REQUEST: QuoteRequest = { ...QUOTE_REQUEST_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 8e9b16f5afc..30cbf3057f2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -70,7 +70,6 @@ import { fetchRelayQuote } from './relay-api.js'; import { getRelayMaxGasStationQuote, isPromotedSubsidizedMaxMoneyAccountQuote, - maybeDemoteUnsubsidizedAtomicQuote, maybePromoteSubsidizedMaxMoneyAccountQuote, throwAtomicPromotionFailed, } from './relay-max.js'; @@ -194,13 +193,7 @@ async function getQuoteWithMaxAmountHandling( const { isMaxAmount } = request; if (!isMaxAmount) { - const quote = await getQuoteWithPostQuoteGasHandling(request, fullRequest); - return maybeDemoteUnsubsidizedAtomicQuote({ - fullRequest, - getSingleQuote, - quote, - request, - }); + return getQuoteWithPostQuoteGasHandling(request, fullRequest); } const discoveryQuote = await getRelayMaxGasStationQuote( From aa57e3d178e26bca27037bd6669fcff60ed6cd3a Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 10:47:00 +0300 Subject: [PATCH 08/15] refactor(transaction-pay-controller): drop discovery target shape check --- .../src/strategy/relay/relay-max.ts | 26 +++---------------- .../src/strategy/relay/relay-quotes.test.ts | 22 +++++++--------- 2 files changed, 12 insertions(+), 36 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index 7e6b71fdead..430ecbfdb29 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -523,11 +523,9 @@ export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ }, ); - assertAtomicPromotionIsValid({ - discoveryQuote, - promotedQuote, - targetAmount, - }); + if (!isSubsidizedRelayQuote(promotedQuote.original)) { + throw new Error('Promoted quote lost subsidy'); + } return { ...promotedQuote, @@ -659,21 +657,3 @@ async function applyAmountDataUpdates({ requiredAssets, }; } - -function assertAtomicPromotionIsValid({ - discoveryQuote, - promotedQuote, - targetAmount, -}: { - discoveryQuote: TransactionPayQuote; - promotedQuote: TransactionPayQuote; - targetAmount: string; -}): void { - if (!isSubsidizedRelayQuote(promotedQuote.original)) { - throw new Error('Promoted quote lost subsidy'); - } - - if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) { - throw new Error('Discovery quote target amount changed before promotion'); - } -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index efedacac4a1..079867378d9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -4342,22 +4342,18 @@ describe('Relay Quotes Utils', () => { }); }); - it('fails atomic promotion terminally when the discovery target changes shape before promotion', async () => { + it('promotes when the discovery target needs normalization', async () => { mockRelayResponses(buildRelayQuote({ amountOut: '099000000' })); - await expect( - getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [MONEY_ACCOUNT_MAX_REQUEST], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }), - ).rejects.toMatchObject({ - info: expect.objectContaining({ - reason: 'no-quotes', - message: expect.stringContaining('Atomic promotion failed'), - }), + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_MAX_REQUEST], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); + + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); }); it('fails atomic promotion terminally with string detail when promotion fails with a non-error', async () => { From 55ba79f1cc1c7523a0bcfc4251e6210b29f33c86 Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 14:20:24 +0300 Subject: [PATCH 09/15] feat(transaction-pay-controller): honor atomic hints for max quotes --- .../src/strategy/relay/relay-max.ts | 44 ++- .../src/strategy/relay/relay-quotes.test.ts | 355 +++++++++++++++++- .../src/strategy/relay/relay-quotes.ts | 69 +++- 3 files changed, 438 insertions(+), 30 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index 430ecbfdb29..737d81258ce 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -501,26 +501,18 @@ export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ discoveryQuote.original.details.currencyOut.amount, ); - const transactionClone = cloneTransactionForPromotion( - fullRequest.transaction, - ); - const promotionTransaction = await applyAmountDataUpdates({ + const preparedRequest = await prepareAtomicMaxAmountQuoteRequest({ amount: targetAmount, - messenger: fullRequest.messenger, - transaction: transactionClone, + fullRequest, }); const promotedQuote = await getSingleQuote( { ...request, atomic: true, - isMaxAmount: false, targetAmountMinimum: targetAmount, }, - { - ...fullRequest, - transaction: promotionTransaction, - }, + preparedRequest, ); if (!isSubsidizedRelayQuote(promotedQuote.original)) { @@ -657,3 +649,33 @@ async function applyAmountDataUpdates({ requiredAssets, }; } + +/** + * Rebuild amount-dependent calls on a clone before an atomic max quote. + * + * @param options - Amount and quote context. + * @param options.amount - Destination amount in raw target-token units. + * @param options.fullRequest - Original quote context. + * @returns Quote context with synchronized calldata and required assets. + */ +export async function prepareAtomicMaxAmountQuoteRequest({ + amount, + fullRequest, +}: { + amount: string; + fullRequest: PayStrategyGetQuotesRequest; +}): Promise { + const transactionClone = cloneTransactionForPromotion( + fullRequest.transaction, + ); + const promotionTransaction = await applyAmountDataUpdates({ + amount, + messenger: fullRequest.messenger, + transaction: transactionClone, + }); + + return { + ...fullRequest, + transaction: promotionTransaction, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 079867378d9..0f169fabfbb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -479,7 +479,7 @@ describe('Relay Quotes Utils', () => { ); }); - it('throws if isMaxAmount is true and transaction includes data', async () => { + it('rejects max included transactions outside the atomic promotion scope', async () => { await expect( getRelayQuotes({ accountSupports7702: true, @@ -487,9 +487,7 @@ describe('Relay Quotes Utils', () => { requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: { ...TRANSACTION_META_MOCK, - txParams: { - data: '0xabc' as Hex, - }, + txParams: { data: '0xabc' as Hex }, } as TransactionMeta, }), ).rejects.toThrow( @@ -497,6 +495,34 @@ describe('Relay Quotes Utils', () => { ); }); + it('skips transaction embedding when isMaxAmount is true and atomic is false', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true, atomic: false }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_INPUT'); + expect(body.txs).toBeUndefined(); + }); + it('includes transactions in request', async () => { successfulFetchMock.mockResolvedValue({ ok: true, @@ -4045,6 +4071,289 @@ describe('Relay Quotes Utils', () => { ); }); + it('uses one atomic exact-output quote when an explicit atomic max target hint remains subsidized', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-atomic-hint', + tradeType: 'EXACT_OUTPUT', + }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(getAmountDataMock).toHaveBeenCalledWith({ + amount: DISCOVERY_OUTPUT_RAW, + transaction: expect.objectContaining({ + nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], + requiredAssets: [ + expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) }), + ], + }), + }); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + nestedTransactions: [ + expect.objectContaining({ data: UPDATED_DEPOSIT_DATA }), + ], + requiredAssets: [ + expect.objectContaining({ amount: toHex(DISCOVERY_OUTPUT_RAW) }), + ], + }), + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body).toStrictEqual( + expect.objectContaining({ + amount: DISCOVERY_OUTPUT_RAW, + tradeType: 'EXACT_OUTPUT', + txs: [ + expect.objectContaining({ + data: expect.stringContaining( + BigInt(DISCOVERY_OUTPUT_RAW).toString(16).padStart(64, '0'), + ), + }), + expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), + ], + }), + ); + expect(result[0].request).toStrictEqual( + expect.objectContaining({ + atomic: true, + isMaxAmount: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }), + ); + expect(result[0].isInputBased).toBe(false); + }); + + it('defaults an omitted atomic max target hint to atomic exact-output', async () => { + const defaultAtomicRequest = { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: undefined, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-default-atomic-hint', + tradeType: 'EXACT_OUTPUT', + }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [defaultAtomicRequest], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(DISCOVERY_OUTPUT_RAW); + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); + expect(result[0].request.sourceTokenAmount).toBe(SOURCE_CAP_RAW); + }); + + it('demotes an unsubsidized atomic max target hint to one non-atomic exact-input max quote without re-promotion', async () => { + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-unsubsidized-hint', + subsidizedAmountUsd: '0', + tradeType: 'EXACT_OUTPUT', + }), + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => + buildRelayQuote({ + requestId: '0x-demoted-max', + tradeType: 'EXACT_INPUT', + }), + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + const hintBody = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + const demotedBody = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(hintBody.tradeType).toBe('EXACT_OUTPUT'); + expect(hintBody.amount).toBe(DISCOVERY_OUTPUT_RAW); + expect(demotedBody.tradeType).toBe('EXACT_INPUT'); + expect(demotedBody.amount).toBe(SOURCE_CAP_RAW); + expect(demotedBody.txs).toBeUndefined(); + expect(result[0].request.atomic).toBe(false); + expect(result[0].isInputBased).toBe(true); + }); + + it('preserves atomic max target hint fetch errors without demoting or re-promoting', async () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + successfulFetchMock.mockRejectedValue(abortError); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow('aborted'); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + }); + + it('uses non-atomic exact-input discovery and existing promotion when the atomic max target hint is zero', async () => { + mockRelayResponses(); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: '0', + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + const discoveryBody = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + const promotionBody = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(discoveryBody.tradeType).toBe('EXACT_INPUT'); + expect(discoveryBody.amount).toBe(SOURCE_CAP_RAW); + expect(discoveryBody.txs).toBeUndefined(); + expect(promotionBody.tradeType).toBe('EXACT_OUTPUT'); + expect(promotionBody.amount).toBe(DISCOVERY_OUTPUT_RAW); + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); + }); + + it('uses non-atomic exact-input max quoting when the atomic max promotion flag is off despite an atomic hint', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxPromotionEnabled: { default: false } }, + }, + }, + }, + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.tradeType).toBe('EXACT_INPUT'); + expect(body.txs).toBeUndefined(); + expect(result[0].request.atomic).toBe(false); + }); + + it('leaves non-max atomic exact-output quoting unchanged', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-non-max', + tradeType: 'EXACT_OUTPUT', + }), + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + isMaxAmount: false, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(DISCOVERY_OUTPUT_RAW); + }); + it('fails atomic promotion terminally when promotion loses subsidy after positive subsidy detection', async () => { mockRelayResponses( buildRelayQuote(), @@ -4222,19 +4531,33 @@ describe('Relay Quotes Utils', () => { ).rejects.toThrow('validation boom'); }); - it('rejects an already-atomic max quote upstream without terminal wrapping', async () => { - mockRelayResponses(buildRelayQuote({ subsidizedAmountUsd: '0' })); + it('returns an atomic-first quote when the request is already atomic max', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-atomic-request', + tradeType: 'EXACT_OUTPUT', + }), + } as never); - await expect( - getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic: true }], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }), - ).rejects.toThrow( - 'Max amount quotes do not support included transactions', - ); + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); }); it('fails atomic promotion terminally when the discovery target is not a positive integer', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 30cbf3057f2..cf197cc0a8c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -42,6 +42,7 @@ import { getRelayOriginGasOverhead, getSlippage, getStablecoins, + isAtomicMaxPromotionEnabled, isEIP7702Chain, isRelayExecuteEnabled, } from '../../utils/feature-flags.js'; @@ -71,8 +72,10 @@ import { getRelayMaxGasStationQuote, isPromotedSubsidizedMaxMoneyAccountQuote, maybePromoteSubsidizedMaxMoneyAccountQuote, + prepareAtomicMaxAmountQuoteRequest, throwAtomicPromotionFailed, } from './relay-max.js'; +import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -196,8 +199,62 @@ async function getQuoteWithMaxAmountHandling( return getQuoteWithPostQuoteGasHandling(request, fullRequest); } + const isAtomicPromotionEligible = isAtomicMaxPromotionEnabled( + fullRequest.messenger, + fullRequest.transaction, + ); + const targetAmount = new BigNumber(request.targetAmountMinimum); + const hasPositiveTargetAmount = + targetAmount.isFinite() && targetAmount.isInteger() && targetAmount.gt(0); + + if ( + isAtomicPromotionEligible && + hasPositiveTargetAmount && + request.atomic !== false && + request.isPostQuote !== true + ) { + const preparedRequest = await prepareAtomicMaxAmountQuoteRequest({ + amount: targetAmount.toFixed(0), + fullRequest, + }); + + const atomicFirstQuote = await getSingleQuote( + { + ...request, + atomic: true, + targetAmountMinimum: targetAmount.toFixed(0), + }, + preparedRequest, + ); + + if (isSubsidizedRelayQuote(atomicFirstQuote.original)) { + return atomicFirstQuote; + } + + log('Atomic max quote was not subsidized, re-quoting non-atomically'); + + return getRelayMaxGasStationQuote( + { + ...request, + atomic: false, + }, + fullRequest, + getSingleQuote, + ); + } + + const discoveryRequest = + request.atomic !== false && + request.isPostQuote !== true && + (isAtomicPromotionEligible || + hasTransactionType(fullRequest.transaction, [ + TransactionType.moneyAccountDeposit, + ])) + ? { ...request, atomic: false } + : request; + const discoveryQuote = await getRelayMaxGasStationQuote( - request, + discoveryRequest, fullRequest, getSingleQuote, ); @@ -206,7 +263,7 @@ async function getQuoteWithMaxAmountHandling( discoveryQuote, fullRequest, getSingleQuote, - request, + request: discoveryRequest, }); } @@ -559,7 +616,13 @@ async function processTransactions( return true; } - if (isMaxAmount) { + // Eligible atomic max requests carry a destination amount, so the calls + // below use EXACT_OUTPUT rather than the usual max EXACT_INPUT quote. + if ( + isMaxAmount && + (request.isPostQuote === true || + !isAtomicMaxPromotionEnabled(messenger, transaction)) + ) { throw new Error('Max amount quotes do not support included transactions'); } From 8a5459d56f4f95541f7fbc0861a7672a7df9b121 Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 14:20:24 +0300 Subject: [PATCH 10/15] docs(transaction-pay-controller): describe atomic max hint handling --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f835a04ee89..c00104dbda5 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) -- Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. ([#10224](https://github.com/MetaMask/core/pull/10224)) +- Subsidized max Money Account Relay deposits now support atomic `EXACT_OUTPUT` quoting for eligible exact-output hints, while unsubsidized or ineligible hints continue to fall back to the existing non-atomic max discovery flow. ([#10224](https://github.com/MetaMask/core/pull/10224)) - Failed atomic promotions are terminal `no-quotes` errors prefixed with `Atomic promotion failed`, and will block rather than silently fall back. - Non-subsidized max behavior is unchanged. From d50b3de7a691e8b15db5d6789b8fc99ab7fda11e Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 14:34:00 +0300 Subject: [PATCH 11/15] fix(transaction-pay-controller): derive max output from source budget --- .../src/strategy/relay/relay-max.ts | 48 +--- .../src/strategy/relay/relay-quotes.test.ts | 238 ++++++------------ .../src/strategy/relay/relay-quotes.ts | 60 +---- 3 files changed, 96 insertions(+), 250 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index 737d81258ce..8111ab661a6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -501,9 +501,10 @@ export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ discoveryQuote.original.details.currencyOut.amount, ); - const preparedRequest = await prepareAtomicMaxAmountQuoteRequest({ + const promotionTransaction = await applyAmountDataUpdates({ amount: targetAmount, - fullRequest, + messenger: fullRequest.messenger, + transaction: cloneTransactionForPromotion(fullRequest.transaction), }); const promotedQuote = await getSingleQuote( @@ -512,21 +513,14 @@ export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ atomic: true, targetAmountMinimum: targetAmount, }, - preparedRequest, + { ...fullRequest, transaction: promotionTransaction }, ); if (!isSubsidizedRelayQuote(promotedQuote.original)) { throw new Error('Promoted quote lost subsidy'); } - return { - ...promotedQuote, - request: { - ...promotedQuote.request, - atomic: true, - isMaxAmount: true, - }, - }; + return promotedQuote; } catch (error) { return throwAtomicPromotionFailed(error); } @@ -541,7 +535,7 @@ export function throwAtomicPromotionFailed(error: unknown): never { }); } -export function isPromotedSubsidizedMaxMoneyAccountQuote( +export function isSubsidizedAtomicMaxQuote( quote: TransactionPayQuote, ): boolean { return ( @@ -649,33 +643,3 @@ async function applyAmountDataUpdates({ requiredAssets, }; } - -/** - * Rebuild amount-dependent calls on a clone before an atomic max quote. - * - * @param options - Amount and quote context. - * @param options.amount - Destination amount in raw target-token units. - * @param options.fullRequest - Original quote context. - * @returns Quote context with synchronized calldata and required assets. - */ -export async function prepareAtomicMaxAmountQuoteRequest({ - amount, - fullRequest, -}: { - amount: string; - fullRequest: PayStrategyGetQuotesRequest; -}): Promise { - const transactionClone = cloneTransactionForPromotion( - fullRequest.transaction, - ); - const promotionTransaction = await applyAmountDataUpdates({ - amount, - messenger: fullRequest.messenger, - transaction: transactionClone, - }); - - return { - ...fullRequest, - transaction: promotionTransaction, - }; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 0f169fabfbb..41b75110a6d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -4071,134 +4071,93 @@ describe('Relay Quotes Utils', () => { ); }); - it('uses one atomic exact-output quote when an explicit atomic max target hint remains subsidized', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => - buildRelayQuote({ - amountIn: DISCOVERY_OUTPUT_RAW, - requestId: '0x-atomic-hint', - tradeType: 'EXACT_OUTPUT', - }), - } as never); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [ - { - ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, - }, - ], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); + it.each([true, undefined])( + 'discovers max output instead of reusing the old required amount with atomic %s', + async (atomic) => { + mockRelayResponses(); - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(getAmountDataMock).toHaveBeenCalledWith({ - amount: DISCOVERY_OUTPUT_RAW, - transaction: expect.objectContaining({ - nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], - requiredAssets: [ - expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) }), - ], - }), - }); - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - nestedTransactions: [ - expect.objectContaining({ data: UPDATED_DEPOSIT_DATA }), - ], - requiredAssets: [ - expect.objectContaining({ amount: toHex(DISCOVERY_OUTPUT_RAW) }), + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic, + targetAmountMinimum: OLD_DEPOSIT_RAW, + }, ], - }), - }); + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); - expect(body).toStrictEqual( - expect.objectContaining({ + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + const discoveryBody = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(discoveryBody.tradeType).toBe('EXACT_INPUT'); + expect(discoveryBody.amount).toBe(SOURCE_CAP_RAW); + expect(discoveryBody.txs).toBeUndefined(); + expect(getAmountDataMock).toHaveBeenCalledWith({ amount: DISCOVERY_OUTPUT_RAW, - tradeType: 'EXACT_OUTPUT', - txs: [ - expect.objectContaining({ - data: expect.stringContaining( - BigInt(DISCOVERY_OUTPUT_RAW).toString(16).padStart(64, '0'), - ), - }), - expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), - ], - }), - ); - expect(result[0].request).toStrictEqual( - expect.objectContaining({ - atomic: true, - isMaxAmount: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, - }), - ); - expect(result[0].isInputBased).toBe(false); - }); + transaction: expect.objectContaining({ + nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], + requiredAssets: [ + expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) }), + ], + }), + }); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + nestedTransactions: [ + expect.objectContaining({ data: UPDATED_DEPOSIT_DATA }), + ], + requiredAssets: [ + expect.objectContaining({ + amount: toHex(DISCOVERY_OUTPUT_RAW), + }), + ], + }), + }); - it('defaults an omitted atomic max target hint to atomic exact-output', async () => { - const defaultAtomicRequest = { - ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: undefined, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, - }; + const body = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(body).toStrictEqual( + expect.objectContaining({ + amount: DISCOVERY_OUTPUT_RAW, + tradeType: 'EXACT_OUTPUT', + txs: [ + expect.objectContaining({ + data: expect.stringContaining( + BigInt(DISCOVERY_OUTPUT_RAW).toString(16).padStart(64, '0'), + ), + }), + expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), + ], + }), + ); + expect(result[0].request).toStrictEqual( + expect.objectContaining({ + atomic: true, + isMaxAmount: true, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }), + ); + expect(result[0].isInputBased).toBe(false); + }, + ); - successfulFetchMock.mockResolvedValue({ + it('keeps an unsubsidized max discovery quote despite an atomic hint and old required amount', async () => { + successfulFetchMock.mockResolvedValueOnce({ ok: true, json: async () => buildRelayQuote({ - amountIn: DISCOVERY_OUTPUT_RAW, - requestId: '0x-default-atomic-hint', - tradeType: 'EXACT_OUTPUT', + amountIn: SOURCE_CAP_RAW, + requestId: '0x-unsubsidized-hint', + subsidizedAmountUsd: '0', + tradeType: 'EXACT_INPUT', }), } as never); - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [defaultAtomicRequest], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); - - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(body.tradeType).toBe('EXACT_OUTPUT'); - expect(body.amount).toBe(DISCOVERY_OUTPUT_RAW); - expect(result[0].request.atomic).toBe(true); - expect(result[0].request.isMaxAmount).toBe(true); - expect(result[0].request.sourceTokenAmount).toBe(SOURCE_CAP_RAW); - }); - - it('demotes an unsubsidized atomic max target hint to one non-atomic exact-input max quote without re-promotion', async () => { - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => - buildRelayQuote({ - amountIn: DISCOVERY_OUTPUT_RAW, - requestId: '0x-unsubsidized-hint', - subsidizedAmountUsd: '0', - tradeType: 'EXACT_OUTPUT', - }), - } as never) - .mockResolvedValueOnce({ - ok: true, - json: async () => - buildRelayQuote({ - requestId: '0x-demoted-max', - tradeType: 'EXACT_INPUT', - }), - } as never); - const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -4206,29 +4165,25 @@ describe('Relay Quotes Utils', () => { { ...MONEY_ACCOUNT_MAX_REQUEST, atomic: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + targetAmountMinimum: OLD_DEPOSIT_RAW, }, ], transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); - expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(successfulFetchMock).toHaveBeenCalledTimes(1); const hintBody = JSON.parse( successfulFetchMock.mock.calls[0][1]?.body as string, ); - const demotedBody = JSON.parse( - successfulFetchMock.mock.calls[1][1]?.body as string, - ); - expect(hintBody.tradeType).toBe('EXACT_OUTPUT'); - expect(hintBody.amount).toBe(DISCOVERY_OUTPUT_RAW); - expect(demotedBody.tradeType).toBe('EXACT_INPUT'); - expect(demotedBody.amount).toBe(SOURCE_CAP_RAW); - expect(demotedBody.txs).toBeUndefined(); + expect(hintBody.tradeType).toBe('EXACT_INPUT'); + expect(hintBody.amount).toBe(SOURCE_CAP_RAW); + expect(hintBody.txs).toBeUndefined(); + expect(getAmountDataMock).not.toHaveBeenCalled(); expect(result[0].request.atomic).toBe(false); expect(result[0].isInputBased).toBe(true); }); - it('preserves atomic max target hint fetch errors without demoting or re-promoting', async () => { + it('preserves max discovery fetch errors without attempting promotion', async () => { const abortError = new Error('aborted'); abortError.name = 'AbortError'; successfulFetchMock.mockRejectedValue(abortError); @@ -4531,35 +4486,6 @@ describe('Relay Quotes Utils', () => { ).rejects.toThrow('validation boom'); }); - it('returns an atomic-first quote when the request is already atomic max', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => - buildRelayQuote({ - amountIn: DISCOVERY_OUTPUT_RAW, - requestId: '0x-atomic-request', - tradeType: 'EXACT_OUTPUT', - }), - } as never); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [ - { - ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, - }, - ], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); - - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - expect(result[0].request.atomic).toBe(true); - expect(result[0].request.isMaxAmount).toBe(true); - }); - it('fails atomic promotion terminally when the discovery target is not a positive integer', async () => { mockRelayResponses(buildRelayQuote({ amountOut: '0' })); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index cf197cc0a8c..c11962de0b1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -70,12 +70,10 @@ import { import { fetchRelayQuote } from './relay-api.js'; import { getRelayMaxGasStationQuote, - isPromotedSubsidizedMaxMoneyAccountQuote, + isSubsidizedAtomicMaxQuote, maybePromoteSubsidizedMaxMoneyAccountQuote, - prepareAtomicMaxAmountQuoteRequest, throwAtomicPromotionFailed, } from './relay-max.js'; -import { isSubsidizedRelayQuote } from './relay-submit-execute.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -118,8 +116,6 @@ export async function getRelayQuotes( log('Fetching quotes', requests); - let quotes: TransactionPayQuote[] = []; - try { const normalizedRequests = await Promise.all( requests @@ -147,17 +143,15 @@ export async function getRelayQuotes( log('Normalized requests', normalizedRequests); - quotes = await Promise.all( + const quotes = await Promise.all( normalizedRequests.map((singleRequest) => getQuoteWithMaxAmountHandling(singleRequest, request), ), ); - const promotedQuotes = quotes.filter( - isPromotedSubsidizedMaxMoneyAccountQuote, - ); + const atomicMaxQuotes = quotes.filter(isSubsidizedAtomicMaxQuote); const otherQuotes = quotes.filter( - (quote) => !isPromotedSubsidizedMaxMoneyAccountQuote(quote), + (quote) => !isSubsidizedAtomicMaxQuote(quote), ); if (otherQuotes.length > 0) { @@ -169,11 +163,11 @@ export async function getRelayQuotes( }); } - if (promotedQuotes.length > 0) { + if (atomicMaxQuotes.length > 0) { try { await validateRelayQuotes({ messenger: request.messenger, - quotes: promotedQuotes, + quotes: atomicMaxQuotes, signal: request.signal, transaction: request.transaction, }); @@ -203,46 +197,8 @@ async function getQuoteWithMaxAmountHandling( fullRequest.messenger, fullRequest.transaction, ); - const targetAmount = new BigNumber(request.targetAmountMinimum); - const hasPositiveTargetAmount = - targetAmount.isFinite() && targetAmount.isInteger() && targetAmount.gt(0); - - if ( - isAtomicPromotionEligible && - hasPositiveTargetAmount && - request.atomic !== false && - request.isPostQuote !== true - ) { - const preparedRequest = await prepareAtomicMaxAmountQuoteRequest({ - amount: targetAmount.toFixed(0), - fullRequest, - }); - - const atomicFirstQuote = await getSingleQuote( - { - ...request, - atomic: true, - targetAmountMinimum: targetAmount.toFixed(0), - }, - preparedRequest, - ); - - if (isSubsidizedRelayQuote(atomicFirstQuote.original)) { - return atomicFirstQuote; - } - - log('Atomic max quote was not subsidized, re-quoting non-atomically'); - - return getRelayMaxGasStationQuote( - { - ...request, - atomic: false, - }, - fullRequest, - getSingleQuote, - ); - } - + // The required target amount may predate Max. An atomic hint does not + // establish the output obtainable from the source budget; discover it first. const discoveryRequest = request.atomic !== false && request.isPostQuote !== true && From d43f7b9f4045045e3e6a479cb47a8715aa93d997 Mon Sep 17 00:00:00 2001 From: Repro Date: Thu, 17 Sep 2026 14:34:00 +0300 Subject: [PATCH 12/15] docs(transaction-pay-controller): clarify max discovery requirement --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c00104dbda5..ebe1911530b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) -- Subsidized max Money Account Relay deposits now support atomic `EXACT_OUTPUT` quoting for eligible exact-output hints, while unsubsidized or ineligible hints continue to fall back to the existing non-atomic max discovery flow. ([#10224](https://github.com/MetaMask/core/pull/10224)) +- Subsidized max Money Account Relay deposits now use atomic `EXACT_OUTPUT` quotes with vault calls embedded. The output amount is discovered from the source-token budget first, even when the client hints atomic execution, rather than reusing the original deposit's required amount. ([#10224](https://github.com/MetaMask/core/pull/10224)) - Failed atomic promotions are terminal `no-quotes` errors prefixed with `Atomic promotion failed`, and will block rather than silently fall back. - Non-subsidized max behavior is unchanged. From 82988d3876fad936df30308b223dd845b980f28e Mon Sep 17 00:00:00 2001 From: Repro Date: Tue, 22 Sep 2026 11:23:24 +0300 Subject: [PATCH 13/15] fix(transaction-pay-controller): quote atomic max from source budget --- .../src/strategy/relay/relay-max.ts | 162 ++++++--- .../src/strategy/relay/relay-quotes.test.ts | 335 +++++++++++++----- .../src/strategy/relay/relay-quotes.ts | 36 +- .../src/utils/feature-flags.test.ts | 32 +- .../src/utils/feature-flags.ts | 29 +- 5 files changed, 374 insertions(+), 220 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts index 8111ab661a6..953d60a3359 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-max.ts @@ -11,7 +11,7 @@ import type { TransactionPayQuote, } from '../../types.js'; import { prefixError } from '../../utils/error-prefix.js'; -import { isAtomicMaxPromotionEnabled } from '../../utils/feature-flags.js'; +import { isAtomicMaxEnabled } from '../../utils/feature-flags.js'; import { getGasStationEligibility, getGasStationCostInSourceTokenRaw, @@ -59,6 +59,93 @@ type MaxAmountQuoteContext = { request: QuoteRequest; }; +/** + * Fetch a max quote using the client's atomic hint for subsidized routes. + * + * @param request - Max quote request. + * @param fullRequest - Full quote context. + * @param getSingleQuote - Fetcher for a single Relay quote. + * @returns An atomic subsidized quote or a non-atomic max quote. + */ +export async function getRelayMaxQuote( + request: QuoteRequest, + fullRequest: PayStrategyGetQuotesRequest, + getSingleQuote: GetSingleQuoteFn, +): Promise> { + const { messenger, transaction } = fullRequest; + const { atomic, isPostQuote } = request; + + if (isPostQuote || !isAtomicMaxEnabled(messenger, transaction)) { + return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote); + } + + if (atomic !== false) { + const sourceToken = getTokenInfo( + messenger, + request.sourceTokenAddress, + request.sourceChainId, + ); + const targetToken = getTokenInfo( + messenger, + request.targetTokenAddress, + request.targetChainId, + ); + + if (!sourceToken || !targetToken) { + throw new Error('Token decimals not found for atomic max quote'); + } + + // Fixed-spread subsidized routes exchange stablecoins 1:1 without fees. + // Use the source budget, not the required amount from before Max. + const targetAmount = new BigNumber(request.sourceTokenAmount) + .shiftedBy(targetToken.decimals - sourceToken.decimals) + .toFixed(0, BigNumber.ROUND_DOWN); + const quote = await getAtomicMaxQuote({ + amount: targetAmount, + fullRequest, + getSingleQuote, + request, + }); + + if (isSubsidizedRelayQuote(quote.original)) { + return quote; + } + + return getRelayMaxGasStationQuote( + { ...request, atomic: false }, + fullRequest, + getSingleQuote, + ); + } + + const quote = await getRelayMaxGasStationQuote( + request, + fullRequest, + getSingleQuote, + ); + + if (!isSubsidizedRelayQuote(quote.original)) { + return quote; + } + + try { + const promotedQuote = await getAtomicMaxQuote({ + amount: quote.original.details.currencyOut.amount, + fullRequest, + getSingleQuote, + request, + }); + + if (!isSubsidizedRelayQuote(promotedQuote.original)) { + throw new Error('Promoted quote lost subsidy'); + } + + return promotedQuote; + } catch (error) { + return throwAtomicPromotionFailed(error); + } +} + /** * Returns a Relay max-amount quote using a two-phase gas-station fallback. * @@ -474,56 +561,32 @@ function markQuoteAsMaxGasStation( }; } -export async function maybePromoteSubsidizedMaxMoneyAccountQuote({ - discoveryQuote, +async function getAtomicMaxQuote({ + amount, fullRequest, getSingleQuote, request, }: { - discoveryQuote: TransactionPayQuote; + amount: string; fullRequest: PayStrategyGetQuotesRequest; getSingleQuote: GetSingleQuoteFn; request: QuoteRequest; }): Promise> { - if ( - !shouldAttemptAtomicPromotion( - request, - fullRequest.transaction, - discoveryQuote, - fullRequest.messenger, - ) - ) { - return discoveryQuote; - } - - try { - const targetAmount = validatePositiveIntegerString( - discoveryQuote.original.details.currencyOut.amount, - ); - - const promotionTransaction = await applyAmountDataUpdates({ - amount: targetAmount, - messenger: fullRequest.messenger, - transaction: cloneTransactionForPromotion(fullRequest.transaction), - }); - - const promotedQuote = await getSingleQuote( - { - ...request, - atomic: true, - targetAmountMinimum: targetAmount, - }, - { ...fullRequest, transaction: promotionTransaction }, - ); - - if (!isSubsidizedRelayQuote(promotedQuote.original)) { - throw new Error('Promoted quote lost subsidy'); - } + const targetAmount = validatePositiveIntegerString(amount); + const atomicTransaction = await applyAmountDataUpdates({ + amount: targetAmount, + messenger: fullRequest.messenger, + transaction: cloneTransactionForPromotion(fullRequest.transaction), + }); - return promotedQuote; - } catch (error) { - return throwAtomicPromotionFailed(error); - } + return getSingleQuote( + { + ...request, + atomic: true, + targetAmountMinimum: targetAmount, + }, + { ...fullRequest, transaction: atomicTransaction }, + ); } export function throwAtomicPromotionFailed(error: unknown): never { @@ -545,21 +608,6 @@ export function isSubsidizedAtomicMaxQuote( ); } -function shouldAttemptAtomicPromotion( - request: QuoteRequest, - transaction: TransactionMeta, - discoveryQuote: TransactionPayQuote, - messenger: TransactionPayControllerMessenger, -): boolean { - return ( - isAtomicMaxPromotionEnabled(messenger, transaction) && - request.isMaxAmount === true && - request.isPostQuote !== true && - request.atomic !== true && - isSubsidizedRelayQuote(discoveryQuote.original) - ); -} - function validatePositiveIntegerString(value: string): string { const amount = new BigNumber(value); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 41b75110a6d..7beeb331c95 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -37,6 +37,7 @@ import { getNativeToken, getTokenBalance, getTokenFiatRate, + getTokenInfo, } from '../../utils/token.js'; import { getRelayQuotes } from './relay-quotes.js'; import { validateRelayQuotes } from './relay-validation.js'; @@ -479,12 +480,20 @@ describe('Relay Quotes Utils', () => { ); }); - it('rejects max included transactions outside the atomic promotion scope', async () => { + it('rejects max included transactions for post-quote requests', async () => { await expect( getRelayQuotes({ accountSupports7702: true, + from: FROM_MOCK, messenger, - requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isMaxAmount: true, + isPostQuote: true, + skipProcessTransactions: false, + }, + ], transaction: { ...TRANSACTION_META_MOCK, txParams: { data: '0xabc' as Hex }, @@ -681,30 +690,36 @@ describe('Relay Quotes Utils', () => { expect(getDelegationTransactionMock).not.toHaveBeenCalled(); }); - it('extracts recipient from token transfer', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => QUOTE_MOCK, - } as never); + it.each([false, true])( + 'extracts recipient from token transfer with isMaxAmount %s', + async (isMaxAmount) => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); - await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: { - ...TRANSACTION_META_MOCK, - txParams: { - data: TOKEN_TRANSFER_DATA_MOCK, - }, - } as TransactionMeta, - }); + await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + } as TransactionMeta, + }); - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); - expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase()); - }); + expect(body.recipient).toBe( + TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase(), + ); + }, + ); it('includes transactions from nested transactions', async () => { successfulFetchMock.mockResolvedValue({ @@ -3949,6 +3964,19 @@ describe('Relay Quotes Utils', () => { describe('subsidized max Money Account promotion', () => { beforeEach(() => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: true } }, + }, + }, + }, + }); + jest + .mocked(getTokenInfo) + .mockReturnValue({ decimals: 6, symbol: 'USDC' }); getAmountDataMock.mockResolvedValue({ updates: [ { @@ -4032,7 +4060,7 @@ describe('Relay Quotes Utils', () => { remoteFeatureFlags: { confirmations_pay_extended: { payStrategies: { - relay: { atomicMaxPromotionEnabled: { default: false } }, + relay: { atomicMaxEnabled: { default: false } }, }, }, }, @@ -4072,9 +4100,9 @@ describe('Relay Quotes Utils', () => { }); it.each([true, undefined])( - 'discovers max output instead of reusing the old required amount with atomic %s', + 'quotes the source budget directly instead of the old required amount with atomic %s', async (atomic) => { - mockRelayResponses(); + mockRelayResponses(buildRelayQuote({ amountOut: SOURCE_CAP_RAW })); const result = await getRelayQuotes({ accountSupports7702: true, @@ -4089,15 +4117,12 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); - expect(successfulFetchMock).toHaveBeenCalledTimes(2); - const discoveryBody = JSON.parse( + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(discoveryBody.tradeType).toBe('EXACT_INPUT'); - expect(discoveryBody.amount).toBe(SOURCE_CAP_RAW); - expect(discoveryBody.txs).toBeUndefined(); expect(getAmountDataMock).toHaveBeenCalledWith({ - amount: DISCOVERY_OUTPUT_RAW, + amount: SOURCE_CAP_RAW, transaction: expect.objectContaining({ nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], requiredAssets: [ @@ -4112,23 +4137,20 @@ describe('Relay Quotes Utils', () => { ], requiredAssets: [ expect.objectContaining({ - amount: toHex(DISCOVERY_OUTPUT_RAW), + amount: toHex(SOURCE_CAP_RAW), }), ], }), }); - const body = JSON.parse( - successfulFetchMock.mock.calls[1][1]?.body as string, - ); expect(body).toStrictEqual( expect.objectContaining({ - amount: DISCOVERY_OUTPUT_RAW, + amount: SOURCE_CAP_RAW, tradeType: 'EXACT_OUTPUT', txs: [ expect.objectContaining({ data: expect.stringContaining( - BigInt(DISCOVERY_OUTPUT_RAW).toString(16).padStart(64, '0'), + BigInt(SOURCE_CAP_RAW).toString(16).padStart(64, '0'), ), }), expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), @@ -4139,24 +4161,137 @@ describe('Relay Quotes Utils', () => { expect.objectContaining({ atomic: true, isMaxAmount: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + targetAmountMinimum: SOURCE_CAP_RAW, }), ); expect(result[0].isInputBased).toBe(false); }, ); - it('keeps an unsubsidized max discovery quote despite an atomic hint and old required amount', async () => { - successfulFetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => + it.each([ + { + sourceDecimals: 6, + targetDecimals: 18, + sourceAmount: '100000001', + targetAmount: '100000001000000000000', + }, + { + sourceDecimals: 18, + targetDecimals: 6, + sourceAmount: '100000001999999999999', + targetAmount: '100000001', + }, + ])( + 'converts $sourceDecimals source decimals to $targetDecimals target decimals without rounding up', + async ({ + sourceDecimals, + targetDecimals, + sourceAmount, + targetAmount, + }) => { + jest + .mocked(getTokenInfo) + .mockImplementation((_messenger, address) => ({ + decimals: + address === QUOTE_REQUEST_MOCK.sourceTokenAddress + ? sourceDecimals + : targetDecimals, + symbol: 'USDC', + })); + mockRelayResponses( buildRelayQuote({ - amountIn: SOURCE_CAP_RAW, - requestId: '0x-unsubsidized-hint', - subsidizedAmountUsd: '0', - tradeType: 'EXACT_INPUT', + amountIn: sourceAmount, + amountOut: targetAmount, }), - } as never); + ); + const transaction = cloneDeep(MONEY_ACCOUNT_MAX_TRANSACTION); + + const [quote] = await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + sourceTokenAmount: sourceAmount, + sourceBalanceRaw: sourceAmount, + }, + ], + transaction, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.amount).toBe(targetAmount); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.txs[0].data).toContain( + BigInt(targetAmount).toString(16).padStart(64, '0'), + ); + expect(quote.request.targetAmountMinimum).toBe(targetAmount); + expect(getAmountDataMock).toHaveBeenCalledWith({ + amount: targetAmount, + transaction: expect.any(Object), + }); + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(transaction).toStrictEqual(MONEY_ACCOUNT_MAX_TRANSACTION); + }, + ); + + it.each([true, false])( + 'supports a non-Money Account transaction with atomic %s when enabled by the flag', + async (atomic) => { + mockRelayResponses(); + const [quote] = await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic }], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + type: TransactionType.contractInteraction, + nestedTransactions: [ + { data: '0xaaaa', type: TransactionType.contractInteraction }, + ], + }, + }); + + expect(quote.request.atomic).toBe(true); + expect(successfulFetchMock).toHaveBeenCalledTimes(atomic ? 1 : 2); + }, + ); + + it.each(['source', 'target'])( + 'rejects atomic max when %s token decimals are unavailable', + async (missingToken) => { + jest + .mocked(getTokenInfo) + .mockImplementation((_messenger, address) => + (address === QUOTE_REQUEST_MOCK.sourceTokenAddress) === + (missingToken === 'source') + ? undefined + : { decimals: 6, symbol: 'USDC' }, + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic: true }], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow('Token decimals not found for atomic max quote'); + expect(successfulFetchMock).not.toHaveBeenCalled(); + }, + ); + + it('falls back to non-atomic exact-input when the atomic hint is not subsidized', async () => { + mockRelayResponses( + buildRelayQuote({ subsidizedAmountUsd: '0' }), + buildRelayQuote({ subsidizedAmountUsd: '0' }), + ); const result = await getRelayQuotes({ accountSupports7702: true, @@ -4171,14 +4306,20 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); - expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(successfulFetchMock).toHaveBeenCalledTimes(2); const hintBody = JSON.parse( successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(hintBody.tradeType).toBe('EXACT_INPUT'); + expect(hintBody.tradeType).toBe('EXACT_OUTPUT'); expect(hintBody.amount).toBe(SOURCE_CAP_RAW); - expect(hintBody.txs).toBeUndefined(); - expect(getAmountDataMock).not.toHaveBeenCalled(); + expect(hintBody.txs).toHaveLength(2); + const fallbackBody = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(fallbackBody.tradeType).toBe('EXACT_INPUT'); + expect(fallbackBody.amount).toBe(SOURCE_CAP_RAW); + expect(fallbackBody.txs).toBeUndefined(); + expect(getAmountDataMock).toHaveBeenCalledTimes(1); expect(result[0].request.atomic).toBe(false); expect(result[0].isInputBased).toBe(true); }); @@ -4191,11 +4332,12 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ accountSupports7702: true, + from: FROM_MOCK, messenger, requests: [ { ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: true, + atomic: false, targetAmountMinimum: DISCOVERY_OUTPUT_RAW, }, ], @@ -4206,7 +4348,7 @@ describe('Relay Quotes Utils', () => { expect(successfulFetchMock).toHaveBeenCalledTimes(1); }); - it('uses non-atomic exact-input discovery and existing promotion when the atomic max target hint is zero', async () => { + it('uses the source budget for atomic max when the original target is zero', async () => { mockRelayResponses(); const result = await getRelayQuotes({ @@ -4222,59 +4364,56 @@ describe('Relay Quotes Utils', () => { transaction: MONEY_ACCOUNT_MAX_TRANSACTION, }); - expect(successfulFetchMock).toHaveBeenCalledTimes(2); - const discoveryBody = JSON.parse( + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( successfulFetchMock.mock.calls[0][1]?.body as string, ); - const promotionBody = JSON.parse( - successfulFetchMock.mock.calls[1][1]?.body as string, - ); - expect(discoveryBody.tradeType).toBe('EXACT_INPUT'); - expect(discoveryBody.amount).toBe(SOURCE_CAP_RAW); - expect(discoveryBody.txs).toBeUndefined(); - expect(promotionBody.tradeType).toBe('EXACT_OUTPUT'); - expect(promotionBody.amount).toBe(DISCOVERY_OUTPUT_RAW); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(SOURCE_CAP_RAW); + expect(body.txs).toHaveLength(2); expect(result[0].request.atomic).toBe(true); expect(result[0].request.isMaxAmount).toBe(true); }); - it('uses non-atomic exact-input max quoting when the atomic max promotion flag is off despite an atomic hint', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), - } as never); - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { atomicMaxPromotionEnabled: { default: false } }, + it.each([true, undefined])( + 'rejects included max transactions when atomic max is disabled despite atomic %s', + async (atomic) => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: false } }, + }, }, }, - }, - }); - - const result = await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [ - { - ...MONEY_ACCOUNT_MAX_REQUEST, - atomic: true, - targetAmountMinimum: DISCOVERY_OUTPUT_RAW, - }, - ], - transaction: MONEY_ACCOUNT_MAX_TRANSACTION, - }); + }); - expect(successfulFetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); - expect(body.tradeType).toBe('EXACT_INPUT'); - expect(body.txs).toBeUndefined(); - expect(result[0].request.atomic).toBe(false); - }); + await expect( + getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow( + 'Max amount quotes do not support included transactions', + ); + expect(successfulFetchMock).not.toHaveBeenCalled(); + expect(getAmountDataMock).not.toHaveBeenCalled(); + }, + ); it('leaves non-max atomic exact-output quoting unchanged', async () => { successfulFetchMock.mockResolvedValue({ diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index c11962de0b1..155d55dfeeb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -42,7 +42,7 @@ import { getRelayOriginGasOverhead, getSlippage, getStablecoins, - isAtomicMaxPromotionEnabled, + isAtomicMaxEnabled, isEIP7702Chain, isRelayExecuteEnabled, } from '../../utils/feature-flags.js'; @@ -69,9 +69,8 @@ import { } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; import { - getRelayMaxGasStationQuote, + getRelayMaxQuote, isSubsidizedAtomicMaxQuote, - maybePromoteSubsidizedMaxMoneyAccountQuote, throwAtomicPromotionFailed, } from './relay-max.js'; import { validateRelayQuotes } from './relay-validation.js'; @@ -193,34 +192,7 @@ async function getQuoteWithMaxAmountHandling( return getQuoteWithPostQuoteGasHandling(request, fullRequest); } - const isAtomicPromotionEligible = isAtomicMaxPromotionEnabled( - fullRequest.messenger, - fullRequest.transaction, - ); - // The required target amount may predate Max. An atomic hint does not - // establish the output obtainable from the source budget; discover it first. - const discoveryRequest = - request.atomic !== false && - request.isPostQuote !== true && - (isAtomicPromotionEligible || - hasTransactionType(fullRequest.transaction, [ - TransactionType.moneyAccountDeposit, - ])) - ? { ...request, atomic: false } - : request; - - const discoveryQuote = await getRelayMaxGasStationQuote( - discoveryRequest, - fullRequest, - getSingleQuote, - ); - - return maybePromoteSubsidizedMaxMoneyAccountQuote({ - discoveryQuote, - fullRequest, - getSingleQuote, - request: discoveryRequest, - }); + return getRelayMaxQuote(request, fullRequest, getSingleQuote); } /** @@ -577,7 +549,7 @@ async function processTransactions( if ( isMaxAmount && (request.isPostQuote === true || - !isAtomicMaxPromotionEnabled(messenger, transaction)) + !isAtomicMaxEnabled(messenger, transaction)) ) { throw new Error('Max amount quotes do not support included transactions'); } 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 dd6de0003eb..79acd41b901 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -37,7 +37,7 @@ import { isEIP7702Chain, getEIP7702UpgradeContractAddress, isRelayExecuteEnabled, - isAtomicMaxPromotionEnabled, + isAtomicMaxEnabled, isRelayValidationEnabled, getFeatureFlags, getGasBuffer, @@ -769,18 +769,18 @@ describe('Feature Flags Utils', () => { }); }); - describe('isAtomicMaxPromotionEnabled', () => { - it('returns true for a Money Account deposit when no flag is set', () => { + describe('isAtomicMaxEnabled', () => { + it('returns false for a Money Account deposit when no flag is set', () => { expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.moneyAccountDeposit, } as TransactionMeta), - ).toBe(true); + ).toBe(false); }); it('returns false for a non-Money Account deposit when no flag is set', () => { expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.perpsDeposit, } as TransactionMeta), ).toBe(false); @@ -792,13 +792,13 @@ describe('Feature Flags Utils', () => { remoteFeatureFlags: { confirmations_pay_extended: { payStrategies: { - relay: { atomicMaxPromotionEnabled: { default: false } }, + relay: { atomicMaxEnabled: { default: false } }, }, }, }, }); expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.moneyAccountDeposit, } as TransactionMeta), ).toBe(false); @@ -810,13 +810,13 @@ describe('Feature Flags Utils', () => { remoteFeatureFlags: { confirmations_pay_extended: { payStrategies: { - relay: { atomicMaxPromotionEnabled: { default: true } }, + relay: { atomicMaxEnabled: { default: true } }, }, }, }, }); expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.perpsDeposit, } as TransactionMeta), ).toBe(true); @@ -829,7 +829,7 @@ describe('Feature Flags Utils', () => { confirmations_pay_extended: { payStrategies: { relay: { - atomicMaxPromotionEnabled: { + atomicMaxEnabled: { default: false, transactionTypes: { [TransactionType.perpsDeposit]: true, @@ -841,7 +841,7 @@ describe('Feature Flags Utils', () => { }, }); expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.perpsDeposit, } as TransactionMeta), ).toBe(true); @@ -854,7 +854,7 @@ describe('Feature Flags Utils', () => { confirmations_pay_extended: { payStrategies: { relay: { - atomicMaxPromotionEnabled: { + atomicMaxEnabled: { default: true, transactionTypes: { [TransactionType.moneyAccountDeposit]: false, @@ -866,7 +866,7 @@ describe('Feature Flags Utils', () => { }, }); expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.moneyAccountDeposit, } as TransactionMeta), ).toBe(false); @@ -879,7 +879,7 @@ describe('Feature Flags Utils', () => { confirmations_pay_extended: { payStrategies: { relay: { - atomicMaxPromotionEnabled: { + atomicMaxEnabled: { default: true, transactionTypes: { [TransactionType.perpsDeposit]: false, @@ -891,7 +891,7 @@ describe('Feature Flags Utils', () => { }, }); expect( - isAtomicMaxPromotionEnabled(messenger, { + isAtomicMaxEnabled(messenger, { type: TransactionType.moneyAccountDeposit, } as TransactionMeta), ).toBe(true); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index f39da674eb6..f88d26e8dd8 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -195,7 +195,7 @@ export type RelayValidationEnabledConfig = { transactionTypes?: Partial>; }; -export type AtomicMaxPromotionEnabledConfig = { +export type AtomicMaxEnabledConfig = { default?: boolean; transactionTypes?: Partial>; }; @@ -204,7 +204,7 @@ type FeatureFlagsExtendedRaw = { excludeChainIdsFromInfura?: Hex[]; payStrategies?: { relay?: { - atomicMaxPromotionEnabled?: AtomicMaxPromotionEnabledConfig; + atomicMaxEnabled?: AtomicMaxEnabledConfig; gaslessEnabled?: boolean; validationEnabled?: RelayValidationEnabledConfig; }; @@ -696,24 +696,24 @@ export function isRelayValidationEnabled( } /** - * Whether atomic max promotion is enabled for a given transaction. + * Whether atomic max quotes are enabled for a given transaction. * - * Gates promotion of subsidized max deposits to atomic exact-output quotes. + * Gates subsidized max deposits using atomic exact-output quotes. * - * Configured via the `payStrategies.relay.atomicMaxPromotionEnabled` flag, an + * Configured via the `payStrategies.relay.atomicMaxEnabled` flag, an * object `{ default?: boolean; transactionTypes?: { [type]?: boolean } }`: * a matching `transactionTypes[type]` entry overrides `default` when the * transaction, or any nested transaction, has that type. * - * When the flag is absent, only direct Money Account deposits are eligible, - * preserving the pre-flag promotion scope. + * When the flag or its default is absent, atomic max is disabled unless a + * matching transaction type override enables it. * * @param messenger - Controller messenger. * @param transaction - Transaction being quoted. Its top-level and nested * types are matched against the `transactionTypes` overrides. - * @returns True if atomic max promotion may be attempted. + * @returns True if atomic max quotes are enabled. */ -export function isAtomicMaxPromotionEnabled( +export function isAtomicMaxEnabled( messenger: TransactionPayControllerMessenger, transaction?: TransactionMeta, ): boolean { @@ -723,10 +723,9 @@ export function isAtomicMaxPromotionEnabled( | FeatureFlagsExtendedRaw | undefined) ?? {}; - const promotionEnabled = - featureFlags.payStrategies?.relay?.atomicMaxPromotionEnabled; + const atomicMaxEnabled = featureFlags.payStrategies?.relay?.atomicMaxEnabled; - const transactionTypes = promotionEnabled?.transactionTypes ?? {}; + const transactionTypes = atomicMaxEnabled?.transactionTypes ?? {}; // A per-type override wins over the global `default` toggle. An override // matches when the transaction, or any nested transaction, has that type. @@ -736,11 +735,7 @@ export function isAtomicMaxPromotionEnabled( } } - // Absent flag preserves the original scope: direct Money Account deposits. - return ( - promotionEnabled?.default ?? - hasTransactionType(transaction, [TransactionType.moneyAccountDeposit]) - ); + return atomicMaxEnabled?.default ?? false; } /** From b8081a99a18fc1fc340a91da205f0a66340a8551 Mon Sep 17 00:00:00 2001 From: Repro Date: Tue, 22 Sep 2026 11:23:24 +0300 Subject: [PATCH 14/15] fix(transaction-pay-controller): use standard quote error fallback --- .../src/utils/quotes.test.ts | 30 +++++++------------ .../src/utils/quotes.ts | 20 ------------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 66738cf6f16..0518b036dab 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -483,7 +483,7 @@ describe('Quotes Utils', () => { expect(secondStrategy.getQuotes).toHaveBeenCalled(); }); - it('short-circuits and does not try next strategy when QuoteError has terminal prefixed atomic promotion failure', async () => { + it('tries the next strategy after a prefixed atomic promotion failure', async () => { const terminalError = new QuoteError({ message: 'Atomic promotion failed: test probe', reason: 'no-quotes', @@ -521,7 +521,7 @@ describe('Quotes Utils', () => { await run(); expect(firstStrategy.getQuotes).toHaveBeenCalledTimes(1); - expect(secondStrategy.getQuotes).not.toHaveBeenCalled(); + expect(secondStrategy.getQuotes).toHaveBeenCalledTimes(1); const transactionDataMock = {} as Record; updateTransactionDataMock.mock.calls.map((call) => @@ -529,11 +529,7 @@ describe('Quotes Utils', () => { ); expect(transactionDataMock).toMatchObject({ - quotes: [], - quoteError: { - message: 'Atomic promotion failed: test probe', - reason: 'no-quotes', - }, + quotes: [QUOTE_MOCK], isLoading: false, }); }); @@ -588,7 +584,7 @@ describe('Quotes Utils', () => { }); }); - it('terminal prefixed atomic promotion failure wins over an earlier ordinary no-quotes error', async () => { + it('continues strategy fallback after ordinary and prefixed no-quotes errors', async () => { const firstStrategy = { supports: jest.fn().mockReturnValue(true), getQuotes: jest.fn().mockRejectedValue( @@ -643,7 +639,7 @@ describe('Quotes Utils', () => { expect(firstStrategy.getQuotes).toHaveBeenCalledTimes(1); expect(secondStrategy.getQuotes).toHaveBeenCalledTimes(1); - expect(thirdStrategy.getQuotes).not.toHaveBeenCalled(); + expect(thirdStrategy.getQuotes).toHaveBeenCalledTimes(1); const transactionDataMock = {} as Record; updateTransactionDataMock.mock.calls.map((call) => @@ -651,11 +647,7 @@ describe('Quotes Utils', () => { ); expect(transactionDataMock).toMatchObject({ - quotes: [], - quoteError: { - message: 'Atomic promotion failed: test probe', - reason: 'no-quotes', - }, + quotes: [QUOTE_MOCK], }); }); @@ -1571,10 +1563,10 @@ describe('Quotes Utils', () => { expect(resultB).toBe(true); }); - it('publishes fail-closed state (empty quotes, terminal error, isLoading false) and skips subsequent strategies when atomic promotion fails on a refresh with a prior executable quote in state', async () => { + it('clears stale quotes and preserves the prefixed error when all strategies fail on refresh', async () => { // Seed a prior executable quote in state so this call is a refresh, not // an initial fetch — proving the block cannot leave the previous quote - // selected once a terminal prefixed promotion failure surfaces. + // selected once all strategies fail. const priorQuote = { ...QUOTE_MOCK, strategy: TransactionPayStrategy.Relay, @@ -1594,7 +1586,7 @@ describe('Quotes Utils', () => { const fallbackStrategy = { supports: jest.fn().mockReturnValue(true), - getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getQuotes: jest.fn().mockRejectedValue(new Error('Fallback failed')), getBatchTransactions: getBatchTransactionsMock, execute: jest.fn(), }; @@ -1625,10 +1617,8 @@ describe('Quotes Utils', () => { expect(result).toBe(true); - // The terminal error must abort remaining strategies — the fallback - // must never be tried. expect(promotionStrategy.getQuotes).toHaveBeenCalledTimes(1); - expect(fallbackStrategy.getQuotes).not.toHaveBeenCalled(); + expect(fallbackStrategy.getQuotes).toHaveBeenCalledTimes(1); expect(promotionStrategy.getBatchTransactions).not.toHaveBeenCalled(); expect(fallbackStrategy.getBatchTransactions).not.toHaveBeenCalled(); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index b80243f81ad..b12e45816ca 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -6,7 +6,6 @@ import { createModuleLogger } from '@metamask/utils'; import { PaymentOverride, TransactionPayStrategy } from '../constants.js'; import { projectLogger } from '../logger.js'; -import { ATOMIC_PROMOTION_FAILURE_PREFIX } from '../strategy/relay/constants.js'; import type { QuoteRequest, QuoteErrorInfo, @@ -759,25 +758,6 @@ async function getQuotes( ? caughtError.info : { message: (caughtError as Error).message, reason: 'no-quotes' }; - if ( - isQuoteError(caughtError) && - caughtError.info.reason === 'no-quotes' && - caughtError.info.message.startsWith(ATOMIC_PROMOTION_FAILURE_PREFIX) - ) { - log( - 'Terminal atomic promotion failure, aborting remaining strategies', - { - strategy: name, - transactionId, - }, - ); - return { - batchTransactions: [], - error: caughtError.info, - quotes: [], - }; - } - if ( isQuoteError(caughtError) && caughtError.info.reason === 'insufficient-source-balance' && From b1c62ee517de3f5f63de1f1439d78a431ffae530 Mon Sep 17 00:00:00 2001 From: Repro Date: Tue, 22 Sep 2026 11:23:24 +0300 Subject: [PATCH 15/15] docs(transaction-pay-controller): clarify atomic max subsidy hints --- packages/transaction-pay-controller/CHANGELOG.md | 7 ++++--- packages/transaction-pay-controller/src/types.ts | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ebe1911530b..ab7f3173769 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,9 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) -- Subsidized max Money Account Relay deposits now use atomic `EXACT_OUTPUT` quotes with vault calls embedded. The output amount is discovered from the source-token budget first, even when the client hints atomic execution, rather than reusing the original deposit's required amount. ([#10224](https://github.com/MetaMask/core/pull/10224)) - - Failed atomic promotions are terminal `no-quotes` errors prefixed with `Atomic promotion failed`, and will block rather than silently fall back. - - Non-subsidized max behavior is unchanged. +- 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. + - Atomic promotion errors retain the `Atomic promotion failed` prefix through standard quote error handling and strategy fallback. ## [29.0.0] diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 2991745122c..2f6a3a3a4f3 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -118,6 +118,9 @@ export type TransactionConfig = { * completion. Used by flows whose second-leg amount is only known after * Relay settles (EXACT_INPUT max flows) or that require the second leg to * originate from a different signer than the Relay solver. + * For max deposits enabled by `payStrategies.relay.atomicMaxEnabled`, this + * is a subsidy hint: atomic quotes are retried non-atomically if not + * subsidized, and subsidized non-atomic quotes are upgraded to atomic. */ atomic?: boolean;