From b3971a33b2b606be7151eaf340e5e69836d9e7ce Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:01:25 +0200 Subject: [PATCH 1/7] Implement fiat strategy submit flow with order polling and relay execution --- .../src/strategy/fiat/fiat-submit.test.ts | 488 +++++++++++++++++- .../src/strategy/fiat/fiat-submit.ts | 331 +++++++++++- .../src/strategy/relay/relay-quotes.ts | 16 +- .../transaction-pay-controller/src/types.ts | 9 +- 4 files changed, 822 insertions(+), 22 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index b1e3e2ff83d..2c4a72e009f 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -1,19 +1,491 @@ +import type { + Quote as RampsQuote, + RampsOrder, + RampsOrderCryptoCurrency, +} from '@metamask/ramps-controller'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import type { TransactionPayFiatAsset } from './constants'; import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; -import type { TransactionPayControllerMessenger } from '../..'; -import type { TransactionPayQuote } from '../../types'; +import { deriveFiatAssetForFiatPayment } from './utils'; +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; + +jest.mock('./utils'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const ORDER_CODE_MOCK = '/providers/transak/orders/order-123'; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { + from: WALLET_ADDRESS_MOCK, + }, + type: TransactionType.predictDeposit, +} as TransactionMeta; + +const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { + address: '0x0000000000000000000000000000000000001010', + caipAssetId: 'eip155:137/slip44:966', + chainId: '0x89', + decimals: 18, +}; + +const RAMPS_QUOTE_MOCK: RampsQuote = { + provider: '/providers/transak-native-staging', + quote: { + amountIn: 20, + amountOut: 5, + paymentMethod: '/payments/debit-credit-card', + }, +}; + +const BASE_QUOTE_REQUEST_MOCK: QuoteRequest = { + from: WALLET_ADDRESS_MOCK, + sourceBalanceRaw: '1000000000000000000', + sourceChainId: '0x89', + sourceTokenAddress: '0x0000000000000000000000000000000000001010', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '12000000', + targetChainId: '0x89', + targetTokenAddress: '0x2222222222222222222222222222222222222222', +}; + +const RELAY_QUOTE_RESULT_MOCK = { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: {} as RelayQuote, + request: BASE_QUOTE_REQUEST_MOCK, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: TransactionPayStrategy.Relay, + targetAmount: { + fiat: '0', + usd: '0', + }, +} as TransactionPayQuote; + +function getFiatOrderMock({ + cryptoAmount = '1', + cryptoCurrency, + status = RampsOrderStatus.Completed, +}: { + cryptoAmount?: RampsOrder['cryptoAmount']; + cryptoCurrency?: RampsOrderCryptoCurrency; + status?: RampsOrderStatus; +} = {}): RampsOrder { + return { + cryptoAmount, + cryptoCurrency, + status, + } as RampsOrder; +} + +function getFiatQuoteMock({ + request = BASE_QUOTE_REQUEST_MOCK, +}: { + request?: QuoteRequest; +} = {}): TransactionPayQuote { + return { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: { + rampsQuote: RAMPS_QUOTE_MOCK, + relayQuote: {} as RelayQuote, + }, + request, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: TransactionPayStrategy.Fiat, + targetAmount: { + fiat: '0', + usd: '0', + }, + }; +} + +function getRequest({ + orderCode = ORDER_CODE_MOCK, + order = getFiatOrderMock(), + quotes = [getFiatQuoteMock()], + transaction = TRANSACTION_MOCK, +}: { + orderCode?: string; + order?: RampsOrder; + quotes?: TransactionPayQuote[]; + transaction?: TransactionMeta; +} = {}): { + callMock: jest.Mock; + request: PayStrategyExecuteRequest; +} { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [transaction.id]: { + fiatPayment: { + orderCode, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + return order; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + request: { + isSmartTransaction: () => false, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes, + transaction, + }, + }; +} describe('submitFiatQuotes', () => { - it('returns empty transaction hash placeholder', async () => { - const result = await submitFiatQuotes({ + const deriveFiatAssetForFiatPaymentMock = jest.mocked( + deriveFiatAssetForFiatPayment, + ); + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + + beforeEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ + transactionHash: '0x1234', + }); + }); + + it('polls completed fiat order then requotes and submits relay', async () => { + const order = getFiatOrderMock({ + cryptoAmount: '1.2345', + cryptoCurrency: { + assetId: FIAT_ASSET_MOCK.caipAssetId, + chainId: 'eip155:137', + symbol: 'POL', + }, + status: RampsOrderStatus.Completed, + }); + const { callMock, request } = getRequest({ order }); + + const result = await submitFiatQuotes(request); + + expect(callMock).toHaveBeenCalledWith( + 'RampsController:getOrder', + 'transak', + 'order-123', + WALLET_ADDRESS_MOCK, + ); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: true, + isPostQuote: false, + sourceBalanceRaw: '1234500000000000000', + sourceTokenAmount: '1234500000000000000', + }), + ]); + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [RELAY_QUOTE_RESULT_MOCK], + }), + ); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('throws if wallet address is missing', async () => { + const { request } = getRequest({ + transaction: { + ...TRANSACTION_MOCK, + txParams: {}, + } as TransactionMeta, + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing wallet address for fiat submission', + ); + }); + + it('throws if order code is missing', async () => { + const { request } = getRequest({ orderCode: '' }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing order code for fiat submission', + ); + }); + + it('throws if order code format is invalid', async () => { + const { request } = getRequest({ + orderCode: '/providers/transak/oops', + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Invalid order code format: /providers/transak/oops', + ); + }); + + it('throws if fiat order status is failed', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.Failed }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order failed', + ); + }); + + it('throws if fiat order status is cancelled', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.Cancelled }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order cancelled', + ); + }); + + it('polls pending orders until completed', async () => { + jest.useFakeTimers(); + + const pendingOrder = getFiatOrderMock({ status: RampsOrderStatus.Pending }); + const completedOrder = getFiatOrderMock({ + cryptoAmount: '1', + status: RampsOrderStatus.Completed, + }); + + let getOrderCallCount = 0; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { orderCode: ORDER_CODE_MOCK }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + getOrderCallCount += 1; + return getOrderCallCount === 1 ? pendingOrder : completedOrder; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const request: PayStrategyExecuteRequest = { isSmartTransaction: () => false, - quotes: [] as TransactionPayQuote[], - messenger: {} as TransactionPayControllerMessenger, - transaction: {} as TransactionMeta, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + const promise = submitFiatQuotes(request); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + expect(getOrderCallCount).toBe(2); + }); + + it('throws if fiat order polling times out and includes last status', async () => { + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(Number.MAX_SAFE_INTEGER); + + const pendingOrder = getFiatOrderMock({ status: RampsOrderStatus.Pending }); + const { request } = getRequest({ order: pendingOrder }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order polling timed out (last status: PENDING)', + ); + + dateNowSpy.mockRestore(); + }); + + it('throws if fiat asset mapping is missing', async () => { + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing fiat asset mapping for transaction type: predictDeposit', + ); + }); + + it('throws if order asset id mismatches expected fiat asset', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ + cryptoCurrency: { + assetId: 'eip155:137/slip44:60', + symbol: 'ETH', + }, + }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + `Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_MOCK.caipAssetId}, got eip155:137/slip44:60`, + ); + }); + + it('throws if order chain mismatches expected fiat asset chain', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ + cryptoCurrency: { + chainId: 'eip155:1', + symbol: 'POL', + }, + }), }); - expect(result).toStrictEqual({ transactionHash: undefined }); + await expect(submitFiatQuotes(request)).rejects.toThrow( + `Fiat order chain mismatch for transaction ${TRANSACTION_ID_MOCK}: expected eip155:137, got eip155:1`, + ); + }); + + it.each([ + ['0', 'Invalid fiat order crypto amount: 0'], + ['-1', 'Invalid fiat order crypto amount: -1'], + ['NaN', 'Invalid fiat order crypto amount: NaN'], + ])( + 'throws if order crypto amount is invalid (%s)', + async (cryptoAmount, expectedError) => { + const { request } = getRequest({ + order: getFiatOrderMock({ cryptoAmount }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError); + }, + ); + + it('throws if request has no fiat quotes', async () => { + const { request } = getRequest(); + request.quotes = []; + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing fiat quote for relay submission', + ); + }); + + it('throws if request has multiple fiat quotes', async () => { + const { request } = getRequest(); + request.quotes = [getFiatQuoteMock(), getFiatQuoteMock()]; + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Multiple fiat quotes are not supported for submission', + ); + }); + + it('throws if crypto amount rounds to zero after decimal shift', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Computed fiat order source amount is not positive', + ); + }); + + it('throws if relay re-quote returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'No relay quotes returned for completed fiat order', + ); + }); + + it('throws if relay submit fails', async () => { + submitRelayQuotesMock.mockRejectedValue(new Error('Relay submit failed')); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Relay submit failed', + ); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 6fb436b654b..8cae22ba512 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -1,14 +1,333 @@ +import type { + RampsOrder, + RampsOrderCryptoCurrency, +} from '@metamask/ramps-controller'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { TransactionPayFiatAsset } from './constants'; import type { FiatQuote } from './types'; -import type { PayStrategy, PayStrategyExecuteRequest } from '../../types'; +import { deriveFiatAssetForFiatPayment } from './utils'; +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayControllerMessenger, +} from '../../types'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; + +const log = createModuleLogger(projectLogger, 'fiat-submit'); + +const ORDER_POLL_INTERVAL_MS = 1000; +const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; + +const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ + RampsOrderStatus.Failed, + RampsOrderStatus.Cancelled, +]; /** - * Submit Fiat quotes. + * Submits fiat strategy quotes by polling the on-ramp order until completion, + * then re-quoting and submitting the relay leg with the settled crypto amount. * - * @param _request - Strategy execute request. - * @returns Empty transaction hash until fiat submit implementation is added. + * @param request - Strategy execute request containing fiat quotes, messenger, and transaction metadata. + * @param request.messenger - Controller messenger for cross-controller calls. + * @param request.quotes - Fiat quotes to execute (exactly one expected). + * @param request.transaction - Original transaction metadata. + * @param request.isSmartTransaction - Callback to check smart transaction eligibility. + * @returns An object containing the relay transaction hash if available. */ export async function submitFiatQuotes( - _request: PayStrategyExecuteRequest, + request: PayStrategyExecuteRequest, ): ReturnType['execute']> { - return { transactionHash: undefined }; + const { messenger, transaction } = request; + const transactionId = transaction.id; + const walletAddress = transaction.txParams.from as Hex | undefined; + + if (!walletAddress) { + throw new Error('Missing wallet address for fiat submission'); + } + + const state = messenger.call('TransactionPayController:getState'); + const orderCode = + state.transactionData[transactionId]?.fiatPayment?.orderCode; + + if (!orderCode) { + throw new Error('Missing order code for fiat submission'); + } + + const parsedOrderCode = parseOrderCode(orderCode); + + if (!parsedOrderCode) { + throw new Error(`Invalid order code format: ${orderCode}`); + } + + log('Starting fiat order polling', { + orderCode, + providerCode: parsedOrderCode.providerCode, + transactionId, + }); + + const order = await waitForOrderCompletion({ + messenger, + orderCode: parsedOrderCode.orderCode, + providerCode: parsedOrderCode.providerCode, + transactionId, + walletAddress, + }); + + log('Fiat order completed', { + cryptoAmount: order.cryptoAmount, + orderCode, + transactionId, + }); + + return await submitRelayAfterFiatCompletion({ order, request }); +} + +/** + * Parses a normalized order code string into its provider and order components. + * + * @param orderCode - Order code in `/providers/{providerCode}/orders/{orderCode}` format. + * @returns The parsed provider and order codes, or `null` if the format is invalid. + */ +function parseOrderCode( + orderCode: string, +): { orderCode: string; providerCode: string } | null { + const parts = orderCode.split('/').filter(Boolean); + + if (parts.length < 4 || parts[0] !== 'providers' || parts[2] !== 'orders') { + return null; + } + + return { orderCode: parts[3], providerCode: parts[1] }; +} + +/** + * Converts the order's human-readable crypto amount to a raw token amount. + * + * @param options - The conversion options. + * @param options.cryptoAmount - Human-readable crypto amount from the completed order. + * @param options.decimals - Token decimals for the fiat asset. + * @returns The raw token amount as a string. + */ +function getRawSourceAmountFromOrder({ + cryptoAmount, + decimals, +}: { + cryptoAmount: RampsOrder['cryptoAmount']; + decimals: number; +}): string { + const normalizedAmount = new BigNumber(String(cryptoAmount)); + + if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) { + throw new Error( + `Invalid fiat order crypto amount: ${String(cryptoAmount)}`, + ); + } + + const rawAmount = normalizedAmount + .shiftedBy(decimals) + .decimalPlaces(0, BigNumber.ROUND_DOWN) + .toFixed(0); + + if (!new BigNumber(rawAmount).gt(0)) { + throw new Error('Computed fiat order source amount is not positive'); + } + + return rawAmount; +} + +/** + * Validates that the completed order's crypto asset matches the expected fiat asset. + * + * @param options - The validation options. + * @param options.expectedAsset - The expected fiat asset derived from the transaction type. + * @param options.orderCrypto - The crypto currency information from the completed order. + * @param options.transactionId - Transaction ID for error reporting. + */ +function validateOrderAsset({ + expectedAsset, + orderCrypto, + transactionId, +}: { + expectedAsset: TransactionPayFiatAsset; + orderCrypto: RampsOrderCryptoCurrency | undefined; + transactionId: string; +}): void { + const orderAssetId = orderCrypto?.assetId?.toLowerCase(); + const expectedAssetId = expectedAsset.caipAssetId.toLowerCase(); + const expectedChainId = expectedAssetId.split('/')[0]; + const orderChainId = orderCrypto?.chainId?.toLowerCase(); + + if (orderAssetId && orderAssetId !== expectedAssetId) { + throw new Error( + `Fiat order asset mismatch for transaction ${transactionId}: ` + + `expected ${expectedAssetId}, got ${orderAssetId}`, + ); + } + + if (orderChainId && orderChainId !== expectedChainId) { + throw new Error( + `Fiat order chain mismatch for transaction ${transactionId}: ` + + `expected ${expectedChainId}, got ${orderChainId}`, + ); + } +} + +/** + * Polls the on-ramp order until it reaches a terminal status. + * + * @param options - The polling options. + * @param options.messenger - Controller messenger for calling `RampsController:getOrder`. + * @param options.orderCode - The order identifier within the provider. + * @param options.providerCode - The on-ramp provider code (e.g. "transak"). + * @param options.transactionId - Transaction ID for logging. + * @param options.walletAddress - Wallet address associated with the order. + * @returns The completed order data. + */ +async function waitForOrderCompletion({ + messenger, + orderCode, + providerCode, + transactionId, + walletAddress, +}: { + messenger: TransactionPayControllerMessenger; + orderCode: string; + providerCode: string; + transactionId: string; + walletAddress: string; +}): Promise { + const startTime = Date.now(); + let lastStatus: string | undefined; + + while (true) { + const order = await messenger.call( + 'RampsController:getOrder', + providerCode, + orderCode, + walletAddress, + ); + + lastStatus = order.status; + + log('Polled fiat order', { + orderStatus: order.status, + providerCode, + transactionId, + }); + + if (order.status === RampsOrderStatus.Completed) { + return order; + } + + if (TERMINAL_FAILURE_STATUSES.includes(order.status)) { + throw new Error(`Fiat order ${order.status.toLowerCase()}`); + } + + if (Date.now() - startTime >= ORDER_POLL_TIMEOUT_MS) { + const statusDetail = lastStatus ? ` (last status: ${lastStatus})` : ''; + throw new Error(`Fiat order polling timed out${statusDetail}`); + } + + await new Promise((resolve) => setTimeout(resolve, ORDER_POLL_INTERVAL_MS)); + } +} + +/** + * Re-quotes and submits the relay leg using the settled amount from a completed fiat order. + * + * @param options - The submission options. + * @param options.order - The completed on-ramp order containing the settled crypto amount. + * @param options.request - The original fiat strategy execute request. + * @returns An object containing the relay transaction hash if available. + */ +async function submitRelayAfterFiatCompletion({ + order, + request, +}: { + order: RampsOrder; + request: PayStrategyExecuteRequest; +}): Promise<{ transactionHash?: Hex }> { + const { messenger, quotes, transaction } = request; + const transactionId = transaction.id; + + if (!quotes.length) { + throw new Error('Missing fiat quote for relay submission'); + } + + if (quotes.length > 1) { + throw new Error('Multiple fiat quotes are not supported for submission'); + } + + const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + if (!fiatAsset) { + throw new Error( + `Missing fiat asset mapping for transaction type: ${String(transaction.type)}`, + ); + } + + validateOrderAsset({ + expectedAsset: fiatAsset, + orderCrypto: order.cryptoCurrency, + transactionId, + }); + + const sourceAmountRaw = getRawSourceAmountFromOrder({ + cryptoAmount: order.cryptoAmount, + decimals: fiatAsset.decimals, + }); + + const baseRequest = quotes[0].request; + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: true, + isPostQuote: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + }; + + log('Re-quoting relay from completed fiat order', { + completedOrderAmount: order.cryptoAmount, + relayRequest, + sourceAmountRaw, + transactionId, + }); + + const relayQuotes = await getRelayQuotes({ + messenger, + requests: [relayRequest], + transaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + log('Received relay quotes for completed fiat order', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + const relaySubmitRequest: PayStrategyExecuteRequest = { + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }; + + const relayResult = await submitRelayQuotes(relaySubmitRequest); + + log('Relay submission completed after fiat order', { + relayResult, + transactionId, + }); + + return relayResult; } 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 8ad148cbf13..82a3dfda9c0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -78,13 +78,15 @@ export async function getRelayQuotes( try { const normalizedRequests = requests - // Ignore gas fee token requests (which have both target=0 and source=0) - // but keep post-quote requests (identified by isPostQuote flag) - .filter( - (singleRequest) => - singleRequest.targetAmountMinimum !== '0' || - singleRequest.isPostQuote, - ) + .filter((singleRequest) => { + const hasTargetMinimum = singleRequest.targetAmountMinimum !== '0'; + const isPostQuote = Boolean(singleRequest.isPostQuote); + const isExactInputRequest = + Boolean(singleRequest.isMaxAmount) && + new BigNumber(singleRequest.sourceTokenAmount).gt(0); + + return hasTargetMinimum || isPostQuote || isExactInputRequest; + }) .map((singleRequest) => normalizeRequest(singleRequest)); log('Normalized requests', normalizedRequests); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 320f07327dc..594e68b3797 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -16,7 +16,10 @@ import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring- import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; -import type { RampsControllerGetQuotesAction } from '@metamask/ramps-controller'; +import type { + RampsControllerGetOrderAction, + RampsControllerGetQuotesAction, +} from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AuthorizationList, @@ -50,6 +53,7 @@ export type AllowedActions = | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction + | RampsControllerGetOrderAction | RampsControllerGetQuotesAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction @@ -211,6 +215,9 @@ export type TransactionFiatPayment = { /** Entered fiat amount for the selected payment method. */ amountFiat?: string; + /** Order identifier - `orderCode` specifically used as RampsService:getOrder parameter in normalized format (/providers/{provider}/orders/{id}). */ + orderCode?: string; + /** Selected fiat payment method ID. */ selectedPaymentMethodId?: string; }; From 19e352150562318668874f22c55786bbb21e8754 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:03:39 +0200 Subject: [PATCH 2/7] Add changelog --- packages/transaction-pay-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 477f6a94e9f..e659884bb98 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) + ### Changed - Add route-based `confirmations_pay` strategy resolution ([#8282](https://github.com/MetaMask/core/pull/8282)) From dd36f40432cbd33d01f30f3d80fcc120d5e7e2a2 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:14:41 +0200 Subject: [PATCH 3/7] Add IdExpired failed status --- .../src/strategy/fiat/fiat-submit.test.ts | 10 ++++++++++ .../src/strategy/fiat/fiat-submit.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 2c4a72e009f..197d4f38013 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -325,6 +325,16 @@ describe('submitFiatQuotes', () => { ); }); + it('throws if fiat order status is id_expired', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.IdExpired }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order id_expired', + ); + }); + it('polls pending orders until completed', async () => { jest.useFakeTimers(); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 8cae22ba512..e7102899630 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -27,8 +27,9 @@ const ORDER_POLL_INTERVAL_MS = 1000; const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ - RampsOrderStatus.Failed, RampsOrderStatus.Cancelled, + RampsOrderStatus.Failed, + RampsOrderStatus.IdExpired, ]; /** From d149b0fda0e5cccbac3a1cdb4d547c419db654fc Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:18:17 +0200 Subject: [PATCH 4/7] Update --- .../src/strategy/fiat/fiat-submit.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index e7102899630..c037716580a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -233,8 +233,9 @@ async function waitForOrderCompletion({ } if (Date.now() - startTime >= ORDER_POLL_TIMEOUT_MS) { - const statusDetail = lastStatus ? ` (last status: ${lastStatus})` : ''; - throw new Error(`Fiat order polling timed out${statusDetail}`); + throw new Error( + `Fiat order polling timed out (last status: ${lastStatus})`, + ); } await new Promise((resolve) => setTimeout(resolve, ORDER_POLL_INTERVAL_MS)); From 3362effb7a103e4abe44c899768d051a6a0910cf Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 10:13:22 +0200 Subject: [PATCH 5/7] Update --- .../src/strategy/fiat/fiat-submit.test.ts | 63 +++++++++++--- .../src/strategy/fiat/fiat-submit.ts | 86 +++++++++++++++---- .../transaction-pay-controller/src/types.ts | 4 +- 3 files changed, 121 insertions(+), 32 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 197d4f38013..27bbfbf88ac 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -28,7 +28,7 @@ jest.mock('../relay/relay-submit'); const TRANSACTION_ID_MOCK = 'tx-id'; const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; -const ORDER_CODE_MOCK = '/providers/transak/orders/order-123'; +const ORDER_ID_MOCK = '/providers/transak/orders/order-123'; const TRANSACTION_MOCK = { id: TRANSACTION_ID_MOCK, @@ -90,7 +90,11 @@ const RELAY_QUOTE_RESULT_MOCK = { usd: '0', }, }, - original: {} as RelayQuote, + original: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, request: BASE_QUOTE_REQUEST_MOCK, sourceAmount: { fiat: '0', @@ -153,7 +157,11 @@ function getFiatQuoteMock({ }, original: { rampsQuote: RAMPS_QUOTE_MOCK, - relayQuote: {} as RelayQuote, + relayQuote: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, }, request, sourceAmount: { @@ -171,12 +179,12 @@ function getFiatQuoteMock({ } function getRequest({ - orderCode = ORDER_CODE_MOCK, + orderId = ORDER_ID_MOCK, order = getFiatOrderMock(), quotes = [getFiatQuoteMock()], transaction = TRANSACTION_MOCK, }: { - orderCode?: string; + orderId?: string; order?: RampsOrder; quotes?: TransactionPayQuote[]; transaction?: TransactionMeta; @@ -190,7 +198,7 @@ function getRequest({ transactionData: { [transaction.id]: { fiatPayment: { - orderCode, + orderId, }, isLoading: false, tokens: [], @@ -287,21 +295,21 @@ describe('submitFiatQuotes', () => { ); }); - it('throws if order code is missing', async () => { - const { request } = getRequest({ orderCode: '' }); + it('throws if order ID is missing', async () => { + const { request } = getRequest({ orderId: '' }); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Missing order code for fiat submission', + 'Missing order ID for fiat submission', ); }); - it('throws if order code format is invalid', async () => { + it('throws if order ID format is invalid', async () => { const { request } = getRequest({ - orderCode: '/providers/transak/oops', + orderId: '/providers/transak/oops', }); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Invalid order code format: /providers/transak/oops', + 'Invalid order ID format: /providers/transak/oops', ); }); @@ -350,7 +358,7 @@ describe('submitFiatQuotes', () => { return { transactionData: { [TRANSACTION_ID_MOCK]: { - fiatPayment: { orderCode: ORDER_CODE_MOCK }, + fiatPayment: { orderId: ORDER_ID_MOCK }, isLoading: false, tokens: [], }, @@ -481,6 +489,35 @@ describe('submitFiatQuotes', () => { ); }); + it('skips slippage check when original relay target amount is zero', async () => { + const { request } = getRequest(); + request.quotes[0].original.relayQuote = { + details: { currencyOut: { amount: '0' } }, + } as unknown as RelayQuote; + + const result = await submitFiatQuotes(request); + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('throws if relay re-quote slippage exceeds threshold', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_RESULT_MOCK, + original: { + details: { + currencyOut: { amount: '10000000' }, + }, + } as unknown as RelayQuote, + }, + ]); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + /Relay re-quote slippage too high/u, + ); + }); + it('throws if relay re-quote returns no quotes', async () => { getRelayQuotesMock.mockResolvedValue([]); const { request } = getRequest(); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index c037716580a..35a58d61db5 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -25,6 +25,7 @@ const log = createModuleLogger(projectLogger, 'fiat-submit'); const ORDER_POLL_INTERVAL_MS = 1000; const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_SLIPPAGE_PERCENT = 5; const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ RampsOrderStatus.Cancelled, @@ -55,36 +56,35 @@ export async function submitFiatQuotes( } const state = messenger.call('TransactionPayController:getState'); - const orderCode = - state.transactionData[transactionId]?.fiatPayment?.orderCode; + const orderId = state.transactionData[transactionId]?.fiatPayment?.orderId; - if (!orderCode) { - throw new Error('Missing order code for fiat submission'); + if (!orderId) { + throw new Error('Missing order ID for fiat submission'); } - const parsedOrderCode = parseOrderCode(orderCode); + const parsedOrder = parseOrderId(orderId); - if (!parsedOrderCode) { - throw new Error(`Invalid order code format: ${orderCode}`); + if (!parsedOrder) { + throw new Error(`Invalid order ID format: ${orderId}`); } log('Starting fiat order polling', { - orderCode, - providerCode: parsedOrderCode.providerCode, + orderId, + providerCode: parsedOrder.providerCode, transactionId, }); const order = await waitForOrderCompletion({ messenger, - orderCode: parsedOrderCode.orderCode, - providerCode: parsedOrderCode.providerCode, + orderCode: parsedOrder.orderCode, + providerCode: parsedOrder.providerCode, transactionId, walletAddress, }); log('Fiat order completed', { cryptoAmount: order.cryptoAmount, - orderCode, + orderId, transactionId, }); @@ -92,15 +92,15 @@ export async function submitFiatQuotes( } /** - * Parses a normalized order code string into its provider and order components. + * Parses a normalized order ID string into its provider and order components. * - * @param orderCode - Order code in `/providers/{providerCode}/orders/{orderCode}` format. + * @param orderId - Order ID in `/providers/{providerCode}/orders/{orderCode}` format. * @returns The parsed provider and order codes, or `null` if the format is invalid. */ -function parseOrderCode( - orderCode: string, +function parseOrderId( + orderId: string, ): { orderCode: string; providerCode: string } | null { - const parts = orderCode.split('/').filter(Boolean); + const parts = orderId.split('/').filter(Boolean); if (parts.length < 4 || parts[0] !== 'providers' || parts[2] !== 'orders') { return null; @@ -181,6 +181,51 @@ function validateOrderAsset({ } } +/** + * Validates that the re-quoted relay target output hasn't drifted beyond the + * acceptable slippage threshold compared to the original quote shown to the user. + * + * @param options - The validation options. + * @param options.originalTargetRaw - Raw target amount from the original relay quote. + * @param options.reQuotedTargetRaw - Raw target amount from the re-quoted relay. + * @param options.transactionId - Transaction ID for error reporting. + */ +function validateRelaySlippage({ + originalTargetRaw, + reQuotedTargetRaw, + transactionId, +}: { + originalTargetRaw: string; + reQuotedTargetRaw: string; + transactionId: string; +}): void { + const original = new BigNumber(originalTargetRaw); + const reQuoted = new BigNumber(reQuotedTargetRaw); + + if (!original.gt(0) || !reQuoted.gt(0)) { + return; + } + + const slippagePercent = original + .minus(reQuoted) + .dividedBy(original) + .multipliedBy(100); + + log('Relay slippage check', { + originalTargetRaw, + reQuotedTargetRaw, + slippagePercent: slippagePercent.toFixed(2), + transactionId, + }); + + if (slippagePercent.gt(MAX_SLIPPAGE_PERCENT)) { + throw new Error( + `Relay re-quote slippage too high for transaction ${transactionId}: ` + + `${slippagePercent.toFixed(2)}% exceeds ${MAX_SLIPPAGE_PERCENT}% max`, + ); + } +} + /** * Polls the on-ramp order until it reaches a terminal status. * @@ -312,6 +357,13 @@ async function submitRelayAfterFiatCompletion({ throw new Error('No relay quotes returned for completed fiat order'); } + const originalRelayQuote = quotes[0].original.relayQuote; + validateRelaySlippage({ + originalTargetRaw: originalRelayQuote.details.currencyOut.amount, + reQuotedTargetRaw: relayQuotes[0].original.details.currencyOut.amount, + transactionId, + }); + log('Received relay quotes for completed fiat order', { relayQuoteCount: relayQuotes.length, transactionId, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 594e68b3797..dd60e8a1551 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -215,8 +215,8 @@ export type TransactionFiatPayment = { /** Entered fiat amount for the selected payment method. */ amountFiat?: string; - /** Order identifier - `orderCode` specifically used as RampsService:getOrder parameter in normalized format (/providers/{provider}/orders/{id}). */ - orderCode?: string; + /** Order identifier in normalized format (/providers/{provider}/orders/{id}). */ + orderId?: string; /** Selected fiat payment method ID. */ selectedPaymentMethodId?: string; From 4d8e9774516c34cb59e19d66f676768a7eb731cb Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 10:59:13 +0200 Subject: [PATCH 6/7] Update --- .../src/strategy/fiat/fiat-submit.test.ts | 56 +++++++++++++++++++ .../src/strategy/fiat/fiat-submit.ts | 42 ++++++++------ 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 27bbfbf88ac..09018e2cad9 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -274,6 +274,12 @@ describe('submitFiatQuotes', () => { sourceTokenAmount: '1234500000000000000', }), ]); + expect(getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data).toBe( + undefined, + ); + expect( + getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, + ).toBe(undefined); expect(submitRelayQuotesMock).toHaveBeenCalledWith( expect.objectContaining({ quotes: [RELAY_QUOTE_RESULT_MOCK], @@ -391,6 +397,56 @@ describe('submitFiatQuotes', () => { expect(getOrderCallCount).toBe(2); }); + it('continues polling after transient getOrder error', async () => { + jest.useFakeTimers(); + + const completedOrder = getFiatOrderMock({ + cryptoAmount: '1', + status: RampsOrderStatus.Completed, + }); + + let getOrderCallCount = 0; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { orderId: ORDER_ID_MOCK }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + getOrderCallCount += 1; + if (getOrderCallCount === 1) { + throw new Error('Network error'); + } + return completedOrder; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const request: PayStrategyExecuteRequest = { + isSmartTransaction: () => false, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + const promise = submitFiatQuotes(request); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + expect(getOrderCallCount).toBe(2); + }); + it('throws if fiat order polling times out and includes last status', async () => { const dateNowSpy = jest .spyOn(Date, 'now') diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 35a58d61db5..8ca4e08c388 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -254,27 +254,35 @@ async function waitForOrderCompletion({ let lastStatus: string | undefined; while (true) { - const order = await messenger.call( - 'RampsController:getOrder', - providerCode, - orderCode, - walletAddress, - ); + let order: RampsOrder | undefined; + + try { + order = await messenger.call( + 'RampsController:getOrder', + providerCode, + orderCode, + walletAddress, + ); + } catch (error) { + log('Order polling network error', error); + } - lastStatus = order.status; + if (order) { + lastStatus = order.status; - log('Polled fiat order', { - orderStatus: order.status, - providerCode, - transactionId, - }); + log('Polled fiat order', { + orderStatus: order.status, + providerCode, + transactionId, + }); - if (order.status === RampsOrderStatus.Completed) { - return order; - } + if (order.status === RampsOrderStatus.Completed) { + return order; + } - if (TERMINAL_FAILURE_STATUSES.includes(order.status)) { - throw new Error(`Fiat order ${order.status.toLowerCase()}`); + if (TERMINAL_FAILURE_STATUSES.includes(order.status)) { + throw new Error(`Fiat order ${order.status.toLowerCase()}`); + } } if (Date.now() - startTime >= ORDER_POLL_TIMEOUT_MS) { From 6cd9e685de84547e1585cdee4ad3c86ebcf00b62 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 13:05:16 +0200 Subject: [PATCH 7/7] Fix lint --- .../src/strategy/fiat/fiat-submit.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 09018e2cad9..ab0615d1498 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -274,12 +274,12 @@ describe('submitFiatQuotes', () => { sourceTokenAmount: '1234500000000000000', }), ]); - expect(getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data).toBe( - undefined, - ); + expect( + getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data, + ).toBeUndefined(); expect( getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, - ).toBe(undefined); + ).toBeUndefined(); expect(submitRelayQuotesMock).toHaveBeenCalledWith( expect.objectContaining({ quotes: [RELAY_QUOTE_RESULT_MOCK],