diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a22518437f5..6780d650759 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Support subsidized max Relay deposits using atomic `EXACT_OUTPUT` quotes with transaction calls embedded, gated by `payStrategies.relay.atomicMaxEnabled` (disabled by default, with per-transaction-type overrides). ([#10224](https://github.com/MetaMask/core/pull/10224)) + - Honor the client's `atomic` hint: atomic max quotes use the source-token budget adjusted to destination decimals for 1:1 subsidized stablecoin routes, without a discovery quote or reusing the original deposit amount. Unsubsidized responses are re-quoted non-atomically. + - Non-atomic hints start with `EXACT_INPUT` and upgrade to atomic execution when subsidized. + - Atomic promotion errors retain the `Atomic promotion failed` prefix through standard quote error handling and strategy fallback. + ## [29.0.2] ### Fixed 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 66% 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..953d60a3359 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,5 @@ +import { toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -8,6 +10,8 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types.js'; +import { prefixError } from '../../utils/error-prefix.js'; +import { isAtomicMaxEnabled } from '../../utils/feature-flags.js'; import { getGasStationEligibility, getGasStationCostInSourceTokenRaw, @@ -17,9 +21,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; @@ -52,6 +59,93 @@ type MaxAmountQuoteContext = { request: QuoteRequest; }; +/** + * Fetch a max quote using the client's atomic hint for subsidized routes. + * + * @param request - Max quote request. + * @param fullRequest - Full quote context. + * @param getSingleQuote - Fetcher for a single Relay quote. + * @returns An atomic subsidized quote or a non-atomic max quote. + */ +export async function getRelayMaxQuote( + request: QuoteRequest, + fullRequest: PayStrategyGetQuotesRequest, + getSingleQuote: GetSingleQuoteFn, +): Promise> { + const { messenger, transaction } = fullRequest; + const { atomic, isPostQuote } = request; + + if (isPostQuote || !isAtomicMaxEnabled(messenger, transaction)) { + return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote); + } + + if (atomic !== false) { + const sourceToken = getTokenInfo( + messenger, + request.sourceTokenAddress, + request.sourceChainId, + ); + const targetToken = getTokenInfo( + messenger, + request.targetTokenAddress, + request.targetChainId, + ); + + if (!sourceToken || !targetToken) { + throw new Error('Token decimals not found for atomic max quote'); + } + + // Fixed-spread subsidized routes exchange stablecoins 1:1 without fees. + // Use the source budget, not the required amount from before Max. + const targetAmount = new BigNumber(request.sourceTokenAmount) + .shiftedBy(targetToken.decimals - sourceToken.decimals) + .toFixed(0, BigNumber.ROUND_DOWN); + const quote = await getAtomicMaxQuote({ + amount: targetAmount, + fullRequest, + getSingleQuote, + request, + }); + + if (isSubsidizedRelayQuote(quote.original)) { + return quote; + } + + return getRelayMaxGasStationQuote( + { ...request, atomic: false }, + fullRequest, + getSingleQuote, + ); + } + + const quote = await getRelayMaxGasStationQuote( + request, + fullRequest, + getSingleQuote, + ); + + if (!isSubsidizedRelayQuote(quote.original)) { + return quote; + } + + try { + const promotedQuote = await getAtomicMaxQuote({ + amount: quote.original.details.currencyOut.amount, + fullRequest, + getSingleQuote, + request, + }); + + if (!isSubsidizedRelayQuote(promotedQuote.original)) { + throw new Error('Promoted quote lost subsidy'); + } + + return promotedQuote; + } catch (error) { + return throwAtomicPromotionFailed(error); + } +} + /** * Returns a Relay max-amount quote using a two-phase gas-station fallback. * @@ -466,3 +560,134 @@ function markQuoteAsMaxGasStation( isMaxGasStation: true, }; } + +async function getAtomicMaxQuote({ + amount, + fullRequest, + getSingleQuote, + request, +}: { + amount: string; + fullRequest: PayStrategyGetQuotesRequest; + getSingleQuote: GetSingleQuoteFn; + request: QuoteRequest; +}): Promise> { + const targetAmount = validatePositiveIntegerString(amount); + const atomicTransaction = await applyAmountDataUpdates({ + amount: targetAmount, + messenger: fullRequest.messenger, + transaction: cloneTransactionForPromotion(fullRequest.transaction), + }); + + return getSingleQuote( + { + ...request, + atomic: true, + targetAmountMinimum: targetAmount, + }, + { ...fullRequest, transaction: atomicTransaction }, + ); +} + +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 isSubsidizedAtomicMaxQuote( + quote: TransactionPayQuote, +): boolean { + return ( + quote.request.isMaxAmount === true && + quote.request.atomic === true && + isSubsidizedRelayQuote(quote.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, + }; +} 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 f0b863757ab..7beeb331c95 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -18,6 +18,7 @@ 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, @@ -36,8 +37,10 @@ import { getNativeToken, getTokenBalance, getTokenFiatRate, + getTokenInfo, } 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', () => ({ @@ -177,6 +180,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 +198,11 @@ describe('Relay Quotes Utils', () => { polymarketGetDepositWalletAddressMock, } = getMessengerMock(); + messenger.registerActionHandler( + 'TransactionPayController:getAmountData', + getAmountDataMock, + ); + beforeEach(() => { jest.resetAllMocks(); @@ -469,17 +480,23 @@ describe('Relay Quotes Utils', () => { ); }); - it('throws if isMaxAmount is true and transaction includes data', async () => { + it('rejects max included transactions for post-quote requests', async () => { await expect( getRelayQuotes({ accountSupports7702: true, + from: FROM_MOCK, messenger, - requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isMaxAmount: true, + isPostQuote: true, + skipProcessTransactions: false, + }, + ], transaction: { ...TRANSACTION_META_MOCK, - txParams: { - data: '0xabc' as Hex, - }, + txParams: { data: '0xabc' as Hex }, } as TransactionMeta, }), ).rejects.toThrow( @@ -487,6 +504,34 @@ describe('Relay Quotes Utils', () => { ); }); + it('skips transaction embedding when isMaxAmount is true and atomic is false', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true, atomic: false }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_INPUT'); + expect(body.txs).toBeUndefined(); + }); + it('includes transactions in request', async () => { successfulFetchMock.mockResolvedValue({ ok: true, @@ -645,30 +690,36 @@ describe('Relay Quotes Utils', () => { expect(getDelegationTransactionMock).not.toHaveBeenCalled(); }); - it('extracts recipient from token transfer', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => QUOTE_MOCK, - } as never); + it.each([false, true])( + 'extracts recipient from token transfer with isMaxAmount %s', + async (isMaxAmount) => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); - await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: { - ...TRANSACTION_META_MOCK, - txParams: { - data: TOKEN_TRANSFER_DATA_MOCK, - }, - } as TransactionMeta, - }); + await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + } as TransactionMeta, + }); - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); - expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase()); - }); + expect(body.recipient).toBe( + TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase(), + ); + }, + ); it('includes transactions from nested transactions', async () => { successfulFetchMock.mockResolvedValue({ @@ -3791,6 +3842,981 @@ describe('Relay Quotes Utils', () => { }); }); + 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); + } + + const validateRelayQuotesMock = jest.mocked(validateRelayQuotes); + + describe('subsidized max Money Account promotion', () => { + beforeEach(() => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: true } }, + }, + }, + }, + }); + jest + .mocked(getTokenInfo) + .mockReturnValue({ decimals: 6, symbol: 'USDC' }); + getAmountDataMock.mockResolvedValue({ + updates: [ + { + 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 atomic max promotion flag is off', async () => { + mockRelayResponses(); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { 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({ + 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.each([true, undefined])( + 'quotes the source budget directly instead of the old required amount with atomic %s', + async (atomic) => { + mockRelayResponses(buildRelayQuote({ amountOut: SOURCE_CAP_RAW })); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic, + targetAmountMinimum: OLD_DEPOSIT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(getAmountDataMock).toHaveBeenCalledWith({ + amount: SOURCE_CAP_RAW, + transaction: expect.objectContaining({ + nestedTransactions: [expect.objectContaining({ data: '0xaaaa' })], + requiredAssets: [ + expect.objectContaining({ amount: toHex(OLD_DEPOSIT_RAW) }), + ], + }), + }); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + nestedTransactions: [ + expect.objectContaining({ data: UPDATED_DEPOSIT_DATA }), + ], + requiredAssets: [ + expect.objectContaining({ + amount: toHex(SOURCE_CAP_RAW), + }), + ], + }), + }); + + expect(body).toStrictEqual( + expect.objectContaining({ + amount: SOURCE_CAP_RAW, + tradeType: 'EXACT_OUTPUT', + txs: [ + expect.objectContaining({ + data: expect.stringContaining( + BigInt(SOURCE_CAP_RAW).toString(16).padStart(64, '0'), + ), + }), + expect.objectContaining({ data: DELEGATION_RESULT_MOCK.data }), + ], + }), + ); + expect(result[0].request).toStrictEqual( + expect.objectContaining({ + atomic: true, + isMaxAmount: true, + targetAmountMinimum: SOURCE_CAP_RAW, + }), + ); + expect(result[0].isInputBased).toBe(false); + }, + ); + + it.each([ + { + sourceDecimals: 6, + targetDecimals: 18, + sourceAmount: '100000001', + targetAmount: '100000001000000000000', + }, + { + sourceDecimals: 18, + targetDecimals: 6, + sourceAmount: '100000001999999999999', + targetAmount: '100000001', + }, + ])( + 'converts $sourceDecimals source decimals to $targetDecimals target decimals without rounding up', + async ({ + sourceDecimals, + targetDecimals, + sourceAmount, + targetAmount, + }) => { + jest + .mocked(getTokenInfo) + .mockImplementation((_messenger, address) => ({ + decimals: + address === QUOTE_REQUEST_MOCK.sourceTokenAddress + ? sourceDecimals + : targetDecimals, + symbol: 'USDC', + })); + mockRelayResponses( + buildRelayQuote({ + amountIn: sourceAmount, + amountOut: targetAmount, + }), + ); + const transaction = cloneDeep(MONEY_ACCOUNT_MAX_TRANSACTION); + + const [quote] = await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + sourceTokenAmount: sourceAmount, + sourceBalanceRaw: sourceAmount, + }, + ], + transaction, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.amount).toBe(targetAmount); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.txs[0].data).toContain( + BigInt(targetAmount).toString(16).padStart(64, '0'), + ); + expect(quote.request.targetAmountMinimum).toBe(targetAmount); + expect(getAmountDataMock).toHaveBeenCalledWith({ + amount: targetAmount, + transaction: expect.any(Object), + }); + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + expect(transaction).toStrictEqual(MONEY_ACCOUNT_MAX_TRANSACTION); + }, + ); + + it.each([true, false])( + 'supports a non-Money Account transaction with atomic %s when enabled by the flag', + async (atomic) => { + mockRelayResponses(); + const [quote] = await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic }], + transaction: { + ...MONEY_ACCOUNT_MAX_TRANSACTION, + type: TransactionType.contractInteraction, + nestedTransactions: [ + { data: '0xaaaa', type: TransactionType.contractInteraction }, + ], + }, + }); + + expect(quote.request.atomic).toBe(true); + expect(successfulFetchMock).toHaveBeenCalledTimes(atomic ? 1 : 2); + }, + ); + + it.each(['source', 'target'])( + 'rejects atomic max when %s token decimals are unavailable', + async (missingToken) => { + jest + .mocked(getTokenInfo) + .mockImplementation((_messenger, address) => + (address === QUOTE_REQUEST_MOCK.sourceTokenAddress) === + (missingToken === 'source') + ? undefined + : { decimals: 6, symbol: 'USDC' }, + ); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [{ ...MONEY_ACCOUNT_MAX_REQUEST, atomic: true }], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow('Token decimals not found for atomic max quote'); + expect(successfulFetchMock).not.toHaveBeenCalled(); + }, + ); + + it('falls back to non-atomic exact-input when the atomic hint is not subsidized', async () => { + mockRelayResponses( + buildRelayQuote({ subsidizedAmountUsd: '0' }), + buildRelayQuote({ subsidizedAmountUsd: '0' }), + ); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: OLD_DEPOSIT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + const hintBody = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(hintBody.tradeType).toBe('EXACT_OUTPUT'); + expect(hintBody.amount).toBe(SOURCE_CAP_RAW); + expect(hintBody.txs).toHaveLength(2); + const fallbackBody = JSON.parse( + successfulFetchMock.mock.calls[1][1]?.body as string, + ); + expect(fallbackBody.tradeType).toBe('EXACT_INPUT'); + expect(fallbackBody.amount).toBe(SOURCE_CAP_RAW); + expect(fallbackBody.txs).toBeUndefined(); + expect(getAmountDataMock).toHaveBeenCalledTimes(1); + expect(result[0].request.atomic).toBe(false); + expect(result[0].isInputBased).toBe(true); + }); + + it('preserves max discovery fetch errors without attempting promotion', async () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + successfulFetchMock.mockRejectedValue(abortError); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: false, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow('aborted'); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + }); + + it('uses the source budget for atomic max when the original target is zero', async () => { + mockRelayResponses(); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + targetAmountMinimum: '0', + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(SOURCE_CAP_RAW); + expect(body.txs).toHaveLength(2); + expect(result[0].request.atomic).toBe(true); + expect(result[0].request.isMaxAmount).toBe(true); + }); + + it.each([true, undefined])( + 'rejects included max transactions when atomic max is disabled despite atomic %s', + async (atomic) => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => buildRelayQuote({ subsidizedAmountUsd: '0' }), + } as never); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: false } }, + }, + }, + }, + }); + + await expect( + getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }), + ).rejects.toThrow( + 'Max amount quotes do not support included transactions', + ); + expect(successfulFetchMock).not.toHaveBeenCalled(); + expect(getAmountDataMock).not.toHaveBeenCalled(); + }, + ); + + it('leaves non-max atomic exact-output quoting unchanged', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => + buildRelayQuote({ + amountIn: DISCOVERY_OUTPUT_RAW, + requestId: '0x-non-max', + tradeType: 'EXACT_OUTPUT', + }), + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...MONEY_ACCOUNT_MAX_REQUEST, + atomic: true, + isMaxAmount: false, + targetAmountMinimum: DISCOVERY_OUTPUT_RAW, + }, + ], + transaction: MONEY_ACCOUNT_MAX_TRANSACTION, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(DISCOVERY_OUTPUT_RAW); + }); + + it('fails atomic promotion terminally when promotion loses subsidy after positive subsidy detection', async () => { + mockRelayResponses( + buildRelayQuote(), + 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('promotes an ERC-20 max quote above the spend cap, leaving balance enforcement to validation', async () => { + mockRelayResponses( + buildRelayQuote(), + buildRelayQuote({ + amountIn: SOURCE_CAP_RAW, + isExecute: false, + requestId: '0x2', + tradeType: 'EXACT_OUTPUT', + }), + ); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + 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 source-token gas is present', 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('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, + }; + 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', + }); + + 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 terminal prefixed failure', 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: 'no-quotes', + message: expect.stringContaining('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('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + it('promotes when the discovery target needs normalization', async () => { + mockRelayResponses(buildRelayQuote({ amountOut: '099000000' })); + + 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 () => { + 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: ['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('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 846aa5569ad..155d55dfeeb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -42,6 +42,7 @@ import { getRelayOriginGasOverhead, getSlippage, getStablecoins, + isAtomicMaxEnabled, isEIP7702Chain, isRelayExecuteEnabled, } from '../../utils/feature-flags.js'; @@ -67,7 +68,11 @@ import { isPredictWithdraw, } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; -import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; +import { + getRelayMaxQuote, + isSubsidizedAtomicMaxQuote, + throwAtomicPromotionFailed, +} from './relay-max.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -143,12 +148,32 @@ export async function getRelayQuotes( ), ); - await validateRelayQuotes({ - messenger: request.messenger, - quotes, - signal: request.signal, - transaction: request.transaction, - }); + const atomicMaxQuotes = quotes.filter(isSubsidizedAtomicMaxQuote); + const otherQuotes = quotes.filter( + (quote) => !isSubsidizedAtomicMaxQuote(quote), + ); + + if (otherQuotes.length > 0) { + await validateRelayQuotes({ + messenger: request.messenger, + quotes: otherQuotes, + signal: request.signal, + transaction: request.transaction, + }); + } + + if (atomicMaxQuotes.length > 0) { + try { + await validateRelayQuotes({ + messenger: request.messenger, + quotes: atomicMaxQuotes, + signal: request.signal, + transaction: request.transaction, + }); + } catch (error) { + throwAtomicPromotionFailed(error); + } + } return quotes; } catch (error) { @@ -167,7 +192,7 @@ async function getQuoteWithMaxAmountHandling( return getQuoteWithPostQuoteGasHandling(request, fullRequest); } - return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote); + return getRelayMaxQuote(request, fullRequest, getSingleQuote); } /** @@ -519,7 +544,13 @@ async function processTransactions( return true; } - if (isMaxAmount) { + // Eligible atomic max requests carry a destination amount, so the calls + // below use EXACT_OUTPUT rather than the usual max EXACT_INPUT quote. + if ( + isMaxAmount && + (request.isPostQuote === true || + !isAtomicMaxEnabled(messenger, transaction)) + ) { throw new Error('Max amount quotes do not support included transactions'); } 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..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 } 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 +15,7 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags.js'; import { + isSubsidizedRelayQuote, getRelayExecuteRequest, submitViaRelayExecute, } from './relay-submit-execute.js'; @@ -109,7 +113,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 +161,7 @@ describe('Relay Submit Execute', () => { allParams = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: '0x1234' as Hex, value: '0x4d2' as Hex, @@ -168,6 +173,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 +235,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 +353,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 +428,7 @@ describe('Relay Submit Execute', () => { allParams = [ { + from: FROM_MOCK, to: '0xfedcb' as Hex, data: '0xa9059cbb000000000000000000000000abcdef1234567890abcdef1234567890abcdef120000000000000000000000000000000000000000000000000000000000989680' as Hex, value: '0x4d2' as Hex, @@ -542,6 +565,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 +684,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..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; } -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/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 9a6da6422ee..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 @@ -2280,5 +2280,147 @@ 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 2991745122c..2f6a3a3a4f3 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -118,6 +118,9 @@ export type TransactionConfig = { * completion. Used by flows whose second-leg amount is only known after * Relay settles (EXACT_INPUT max flows) or that require the second leg to * originate from a different signer than the Relay solver. + * For max deposits enabled by `payStrategies.relay.atomicMaxEnabled`, this + * is a subsidy hint: atomic quotes are retried non-atomically if not + * subsidized, and subsidized non-atomic quotes are upgraded to atomic. */ atomic?: boolean; 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..79acd41b901 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -37,6 +37,7 @@ import { isEIP7702Chain, getEIP7702UpgradeContractAddress, isRelayExecuteEnabled, + isAtomicMaxEnabled, isRelayValidationEnabled, getFeatureFlags, getGasBuffer, @@ -768,6 +769,135 @@ describe('Feature Flags Utils', () => { }); }); + describe('isAtomicMaxEnabled', () => { + it('returns false for a Money Account deposit when no flag is set', () => { + expect( + isAtomicMaxEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns false for a non-Money Account deposit when no flag is set', () => { + expect( + isAtomicMaxEnabled(messenger, { + type: TransactionType.perpsDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns false when default is false', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: false } }, + }, + }, + }, + }); + expect( + isAtomicMaxEnabled(messenger, { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns true when default is true', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_extended: { + payStrategies: { + relay: { atomicMaxEnabled: { default: true } }, + }, + }, + }, + }); + expect( + isAtomicMaxEnabled(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: { + atomicMaxEnabled: { + default: false, + transactionTypes: { + [TransactionType.perpsDeposit]: true, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxEnabled(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: { + atomicMaxEnabled: { + default: true, + transactionTypes: { + [TransactionType.moneyAccountDeposit]: false, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxEnabled(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: { + atomicMaxEnabled: { + default: true, + transactionTypes: { + [TransactionType.perpsDeposit]: false, + }, + }, + }, + }, + }, + }, + }); + expect( + isAtomicMaxEnabled(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..f88d26e8dd8 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 AtomicMaxEnabledConfig = { + default?: boolean; + transactionTypes?: Partial>; +}; + type FeatureFlagsExtendedRaw = { excludeChainIdsFromInfura?: Hex[]; payStrategies?: { relay?: { + atomicMaxEnabled?: AtomicMaxEnabledConfig; gaslessEnabled?: boolean; validationEnabled?: RelayValidationEnabledConfig; }; @@ -689,6 +695,49 @@ export function isRelayValidationEnabled( return validationEnabled?.default ?? false; } +/** + * Whether atomic max quotes are enabled for a given transaction. + * + * Gates subsidized max deposits using atomic exact-output quotes. + * + * Configured via the `payStrategies.relay.atomicMaxEnabled` flag, an + * object `{ default?: boolean; transactionTypes?: { [type]?: boolean } }`: + * a matching `transactionTypes[type]` entry overrides `default` when the + * transaction, or any nested transaction, has that type. + * + * When the flag or its default is absent, atomic max is disabled unless a + * matching transaction type override enables it. + * + * @param messenger - Controller messenger. + * @param transaction - Transaction being quoted. Its top-level and nested + * types are matched against the `transactionTypes` overrides. + * @returns True if atomic max quotes are enabled. + */ +export function isAtomicMaxEnabled( + messenger: TransactionPayControllerMessenger, + transaction?: TransactionMeta, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = + (state.remoteFeatureFlags?.confirmations_pay_extended as + | FeatureFlagsExtendedRaw + | undefined) ?? {}; + + const atomicMaxEnabled = featureFlags.payStrategies?.relay?.atomicMaxEnabled; + + const transactionTypes = atomicMaxEnabled?.transactionTypes ?? {}; + + // A per-type override wins over the global `default` toggle. An override + // matches when the transaction, or any nested transaction, has that type. + for (const [type, enabled] of Object.entries(transactionTypes)) { + if (hasTransactionType(transaction, [type as TransactionType])) { + return enabled; + } + } + + return atomicMaxEnabled?.default ?? false; +} + /** * 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 040f576411b..041291f153e 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -485,6 +485,174 @@ describe('Quotes Utils', () => { expect(secondStrategy.getQuotes).toHaveBeenCalled(); }); + it('tries the next strategy after a prefixed atomic promotion failure', async () => { + const terminalError = new QuoteError({ + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', + }); + + 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).toHaveBeenCalledTimes(1); + + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [QUOTE_MOCK], + 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('continues strategy fallback after ordinary and prefixed no-quotes errors', 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 promotion failed: test probe', + reason: 'no-quotes', + }), + ), + 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).toHaveBeenCalledTimes(1); + + const transactionDataMock = {} as Record; + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [QUOTE_MOCK], + }); + }); + it('falls back to next strategy when batch transactions fail', async () => { const firstStrategy = { supports: jest.fn().mockReturnValue(true), @@ -1417,6 +1585,160 @@ describe('Quotes Utils', () => { expect(resultA).toBe(true); expect(resultB).toBe(true); }); + + it('clears stale quotes and preserves the prefixed error when all strategies fail on refresh', async () => { + // Seed a prior executable quote in state so this call is a refresh, not + // an initial fetch — proving the block cannot leave the previous quote + // selected once all strategies fail. + const priorQuote = { + ...QUOTE_MOCK, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + + const promotionStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue( + new QuoteError({ + message: 'Atomic promotion failed: test probe', + reason: 'no-quotes', + }), + ), + getBatchTransactions: jest.fn(), + execute: jest.fn(), + }; + + const fallbackStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue(new Error('Fallback failed')), + 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); + + expect(promotionStrategy.getQuotes).toHaveBeenCalledTimes(1); + expect(fallbackStrategy.getQuotes).toHaveBeenCalledTimes(1); + 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 promotion failed: test probe', + reason: 'no-quotes', + }, + 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); + }); }); });