diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0aafdfb6575..1a87d63e1e3 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 - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) 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..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 @@ -1,19 +1,594 @@ +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_ID_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: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown 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: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, + }, + request, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: TransactionPayStrategy.Fiat, + targetAmount: { + fiat: '0', + usd: '0', + }, + }; +} + +function getRequest({ + orderId = ORDER_ID_MOCK, + order = getFiatOrderMock(), + quotes = [getFiatQuoteMock()], + transaction = TRANSACTION_MOCK, +}: { + orderId?: 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: { + orderId, + }, + 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( + getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data, + ).toBeUndefined(); + expect( + getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, + ).toBeUndefined(); + 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 ID is missing', async () => { + const { request } = getRequest({ orderId: '' }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing order ID for fiat submission', + ); + }); + + it('throws if order ID format is invalid', async () => { + const { request } = getRequest({ + orderId: '/providers/transak/oops', + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Invalid order ID 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('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(); + + 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: { orderId: ORDER_ID_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, + 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('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, - 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', + }, + }), + }); + + 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' }), }); - expect(result).toStrictEqual({ transactionHash: undefined }); + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Computed fiat order source amount is not positive', + ); + }); + + 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(); + + 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..8ca4e08c388 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,395 @@ +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 MAX_SLIPPAGE_PERCENT = 5; + +const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ + RampsOrderStatus.Cancelled, + RampsOrderStatus.Failed, + RampsOrderStatus.IdExpired, +]; /** - * 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 orderId = state.transactionData[transactionId]?.fiatPayment?.orderId; + + if (!orderId) { + throw new Error('Missing order ID for fiat submission'); + } + + const parsedOrder = parseOrderId(orderId); + + if (!parsedOrder) { + throw new Error(`Invalid order ID format: ${orderId}`); + } + + log('Starting fiat order polling', { + orderId, + providerCode: parsedOrder.providerCode, + transactionId, + }); + + const order = await waitForOrderCompletion({ + messenger, + orderCode: parsedOrder.orderCode, + providerCode: parsedOrder.providerCode, + transactionId, + walletAddress, + }); + + log('Fiat order completed', { + cryptoAmount: order.cryptoAmount, + orderId, + transactionId, + }); + + return await submitRelayAfterFiatCompletion({ order, request }); +} + +/** + * Parses a normalized order ID string into its provider and order components. + * + * @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 parseOrderId( + orderId: string, +): { orderCode: string; providerCode: string } | null { + const parts = orderId.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}`, + ); + } +} + +/** + * 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. + * + * @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) { + let order: RampsOrder | undefined; + + try { + order = await messenger.call( + 'RampsController:getOrder', + providerCode, + orderCode, + walletAddress, + ); + } catch (error) { + log('Order polling network error', error); + } + + if (order) { + 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) { + throw new Error( + `Fiat order polling timed out (last status: ${lastStatus})`, + ); + } + + 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'); + } + + 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, + }); + + 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..dd60e8a1551 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 in normalized format (/providers/{provider}/orders/{id}). */ + orderId?: string; + /** Selected fiat payment method ID. */ selectedPaymentMethodId?: string; };