From 103ddbe05f74a9f209b4cb6f776271af6e34c679 Mon Sep 17 00:00:00 2001 From: Repro Date: Mon, 14 Sep 2026 13:59:38 +0300 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 () => {