diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 7c3c819a15e..a3097eaf9b3 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use the native Transak buy quote's fee for the MM Pay fiat estimate when Transak Native is the resolved provider, falling back to the aggregator quote's fee when native is unavailable or the lookup fails ([#9317](https://github.com/MetaMask/core/pull/9317)) - The native lookup and fee reconciliation are owned by `RampsController:getQuoteWithFees`; clients must delegate `RampsController:getQuoteWithFees` to the `TransactionPayController` messenger (the previously required `TransakService:getBuyQuote` delegation is no longer needed) ([#10238](https://github.com/MetaMask/core/pull/10238)). - Charge direct Monad mUSD on-ramp fees on top of the entered amount (fee-on-top), so the total is the entered amount plus fees ([#9317](https://github.com/MetaMask/core/pull/9317)) +- 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)) + - 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)) - Bump `@metamask/assets-controller` from `^16.0.0` to `^16.1.0` ([#10242](https://github.com/MetaMask/core/pull/10242)) - Bump `@metamask/assets-controllers` from `^112.0.1` to `^112.0.2` ([#10242](https://github.com/MetaMask/core/pull/10242)) 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 f0b863757ab..b1944151a37 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, @@ -38,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', () => ({ @@ -177,6 +179,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 +197,11 @@ describe('Relay Quotes Utils', () => { polymarketGetDepositWalletAddressMock, } = getMessengerMock(); + messenger.registerActionHandler( + 'TransactionPayController:getAmountData', + getAmountDataMock, + ); + beforeEach(() => { jest.resetAllMocks(); @@ -3791,6 +3801,752 @@ 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(() => { + 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: { 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({ + 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('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('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('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('fails atomic promotion terminally 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: 'no-quotes', + message: expect.stringContaining('Atomic promotion failed'), + }), + }); + }); + + 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('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 846aa5569ad..8e9b16f5afc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -67,7 +67,13 @@ import { isPredictWithdraw, } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; -import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; +import { + getRelayMaxGasStationQuote, + isPromotedSubsidizedMaxMoneyAccountQuote, + maybeDemoteUnsubsidizedAtomicQuote, + maybePromoteSubsidizedMaxMoneyAccountQuote, + throwAtomicPromotionFailed, +} from './relay-max.js'; import { validateRelayQuotes } from './relay-validation.js'; import type { RelayQuote, @@ -110,6 +116,8 @@ export async function getRelayQuotes( log('Fetching quotes', requests); + let quotes: TransactionPayQuote[] = []; + try { const normalizedRequests = await Promise.all( requests @@ -137,18 +145,40 @@ export async function getRelayQuotes( log('Normalized requests', normalizedRequests); - const quotes = await Promise.all( + quotes = await Promise.all( normalizedRequests.map((singleRequest) => getQuoteWithMaxAmountHandling(singleRequest, request), ), ); - await validateRelayQuotes({ - messenger: request.messenger, - quotes, - signal: request.signal, - transaction: request.transaction, - }); + const promotedQuotes = quotes.filter( + isPromotedSubsidizedMaxMoneyAccountQuote, + ); + const otherQuotes = quotes.filter( + (quote) => !isPromotedSubsidizedMaxMoneyAccountQuote(quote), + ); + + if (otherQuotes.length > 0) { + await validateRelayQuotes({ + messenger: request.messenger, + quotes: otherQuotes, + signal: request.signal, + transaction: request.transaction, + }); + } + + 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) { @@ -164,10 +194,27 @@ async function getQuoteWithMaxAmountHandling( const { isMaxAmount } = request; if (!isMaxAmount) { - return getQuoteWithPostQuoteGasHandling(request, fullRequest); + const quote = await getQuoteWithPostQuoteGasHandling(request, fullRequest); + return maybeDemoteUnsubsidizedAtomicQuote({ + fullRequest, + getSingleQuote, + quote, + request, + }); } - return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote); + const discoveryQuote = await getRelayMaxGasStationQuote( + request, + fullRequest, + getSingleQuote, + ); + + return maybePromoteSubsidizedMaxMoneyAccountQuote({ + discoveryQuote, + fullRequest, + getSingleQuote, + request, + }); } /** 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/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 71c582f8fe8..66738cf6f16 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 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).not.toHaveBeenCalled(); + + 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, + }); + }); + + 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 prefixed atomic promotion failure 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 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).not.toHaveBeenCalled(); + + 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', + }, + }); + }); + 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 prefixed promotion failure 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 promotion failed: test probe', + reason: 'no-quotes', + }), + ), + 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 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); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index b12e45816ca..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, @@ -758,6 +759,25 @@ async function getQuotes( ? caughtError.info : { message: (caughtError as Error).message, reason: 'no-quotes' }; + if ( + isQuoteError(caughtError) && + caughtError.info.reason === 'no-quotes' && + caughtError.info.message.startsWith(ATOMIC_PROMOTION_FAILURE_PREFIX) + ) { + log( + 'Terminal atomic promotion failure, aborting remaining strategies', + { + strategy: name, + transactionId, + }, + ); + return { + batchTransactions: [], + error: caughtError.info, + quotes: [], + }; + } + if ( isQuoteError(caughtError) && caughtError.info.reason === 'insufficient-source-balance' &&