From 439eaafd4702cbc74d4357df5d525ca6ebd14156 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:01:25 +0200 Subject: [PATCH 01/17] Implement fiat strategy submit flow with order polling and relay execution --- .../src/strategy/fiat/fiat-submit.test.ts | 489 +++++++++++++++++- .../src/strategy/fiat/fiat-submit.ts | 332 +++++++++++- .../src/strategy/relay/relay-quotes.ts | 16 +- .../transaction-pay-controller/src/types.ts | 9 +- 4 files changed, 825 insertions(+), 21 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 6d444be1b4..6b215bd5cc 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,494 @@ +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 { TransactionPayControllerMessenger } from '../..'; import type { TransactionPayQuote } from '../../types'; import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; +import type { TransactionPayFiatAsset } from './constants'; +import { submitFiatQuotes } from './fiat-submit'; +import type { FiatQuote } 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', + }, + }), }); - expect(result).toStrictEqual({ transactionHash: undefined }); + 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' }), + }); + + 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 29b3802029..4498c3fd9b 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,334 @@ -import type { PayStrategy, PayStrategyExecuteRequest } from '../../types'; import type { FiatQuote } from './types'; +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 { 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 19c0b4d332..ccbc6f9343 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 41cdc70b8c..915bba3ad3 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -20,7 +20,10 @@ import type { 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, @@ -55,6 +58,7 @@ export type AllowedActions = | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction + | RampsControllerGetOrderAction | RampsControllerGetQuotesAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction @@ -239,6 +243,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 a2f007a63991fa0d4b28cd52ca6f3c256e28c6b4 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:03:39 +0200 Subject: [PATCH 02/17] 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 d99846839e..c0691d3fc6 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)) + ## [20.2.0] ### Changed From 306327dcbcfab48d757949f56c01013c08b2d123 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:14:41 +0200 Subject: [PATCH 03/17] 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 6b215bd5cc..e9b15cee68 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 @@ -328,6 +328,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 4498c3fd9b..2c36af990a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -28,8 +28,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 ff583a2e50c69732b016a1e565c8e1d1b865fe1c Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 31 Mar 2026 11:18:17 +0200 Subject: [PATCH 04/17] 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 2c36af990a..cdfbbdc587 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -234,8 +234,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 fb6fb4ccf79d2198821e8413ceb8f17357f810f8 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 10:13:22 +0200 Subject: [PATCH 05/17] 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 e9b15cee68..64a1524989 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 @@ -31,7 +31,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, @@ -93,7 +93,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', @@ -156,7 +160,11 @@ function getFiatQuoteMock({ }, original: { rampsQuote: RAMPS_QUOTE_MOCK, - relayQuote: {} as RelayQuote, + relayQuote: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, }, request, sourceAmount: { @@ -174,12 +182,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; @@ -193,7 +201,7 @@ function getRequest({ transactionData: { [transaction.id]: { fiatPayment: { - orderCode, + orderId, }, isLoading: false, tokens: [], @@ -290,21 +298,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', ); }); @@ -353,7 +361,7 @@ describe('submitFiatQuotes', () => { return { transactionData: { [TRANSACTION_ID_MOCK]: { - fiatPayment: { orderCode: ORDER_CODE_MOCK }, + fiatPayment: { orderId: ORDER_ID_MOCK }, isLoading: false, tokens: [], }, @@ -484,6 +492,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 cdfbbdc587..483f28d087 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -26,6 +26,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, @@ -56,36 +57,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, }); @@ -93,15 +93,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; @@ -182,6 +182,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. * @@ -313,6 +358,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 915bba3ad3..20579711d1 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -243,8 +243,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 e5e6ce2817e712c5c1adbee5c2e98a689334ef5a Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 10:59:13 +0200 Subject: [PATCH 06/17] 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 64a1524989..f517d9d37f 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 @@ -277,6 +277,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], @@ -394,6 +400,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 483f28d087..3d176a04a8 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -255,27 +255,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 958fe54b5dca05ad16330a59d165975784e893ec Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 1 Apr 2026 13:05:16 +0200 Subject: [PATCH 07/17] 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 f517d9d37f..a3ac3f9846 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 @@ -277,12 +277,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], From ac8d51b069e67db0cc7d1cfce736f4d946e629ca Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Apr 2026 10:58:31 +0200 Subject: [PATCH 08/17] Update --- .../src/TransactionPayController.test.ts | 134 +++++++++++++++++- .../src/TransactionPayController.ts | 38 ++++- .../transaction-pay-controller/src/types.ts | 2 + 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index eba792cae2..bd8e5a4cfe 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -7,18 +7,21 @@ import { TransactionPayController } from '.'; import { updateFiatPayment } from './actions/update-fiat-payment'; import { updatePaymentToken } from './actions/update-payment-token'; import { TransactionPayStrategy } from './constants'; +import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import { getMessengerMock } from './tests/messenger-mock'; import type { TransactionPayControllerMessenger, TransactionPaySourceAmount, + UpdateTransactionDataCallback, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { pollTransactionChanges } from './utils/transaction'; +import { getTransaction, pollTransactionChanges } from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); jest.mock('./actions/update-payment-token'); +jest.mock('./strategy/fiat/utils'); jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); @@ -32,6 +35,10 @@ const CHAIN_ID_MOCK = '0x1' as Hex; describe('TransactionPayController', () => { const updateFiatPaymentMock = jest.mocked(updateFiatPayment); const updatePaymentTokenMock = jest.mocked(updatePaymentToken); + const deriveFiatAssetForFiatPaymentMock = jest.mocked( + deriveFiatAssetForFiatPayment, + ); + const getTransactionMock = jest.mocked(getTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); @@ -634,4 +641,129 @@ describe('TransactionPayController', () => { ).toBeUndefined(); }); }); + + describe('fiat token selection', () => { + const CAIP_ASSET_ID_MOCK = 'eip155:137/slip44:966'; + const FIAT_ASSET_MOCK = { + address: '0x0000000000000000000000000000000000001010' as Hex, + caipAssetId: CAIP_ASSET_ID_MOCK, + chainId: '0x89' as Hex, + decimals: 18, + }; + + let setSelectedTokenMock: jest.Mock; + + beforeEach(() => { + setSelectedTokenMock = jest.fn(); + messenger.registerActionHandler( + 'RampsController:setSelectedToken' as never, + setSelectedTokenMock as never, + ); + }); + + function getUpdateTransactionData(): UpdateTransactionDataCallback { + const controller = createController(); + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + return updatePaymentTokenMock.mock.calls[0][1].updateTransactionData; + } + + it('does not call setSelectedToken when only fiat amount changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { amountFiat: '100' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('calls RampsController:setSelectedToken when payment method changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).toHaveBeenCalledWith(CAIP_ASSET_ID_MOCK); + }); + + it('triggers quote update when fiat payment changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { amountFiat: '100' }; + }); + + expect(updateQuotesMock).toHaveBeenCalledTimes(1); + }); + + it('does not call setSelectedToken when transaction is not found', () => { + getTransactionMock.mockReturnValue(undefined); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not call setSelectedToken when fiat asset cannot be derived', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not call setSelectedToken when fiat payment does not change', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.sourceAmounts = [ + { sourceAmountHuman: '1.23' } as TransactionPaySourceAmount, + ]; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not throw when setSelectedToken throws', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + setSelectedTokenMock.mockImplementation(() => { + throw new Error('Tokens not loaded'); + }); + + const updateTransactionData = getUpdateTransactionData(); + + expect(() => { + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + }).not.toThrow(); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index d52a50466c..e1b7670379 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -12,6 +12,7 @@ import { TransactionPayStrategy, } from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; +import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import type { GetDelegationTransactionCallback, TransactionConfigCallback, @@ -25,7 +26,7 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { pollTransactionChanges } from './utils/transaction'; +import { getTransaction, pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', @@ -215,6 +216,7 @@ export class TransactionPayController extends BaseController< fn: (transactionData: Draft) => void, ): void { let shouldUpdateQuotes = false; + let shouldUpdateFiatToken = false; this.update((state) => { const { transactionData } = state; @@ -224,6 +226,9 @@ export class TransactionPayController extends BaseController< const originalIsMaxAmount = current?.isMaxAmount; const originalIsPostQuote = current?.isPostQuote; const originalAccountOverride = current?.accountOverride; + const originalFiatPaymentAmount = current?.fiatPayment?.amountFiat; + const originalFiatPaymentMethodId = + current?.fiatPayment?.selectedPaymentMethodId; if (!current) { transactionData[transactionId] = { @@ -247,6 +252,11 @@ export class TransactionPayController extends BaseController< const isPostQuoteUpdated = current.isPostQuote !== originalIsPostQuote; const isAccountOverrideUpdated = current.accountOverride !== originalAccountOverride; + const isFiatAmountUpdated = + current.fiatPayment?.amountFiat !== originalFiatPaymentAmount; + const isFiatPaymentMethodUpdated = + current.fiatPayment?.selectedPaymentMethodId !== + originalFiatPaymentMethodId; if ( isPaymentTokenUpdated || @@ -259,8 +269,34 @@ export class TransactionPayController extends BaseController< shouldUpdateQuotes = true; } + + if (isFiatAmountUpdated || isFiatPaymentMethodUpdated) { + shouldUpdateQuotes = true; + } + + if (isFiatPaymentMethodUpdated) { + shouldUpdateFiatToken = true; + } }); + if (shouldUpdateFiatToken) { + const transaction = getTransaction( + transactionId, + this.messenger, + ) as TransactionMeta; + const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + if (fiatAsset) { + try { + this.messenger.call( + 'RampsController:setSelectedToken', + fiatAsset.caipAssetId, + ); + } catch { + // Intentionally no-op — tokens may not be loaded in RampsController yet. + } + } + } + if (shouldUpdateQuotes) { updateQuotes({ getStrategies: this.#getStrategiesWithFallback.bind(this), diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 20579711d1..43a9233cbb 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -23,6 +23,7 @@ import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/netw import type { RampsControllerGetOrderAction, RampsControllerGetQuotesAction, + RampsControllerSetSelectedTokenAction, } from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { @@ -60,6 +61,7 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | RampsControllerGetOrderAction | RampsControllerGetQuotesAction + | RampsControllerSetSelectedTokenAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction | TokenRatesControllerGetStateAction From 525facb01c0ddaa877ff984d9e0069d028de4e02 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 29 Apr 2026 11:33:08 +0200 Subject: [PATCH 09/17] 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 3d176a04a8..c18fefa0cc 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -1,4 +1,3 @@ -import type { FiatQuote } from './types'; import type { RampsOrder, RampsOrderCryptoCurrency, @@ -8,10 +7,10 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { projectLogger } from '../../logger'; import type { TransactionPayFiatAsset } from './constants'; import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment } from './utils'; -import { projectLogger } from '../../logger'; import type { PayStrategy, PayStrategyExecuteRequest, @@ -357,6 +356,7 @@ async function submitRelayAfterFiatCompletion({ }); const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, messenger, requests: [relayRequest], transaction, @@ -379,6 +379,7 @@ async function submitRelayAfterFiatCompletion({ }); const relaySubmitRequest: PayStrategyExecuteRequest = { + accountSupports7702: request.accountSupports7702, isSmartTransaction: request.isSmartTransaction, messenger, quotes: relayQuotes, From e719c1acefbb896ebb16f713b1aa34621d7dd9a4 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 29 Apr 2026 11:37:57 +0200 Subject: [PATCH 10/17] Update --- .../src/strategy/fiat/fiat-submit.test.ts | 11 ++++------- .../src/strategy/fiat/fiat-submit.ts | 6 +++--- 2 files changed, 7 insertions(+), 10 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 a3ac3f9846..8b94b88f2f 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 @@ -8,13 +8,6 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import type { TransactionPayQuote } from '../../types'; -import { submitFiatQuotes } from './fiat-submit'; -import type { FiatQuote } from './types'; -import type { TransactionPayFiatAsset } from './constants'; -import { submitFiatQuotes } from './fiat-submit'; -import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment } from './utils'; import { TransactionPayStrategy } from '../../constants'; import type { PayStrategyExecuteRequest, @@ -24,6 +17,10 @@ import type { import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; +import type { TransactionPayFiatAsset } from './constants'; +import { submitFiatQuotes } from './fiat-submit'; +import type { FiatQuote } from './types'; +import { deriveFiatAssetForFiatPayment } from './utils'; jest.mock('./utils'); jest.mock('../relay/relay-quotes'); 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 c18fefa0cc..f80a7bf583 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -8,9 +8,6 @@ import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { projectLogger } from '../../logger'; -import type { TransactionPayFiatAsset } from './constants'; -import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment } from './utils'; import type { PayStrategy, PayStrategyExecuteRequest, @@ -20,6 +17,9 @@ import type { import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; +import type { TransactionPayFiatAsset } from './constants'; +import type { FiatQuote } from './types'; +import { deriveFiatAssetForFiatPayment } from './utils'; const log = createModuleLogger(projectLogger, 'fiat-submit'); From 23e00bed0db406c4f73834b334b7b75ca61b54a7 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 08:32:10 +0200 Subject: [PATCH 11/17] Remove unnecessary transactionId from logs --- .../transaction-pay-controller/src/strategy/fiat/fiat-submit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f80a7bf583..94c2234428 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -220,7 +220,7 @@ function validateRelaySlippage({ if (slippagePercent.gt(MAX_SLIPPAGE_PERCENT)) { throw new Error( - `Relay re-quote slippage too high for transaction ${transactionId}: ` + + `Relay re-quote slippage too high for transaction ` + `${slippagePercent.toFixed(2)}% exceeds ${MAX_SLIPPAGE_PERCENT}% max`, ); } From 67166667c65da75c8ad2d61df53a28a06174323c Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 13:00:06 +0200 Subject: [PATCH 12/17] Fix lint --- .../src/strategy/relay/hyperliquid-withdraw.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index 69362a6579..0ddb44519c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -123,14 +123,16 @@ async function executeAuthorizeStep( log('Signing authorize (nonce-mapping)', { domain: sign.domain }); - const signature = await messenger.call( + // Cast needed: the AllowedActions union is too large for TypeScript to + // resolve the messenger return type (it degrades to `unknown`). + const signature = (await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(typedData), }, SignTypedDataVersion.V4, - ); + )) as string; log('Posting authorize signature to Relay'); @@ -220,14 +222,16 @@ async function executeDepositStep( action: action.type, }); - const signature = await messenger.call( + // Cast needed: the AllowedActions union is too large for TypeScript to + // resolve the messenger return type (it degrades to `unknown`). + const signature = (await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(signatureData), }, SignTypedDataVersion.V4, - ); + )) as string; // eslint-disable-next-line id-length const r = signature.slice(0, 66); From c1772887cd2384f44f6fd27d66d65f0694305dd5 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 13:42:22 +0200 Subject: [PATCH 13/17] Revert --- .../src/strategy/relay/hyperliquid-withdraw.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index 0ddb44519c..69362a6579 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -123,16 +123,14 @@ async function executeAuthorizeStep( log('Signing authorize (nonce-mapping)', { domain: sign.domain }); - // Cast needed: the AllowedActions union is too large for TypeScript to - // resolve the messenger return type (it degrades to `unknown`). - const signature = (await messenger.call( + const signature = await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(typedData), }, SignTypedDataVersion.V4, - )) as string; + ); log('Posting authorize signature to Relay'); @@ -222,16 +220,14 @@ async function executeDepositStep( action: action.type, }); - // Cast needed: the AllowedActions union is too large for TypeScript to - // resolve the messenger return type (it degrades to `unknown`). - const signature = (await messenger.call( + const signature = await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(signatureData), }, SignTypedDataVersion.V4, - )) as string; + ); // eslint-disable-next-line id-length const r = signature.slice(0, 66); From 529d755ada766c37eb52161c846866bd4c3ecc04 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 17:31:48 +0200 Subject: [PATCH 14/17] fix changelog --- packages/transaction-pay-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 623c7b629c..cd14cdf303 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,11 +7,14 @@ 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)) + ## [21.0.0] ### Added -- Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) - Add Gas Station support for Across source transactions when native balance is insufficient ([#8588](https://github.com/MetaMask/core/pull/8588)) ### Changed From fd6d1f8236bd50eada4e26bd151fc6508fdeb6c5 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 19:18:02 +0200 Subject: [PATCH 15/17] Update --- .../src/strategy/relay/hyperliquid-withdraw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index 69362a6579..d848b5abea 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -123,14 +123,14 @@ async function executeAuthorizeStep( log('Signing authorize (nonce-mapping)', { domain: sign.domain }); - const signature = await messenger.call( + const signature = (await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(typedData), }, SignTypedDataVersion.V4, - ); + )) as string; log('Posting authorize signature to Relay'); @@ -220,14 +220,14 @@ async function executeDepositStep( action: action.type, }); - const signature = await messenger.call( + const signature = (await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(signatureData), }, SignTypedDataVersion.V4, - ); + )) as string; // eslint-disable-next-line id-length const r = signature.slice(0, 66); From 90db9b9b4a9538c855186ea94a4b711b16936c7b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 19:40:23 +0200 Subject: [PATCH 16/17] Revert --- .../src/strategy/relay/hyperliquid-withdraw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts index d848b5abea..69362a6579 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-withdraw.ts @@ -123,14 +123,14 @@ async function executeAuthorizeStep( log('Signing authorize (nonce-mapping)', { domain: sign.domain }); - const signature = (await messenger.call( + const signature = await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(typedData), }, SignTypedDataVersion.V4, - )) as string; + ); log('Posting authorize signature to Relay'); @@ -220,14 +220,14 @@ async function executeDepositStep( action: action.type, }); - const signature = (await messenger.call( + const signature = await messenger.call( 'KeyringController:signTypedMessage', { from, data: JSON.stringify(signatureData), }, SignTypedDataVersion.V4, - )) as string; + ); // eslint-disable-next-line id-length const r = signature.slice(0, 66); From 45c2c795d92f32380e2c432ce32393f8632cf94d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 4 May 2026 19:45:35 +0200 Subject: [PATCH 17/17] Update --- packages/transaction-pay-controller/src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index e577b75511..32b3c80cf6 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -25,6 +25,7 @@ import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metam import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; import type { Quote as RampsQuote } from '@metamask/ramps-controller'; import type { + RampsControllerGetOrderAction, RampsControllerGetQuotesAction, RampsControllerGetStateAction, RampsControllerSetSelectedTokenAction,