From 65cffa91913890e9270264b8d731b15a58587a4b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:23:23 +0200 Subject: [PATCH 01/28] feat: add getAmountData callback to TransactionPayController Add optional getAmountData callback that re-encodes nested transaction calldata for a given token amount. Used by transaction types with non-standard nested data (e.g. vault approve + deposit) that require client-side context (vault config, RPC providers) to encode. --- ...actionPayController-method-action-types.ts | 6 +++++ .../src/TransactionPayController.ts | 14 ++++++++++ .../transaction-pay-controller/src/index.ts | 4 +++ .../transaction-pay-controller/src/types.ts | 26 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 3eb0b69758..a46f576624 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -120,6 +120,11 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { handler: TransactionPayController['polymarketSubmitDepositWalletBatch']; }; +export type TransactionPayControllerGetAmountDataAction = { + type: `TransactionPayController:getAmountData`; + handler: TransactionPayController['getAmountData']; +}; + /** * Union of all TransactionPayController action types. */ @@ -127,6 +132,7 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction + | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetPaymentOverrideDataAction | TransactionPayControllerGetStrategyAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index cc6e9f7a3f..9728deb7df 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -14,6 +14,7 @@ import { import { QuoteRefresher } from './helpers/QuoteRefresher'; import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import type { + GetAmountDataCallback, GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, PolymarketCallbacks, @@ -36,6 +37,7 @@ import { } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ + 'getAmountData', 'getDelegationTransaction', 'getPaymentOverrideData', 'getStrategy', @@ -64,6 +66,8 @@ export class TransactionPayController extends BaseController< TransactionPayControllerState, TransactionPayControllerMessenger > { + readonly #getAmountData?: GetAmountDataCallback; + readonly #getDelegationTransaction: GetDelegationTransactionCallback; readonly #getPaymentOverrideData?: GetPaymentOverrideDataCallback; @@ -79,6 +83,7 @@ export class TransactionPayController extends BaseController< readonly #polymarket?: PolymarketCallbacks; constructor({ + getAmountData, getDelegationTransaction, getPaymentOverrideData, getStrategy, @@ -94,6 +99,7 @@ export class TransactionPayController extends BaseController< state: { ...getDefaultState(), ...state }, }); + this.#getAmountData = getAmountData; this.#getDelegationTransaction = getDelegationTransaction; this.#getPaymentOverrideData = getPaymentOverrideData; this.#getStrategy = getStrategy; @@ -233,6 +239,14 @@ export class TransactionPayController extends BaseController< * @param args - The arguments forwarded to the {@link GetPaymentOverrideDataCallback}. * @returns A promise resolving to the additional transactions array. */ + getAmountData( + ...args: Parameters + ): ReturnType { + return ( + this.#getAmountData?.(...args) ?? Promise.resolve({ updates: [] }) + ); + } + getPaymentOverrideData( ...args: Parameters ): ReturnType { diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 9b0b113627..fe2679a011 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,4 +1,7 @@ export type { + GetAmountDataCallback, + GetAmountDataRequest, + GetAmountDataResponse, GetPaymentOverrideDataRequest, GetPaymentOverrideDataResponse, TransactionConfig, @@ -23,6 +26,7 @@ export type { UpdatePaymentTokenRequest, } from './types'; export type { + TransactionPayControllerGetAmountDataAction, TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fa95fa1f2d..458bafee6c 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -177,6 +177,29 @@ export type GetPaymentOverrideDataCallback = ( request: GetPaymentOverrideDataRequest, ) => Promise; +export type GetAmountDataRequest = { + /** Raw token amount (atomic units) to encode into calldata. */ + amount: string; + + /** Metadata of the transaction whose nested calls need updating. */ + transaction: TransactionMeta; +}; + +export type GetAmountDataResponse = { + /** Per-nested-call data updates; empty when no update is needed. */ + updates: { nestedTransactionIndex: number; data: Hex }[]; +}; + +/** + * Optional callback that re-encodes nested transaction calldata for a given + * token amount. Used by transaction types with non-standard nested data + * (e.g. vault approve + deposit) that cannot be derived from the amount alone + * without client-side context (vault config, RPC providers, etc.). + */ +export type GetAmountDataCallback = ( + request: GetAmountDataRequest, +) => Promise; + /** Callback to update fiat payment state. */ export type TransactionFiatPaymentCallback = ( fiatPayment: TransactionFiatPayment, @@ -213,6 +236,9 @@ export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ /** Options for the TransactionPayController. */ export type TransactionPayControllerOptions = { + /** Optional callback to re-encode nested transaction calldata for a given amount. */ + getAmountData?: GetAmountDataCallback; + /** Callback to convert a transaction into a redeem delegation. */ getDelegationTransaction: GetDelegationTransactionCallback; From 95d33b41fd5a9a6d132078700008180203c23019 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:23:31 +0200 Subject: [PATCH 02/28] fix: fiat moneyAccountDeposit three-phase relay submit After fiat order settlement, use a three-phase relay flow: 1. Discovery quote (EXACT_INPUT) to find settled target token output 2. Re-encode nested calldata via getAmountData callback 3. Real relay quote with delegation (EXACT_OUTPUT) for execution Also removes validateRelaySlippage which incorrectly compared outputs from relay quotes made with different source amounts, and removes isMaxAmount:true which caused delegation errors with nested transactions. --- .../src/strategy/fiat/fiat-submit.ts | 190 ++++++++++++------ 1 file changed, 127 insertions(+), 63 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 0c82c8c3b6..a6eccdde22 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -15,7 +15,7 @@ import type { TransactionPayControllerMessenger, } from '../../types'; import { buildCaipAssetType } from '../../utils/token'; -import { updateTransaction } from '../../utils/transaction'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -27,7 +27,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 MAX_RATE_DRIFT_PERCENT = 10; const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ RampsOrderStatus.Cancelled, @@ -169,51 +169,6 @@ 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 ` + - `${slippagePercent.toFixed(2)}% exceeds ${MAX_SLIPPAGE_PERCENT}% max`, - ); - } -} - /** * Polls the on-ramp order until it reaches a terminal status. * @@ -327,39 +282,91 @@ async function submitRelayAfterFiatCompletion({ }); const baseRequest = quotes[0].request; - const relayRequest: QuoteRequest = { + + // Phase 1: Discovery quote with EXACT_INPUT to find the actual target + // token output for the settled source amount. + const discoveryRequest: QuoteRequest = { ...baseRequest, - isMaxAmount: true, - isPostQuote: false, + isMaxAmount: false, + isPostQuote: true, sourceBalanceRaw: sourceAmountRaw, sourceTokenAmount: sourceAmountRaw, }; - log('Re-quoting relay from completed fiat order', { - completedOrderAmount: order.cryptoAmount, - relayRequest, - sourceAmountRaw, + const discoveryQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [discoveryRequest], + transaction, + }); + + if (!discoveryQuotes.length) { + throw new Error('No relay quotes returned for fiat discovery'); + } + + const discoveryRelay = discoveryQuotes[0].original; + const settledTargetRaw = discoveryRelay.details.currencyOut.minimumAmount; + + const originalRelayQuote = quotes[0].original.relayQuote; + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: discoveryRelay, transactionId, }); + // Phase 2: Delegate calldata re-encoding to the client via getAmountData. + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { amount: settledTargetRaw, transaction }, + ); + + const hasNestedCalldata = (transaction.nestedTransactions?.length ?? 0) >= 2; + if (hasNestedCalldata && !updates.length) { + throw new Error( + 'getAmountData returned no updates for transaction with nested calldata', + ); + } + + if (updates.length) { + updateTransaction( + { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}` as Hex; + } + }, + ); + } + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + + // Phase 3: Real relay quote with delegation (standard crypto-like flow). + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + targetAmountMinimum: settledTargetRaw, + }; + const relayQuotes = await getRelayQuotes({ accountSupports7702: request.accountSupports7702, messenger, requests: [relayRequest], - transaction, + transaction: updatedTransaction, }); 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, @@ -370,7 +377,7 @@ async function submitRelayAfterFiatCompletion({ isSmartTransaction: request.isSmartTransaction, messenger, quotes: relayQuotes, - transaction, + transaction: updatedTransaction, }; const relayResult = await submitRelayQuotes(relaySubmitRequest); @@ -382,3 +389,60 @@ async function submitRelayAfterFiatCompletion({ return relayResult; } + +/** + * Validates that the relay exchange rate hasn't drifted significantly between + * the original quoting phase and the post-settlement discovery quote. + * + * Compares the USD output/input ratio from both quotes. This normalises for + * different source amounts (quoting phase uses a theoretical amount, discovery + * uses the actual settled amount) so the comparison reflects genuine rate + * movement rather than amount differences. + */ +function validateRelayRateDrift({ + originalQuote, + discoveryQuote, + transactionId, +}: { + originalQuote: RelayQuote; + discoveryQuote: RelayQuote; + transactionId: string; +}): void { + const originalIn = new BigNumber(originalQuote.details.currencyIn.amountUsd); + const originalOut = new BigNumber(originalQuote.details.currencyOut.amountUsd); + const discoveryIn = new BigNumber(discoveryQuote.details.currencyIn.amountUsd); + const discoveryOut = new BigNumber( + discoveryQuote.details.currencyOut.amountUsd, + ); + + if ( + !originalIn.gt(0) || + !originalOut.gt(0) || + !discoveryIn.gt(0) || + !discoveryOut.gt(0) + ) { + return; + } + + const originalRate = originalOut.dividedBy(originalIn); + const discoveryRate = discoveryOut.dividedBy(discoveryIn); + + const driftPercent = originalRate + .minus(discoveryRate) + .dividedBy(originalRate) + .multipliedBy(100); + + log('Relay rate drift check', { + originalRate: originalRate.toFixed(6), + discoveryRate: discoveryRate.toFixed(6), + driftPercent: driftPercent.toFixed(2), + transactionId, + }); + + if (driftPercent.abs().gt(MAX_RATE_DRIFT_PERCENT)) { + throw new Error( + `Relay rate drift too high for transaction ` + + `${driftPercent.toFixed(2)}% exceeds ${MAX_RATE_DRIFT_PERCENT}% max`, + ); + } +} From 5a91c7d6b3855b04ed14692e5e9968749a9dede1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:53:00 +0200 Subject: [PATCH 03/28] fix: lint errors and update tests for fiat submit changes - Fix unnecessary type assertion on requiredAssets hex amount - Add JSDoc @param tags to validateRelayRateDrift - Update fiat-submit tests for three-phase relay flow - Add getAmountData controller tests - Add rate drift, stale calldata, and discovery quote error tests - 100% test coverage maintained --- .../src/TransactionPayController.test.ts | 45 ++++ .../src/strategy/fiat/fiat-submit.test.ts | 194 ++++++++++++++++-- .../src/strategy/fiat/fiat-submit.ts | 7 +- 3 files changed, 228 insertions(+), 18 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index b7d2fc2b9c..1b9e31f0c4 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -518,6 +518,51 @@ describe('TransactionPayController', () => { }); }); + describe('getAmountData', () => { + it('delegates to the callback', async () => { + const resultMock = { + updates: [{ nestedTransactionIndex: 0, data: '0xabc' as const }], + }; + const getAmountDataMock = jest.fn().mockResolvedValue(resultMock); + + new TransactionPayController({ + getAmountData: getAmountDataMock, + getDelegationTransaction: jest.fn(), + messenger, + }); + + const requestMock = { + amount: '5000000', + transaction: TRANSACTION_META_MOCK, + }; + + const result = await messenger.call( + 'TransactionPayController:getAmountData', + requestMock, + ); + + expect(getAmountDataMock).toHaveBeenCalledWith(requestMock); + expect(result).toStrictEqual(resultMock); + }); + + it('returns empty updates when no callback is configured', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + }); + + const result = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount: '5000000', + transaction: TRANSACTION_META_MOCK, + }, + ); + + expect(result).toStrictEqual({ updates: [] }); + }); + }); + describe('polymarket callbacks', () => { const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex; const DEPOSIT_WALLET_MOCK = 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 d86cc937ab..b7db9097be 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 @@ -15,7 +15,7 @@ import type { TransactionPayQuote, } from '../../types'; import { buildCaipAssetType } from '../../utils/token'; -import { updateTransaction } from '../../utils/transaction'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -96,7 +96,12 @@ const RELAY_QUOTE_RESULT_MOCK = { }, original: { details: { - currencyOut: { amount: '12000000' }, + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, }, } as unknown as RelayQuote, request: BASE_QUOTE_REQUEST_MOCK, @@ -163,7 +168,12 @@ function getFiatQuoteMock({ rampsQuote: RAMPS_QUOTE_MOCK, relayQuote: { details: { - currencyOut: { amount: '12000000' }, + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, }, } as unknown as RelayQuote, }, @@ -218,6 +228,10 @@ function getRequest({ return order; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -240,6 +254,7 @@ describe('submitFiatQuotes', () => { deriveFiatAssetForFiatPayment, ); const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw); + const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); @@ -251,6 +266,7 @@ describe('submitFiatQuotes', () => { buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); + getTransactionMock.mockReturnValue(TRANSACTION_MOCK); getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0x1234', @@ -284,21 +300,24 @@ describe('submitFiatQuotes', () => { fiatAsset: FIAT_ASSET_MOCK, walletAddress: WALLET_ADDRESS_MOCK, }); - expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(2); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ - isMaxAmount: true, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: '1234500000000000000', + sourceTokenAmount: '1234500000000000000', + }), + ]); + expect(getRelayQuotesMock.mock.calls[1][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: false, isPostQuote: false, sourceBalanceRaw: '1234500000000000000', sourceTokenAmount: '1234500000000000000', + targetAmountMinimum: '11900000', }), ]); - 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], @@ -496,6 +515,10 @@ describe('submitFiatQuotes', () => { return getOrderCallCount === 1 ? pendingOrder : completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -549,6 +572,10 @@ describe('submitFiatQuotes', () => { return completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -668,10 +695,13 @@ describe('submitFiatQuotes', () => { ); }); - it('skips slippage check when original relay target amount is zero', async () => { + it('skips rate drift check when original relay amounts are zero', async () => { const { request } = getRequest(); request.quotes[0].original.relayQuote = { - details: { currencyOut: { amount: '0' } }, + details: { + currencyIn: { amount: '0', amountUsd: '0' }, + currencyOut: { amount: '0', amountUsd: '0', minimumAmount: '11900000' }, + }, } as unknown as RelayQuote; const result = await submitFiatQuotes(request); @@ -679,13 +709,18 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); - it('throws if relay re-quote slippage exceeds threshold', async () => { + it('throws if relay rate drift exceeds threshold', async () => { getRelayQuotesMock.mockResolvedValue([ { ...RELAY_QUOTE_RESULT_MOCK, original: { details: { - currencyOut: { amount: '10000000' }, + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '10000000', + amountUsd: '2.00', + minimumAmount: '9800000', + }, }, } as unknown as RelayQuote, }, @@ -693,19 +728,144 @@ describe('submitFiatQuotes', () => { const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( - /Relay re-quote slippage too high/u, + /Relay rate drift too high/u, ); }); - it('throws if relay re-quote returns no quotes', async () => { + it('throws if discovery relay quote returns no quotes', async () => { getRelayQuotesMock.mockResolvedValue([]); const { request } = getRequest(); + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'No relay quotes returned for fiat discovery', + ); + }); + + it('throws if final relay re-quote returns no quotes', async () => { + getRelayQuotesMock + .mockResolvedValueOnce([RELAY_QUOTE_RESULT_MOCK]) + .mockResolvedValueOnce([]); + const { request } = getRequest(); + await expect(submitFiatQuotes(request)).rejects.toThrow( 'No relay quotes returned for completed fiat order', ); }); + it('throws if getAmountData returns no updates for transaction with nested calldata', async () => { + const { callMock, request } = getRequest({ + transaction: { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa', data: '0x1234' }, + { to: '0xbbb', data: '0x5678' }, + ], + } as unknown as TransactionMeta, + }); + + callMock.mockImplementation((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'RampsController:getOrder') { + return getFiatOrderMock(); + } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'getAmountData returned no updates for transaction with nested calldata', + ); + }); + + it('applies getAmountData updates to nested calldata and requiredAssets', async () => { + const nestedTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], + requiredAssets: [{ address: '0xaaa' as Hex, amount: '0x0' as Hex }], + } as unknown as TransactionMeta; + + const { callMock, request } = getRequest({ + transaction: nestedTransaction, + }); + + callMock.mockImplementation((action: string, ...args: unknown[]) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'RampsController:getOrder') { + return getFiatOrderMock(); + } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + await submitFiatQuotes(request); + + const settledAmountCall = updateTransactionMock.mock.calls.find( + ([opts]) => opts.note === 'Fiat deposit: update settled amount', + ); + expect(settledAmountCall).toBeDefined(); + + const txDraft = { + nestedTransactions: [ + { to: '0xaaa', data: '0x1111' }, + { to: '0xbbb', data: '0x2222' }, + ], + requiredAssets: [{ address: '0xaaa', amount: '0x0' }], + } as unknown as TransactionMeta; + + settledAmountCall![1](txDraft); + + expect(txDraft.nestedTransactions![0].data).toBe('0xNewApprove'); + expect(txDraft.nestedTransactions![1].data).toBe('0xNewDeposit'); + expect(txDraft.requiredAssets![0].amount).toBe('0xb59460'); + }); + + it('falls back to original transaction when getTransaction returns undefined', async () => { + getTransactionMock.mockReturnValue(undefined); + const { request } = getRequest(); + + const result = await submitFiatQuotes(request); + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + it('throws if relay submit fails', async () => { submitRelayQuotesMock.mockRejectedValue(new Error('Relay submit failed')); 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 a6eccdde22..2f8df97c26 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -337,7 +337,7 @@ async function submitRelayAfterFiatCompletion({ } } if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}` as Hex; + tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}`; } }, ); @@ -398,6 +398,11 @@ async function submitRelayAfterFiatCompletion({ * different source amounts (quoting phase uses a theoretical amount, discovery * uses the actual settled amount) so the comparison reflects genuine rate * movement rather than amount differences. + * + * @param options - The validation options. + * @param options.originalQuote - Relay quote from the original quoting phase. + * @param options.discoveryQuote - Relay quote from the post-settlement discovery. + * @param options.transactionId - Transaction ID for error reporting. */ function validateRelayRateDrift({ originalQuote, From a511f02b35f466a0eca7d047bbcd603228b3b7bb Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:55:40 +0200 Subject: [PATCH 04/28] Fix lint --- .../src/TransactionPayController.ts | 4 +--- .../src/strategy/fiat/fiat-submit.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 9728deb7df..3a8842f962 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -242,9 +242,7 @@ export class TransactionPayController extends BaseController< getAmountData( ...args: Parameters ): ReturnType { - return ( - this.#getAmountData?.(...args) ?? Promise.resolve({ updates: [] }) - ); + return this.#getAmountData?.(...args) ?? Promise.resolve({ updates: [] }); } getPaymentOverrideData( 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 2f8df97c26..27aa007538 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -414,8 +414,12 @@ function validateRelayRateDrift({ transactionId: string; }): void { const originalIn = new BigNumber(originalQuote.details.currencyIn.amountUsd); - const originalOut = new BigNumber(originalQuote.details.currencyOut.amountUsd); - const discoveryIn = new BigNumber(discoveryQuote.details.currencyIn.amountUsd); + const originalOut = new BigNumber( + originalQuote.details.currencyOut.amountUsd, + ); + const discoveryIn = new BigNumber( + discoveryQuote.details.currencyIn.amountUsd, + ); const discoveryOut = new BigNumber( discoveryQuote.details.currencyOut.amountUsd, ); From 36926755f2e36452d33c5911d187c2441d57cac1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:58:05 +0200 Subject: [PATCH 05/28] docs: update transaction-pay-controller changelog --- packages/transaction-pay-controller/CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 42f8f96d98..e54ca86690 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,15 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `getAmountData` callback to `TransactionPayControllerOptions` for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Add `TransactionPayController:getAmountData` messenger action ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Add `GetAmountDataCallback`, `GetAmountDataRequest`, and `GetAmountDataResponse` exported types ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) - The package was already imported at runtime by `src/strategy/relay/hyperliquid-withdraw.ts` but wasn't declared in `package.json`; this PR fixes the omission. ### Changed +- Fiat submit now uses a three-phase relay flow after on-ramp settlement: discovery quote, calldata update via `getAmountData`, then delegation quote ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Replace `validateRelaySlippage` with `validateRelayRateDrift` which compares USD exchange rates instead of absolute output amounts ([#8987](https://github.com/MetaMask/core/pull/8987)) - Fiat quote submission now treats the provider code (e.g. `transak-native`) as the canonical form when resolving the provider from a ramps quote, while continuing to accept the legacy path form (e.g. `/providers/transak-native`) for backwards compatibility ([#9004](https://github.com/MetaMask/core/pull/9004)) - Live token balance queries now respect the `confirmations_pay_extended.excludeChainIdsFromInfura` feature flag, skipping the Infura endpoint preference for excluded chains ([#8992](https://github.com/MetaMask/core/pull/8992)) - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.5.0` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8999](https://github.com/MetaMask/core/pull/8999)) - Bump `@metamask/assets-controller` from `^8.0.2` to `^8.3.2` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985), [#8999](https://github.com/MetaMask/core/pull/8999)) + +### Removed + +- Remove `validateRelaySlippage` and `MAX_SLIPPAGE_PERCENT` from fiat submit ([#8987](https://github.com/MetaMask/core/pull/8987)) + - The previous check compared relay outputs from quotes made with different source amounts, producing false positives. Relay's own `slippageTolerance` parameter already guards on-chain execution. + +### Fixed + +- Fix fiat `moneyAccountDeposit` failing with `"Max amount quotes do not support included transactions"` by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) - Bump `@metamask/ramps-controller` from `^14.1.0` to `^14.1.1` ([#8989](https://github.com/MetaMask/core/pull/8989)) - Bump `@metamask/bridge-status-controller` from `^72.0.0` to `^72.0.2` ([#8990](https://github.com/MetaMask/core/pull/8990), [#8999](https://github.com/MetaMask/core/pull/8999)) From 45e37c77c95c6321e4a36a35bd964c0cf0e04026 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:10:20 +0200 Subject: [PATCH 06/28] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index e54ca86690..85f699a50a 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -24,11 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.5.0` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8999](https://github.com/MetaMask/core/pull/8999)) - Bump `@metamask/assets-controller` from `^8.0.2` to `^8.3.2` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985), [#8999](https://github.com/MetaMask/core/pull/8999)) -### Removed - -- Remove `validateRelaySlippage` and `MAX_SLIPPAGE_PERCENT` from fiat submit ([#8987](https://github.com/MetaMask/core/pull/8987)) - - The previous check compared relay outputs from quotes made with different source amounts, producing false positives. Relay's own `slippageTolerance` parameter already guards on-chain execution. - ### Fixed - Fix fiat `moneyAccountDeposit` failing with `"Max amount quotes do not support included transactions"` by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) From e1f1f944c2faba57daf11f8b43cf3216cdc30e3c Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:15:07 +0200 Subject: [PATCH 07/28] fix: only reject rate drift when rate worsens, not improves A better post-settlement rate benefits the user and should not block fiat completion. Remove .abs() so only positive drift (rate worsened) is rejected. --- .../src/strategy/fiat/fiat-submit.test.ts | 23 +++++++++++++++++++ .../src/strategy/fiat/fiat-submit.ts | 2 +- 2 files changed, 24 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 b7db9097be..b067a8ce9f 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 @@ -732,6 +732,29 @@ describe('submitFiatQuotes', () => { ); }); + it('allows rate drift when discovery rate is better than original', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_RESULT_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '14000000', + amountUsd: '6.00', + minimumAmount: '13800000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + const { request } = getRequest(); + + const result = await submitFiatQuotes(request); + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + it('throws if discovery relay 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 27aa007538..5294b836dd 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -448,7 +448,7 @@ function validateRelayRateDrift({ transactionId, }); - if (driftPercent.abs().gt(MAX_RATE_DRIFT_PERCENT)) { + if (driftPercent.gt(MAX_RATE_DRIFT_PERCENT)) { throw new Error( `Relay rate drift too high for transaction ` + `${driftPercent.toFixed(2)}% exceeds ${MAX_RATE_DRIFT_PERCENT}% max`, From 44dd21fe77434474dc6579e870b5898a873dfbdf Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:19:02 +0200 Subject: [PATCH 08/28] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 1 - .../TransactionPayController-method-action-types.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 85f699a50a..c381b0d5a5 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `getAmountData` callback to `TransactionPayControllerOptions` for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `TransactionPayController:getAmountData` messenger action ([#8987](https://github.com/MetaMask/core/pull/8987)) -- Add `GetAmountDataCallback`, `GetAmountDataRequest`, and `GetAmountDataResponse` exported types ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) - The package was already imported at runtime by `src/strategy/relay/hyperliquid-withdraw.ts` but wasn't declared in `package.json`; this PR fixes the omission. diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index a46f576624..94bf7c10fc 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -76,6 +76,11 @@ export type TransactionPayControllerGetDelegationTransactionAction = { * @param args - The arguments forwarded to the {@link GetPaymentOverrideDataCallback}. * @returns A promise resolving to the additional transactions array. */ +export type TransactionPayControllerGetAmountDataAction = { + type: `TransactionPayController:getAmountData`; + handler: TransactionPayController['getAmountData']; +}; + export type TransactionPayControllerGetPaymentOverrideDataAction = { type: `TransactionPayController:getPaymentOverrideData`; handler: TransactionPayController['getPaymentOverrideData']; @@ -120,11 +125,6 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { handler: TransactionPayController['polymarketSubmitDepositWalletBatch']; }; -export type TransactionPayControllerGetAmountDataAction = { - type: `TransactionPayController:getAmountData`; - handler: TransactionPayController['getAmountData']; -}; - /** * Union of all TransactionPayController action types. */ @@ -132,8 +132,8 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction - | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetDelegationTransactionAction + | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetPaymentOverrideDataAction | TransactionPayControllerGetStrategyAction | TransactionPayControllerPolymarketGetDepositWalletAddressAction From 73182b99e838d93b6e94cb062f70bad295aa466d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 16:00:48 +0200 Subject: [PATCH 09/28] fix: lint errors in fiat-submit tests - Remove unused args parameter - Replace non-null assertions with optional chaining --- .../src/strategy/fiat/fiat-submit.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 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 b067a8ce9f..2ca5533fe9 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 @@ -829,7 +829,7 @@ describe('submitFiatQuotes', () => { transaction: nestedTransaction, }); - callMock.mockImplementation((action: string, ...args: unknown[]) => { + callMock.mockImplementation((action: string) => { if (action === 'TransactionPayController:getState') { return { transactionData: { @@ -873,11 +873,13 @@ describe('submitFiatQuotes', () => { requiredAssets: [{ address: '0xaaa', amount: '0x0' }], } as unknown as TransactionMeta; - settledAmountCall![1](txDraft); + const updateFn = settledAmountCall?.[1]; + expect(updateFn).toBeDefined(); + (updateFn as (tx: TransactionMeta) => void)(txDraft); - expect(txDraft.nestedTransactions![0].data).toBe('0xNewApprove'); - expect(txDraft.nestedTransactions![1].data).toBe('0xNewDeposit'); - expect(txDraft.requiredAssets![0].amount).toBe('0xb59460'); + expect(txDraft.nestedTransactions?.[0].data).toBe('0xNewApprove'); + expect(txDraft.nestedTransactions?.[1].data).toBe('0xNewDeposit'); + expect(txDraft.requiredAssets?.[0].amount).toBe('0xb59460'); }); it('falls back to original transaction when getTransaction returns undefined', async () => { From 220c6c61698545286709fc07d6c6d43be5a18461 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 19:06:31 +0200 Subject: [PATCH 10/28] fix: skip source balance check for relay execute flow The execute flow uses Relay's relayer to handle the source-side transaction, so the user's EOA does not need to hold the source tokens at submit time. This was causing fiat moneyAccountDeposit to fail with 'Insufficient source token balance' after Transak settlement. --- .../src/strategy/relay/relay-submit.test.ts | 19 +++++++++++++------ .../src/strategy/relay/relay-submit.ts | 15 +++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index c43dfb22e6..af222e84f6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1423,6 +1423,15 @@ describe('Relay Submit Utils', () => { ); }); + it('skips balance check when isExecute is true', async () => { + getLiveTokenBalanceMock.mockResolvedValue('0'); + request.quotes[0].original.metamask.isExecute = true; + + await submitRelayQuotes(request); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + }); + describe('HyperLiquid source', () => { it('calls submitHyperliquidWithdraw instead of submitTransactions', async () => { const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( @@ -1843,14 +1852,12 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).not.toHaveBeenCalled(); }); - it('still validates source balance', async () => { - getLiveTokenBalanceMock.mockResolvedValue('500000'); + it('skips source balance validation for execute flow', async () => { + getLiveTokenBalanceMock.mockResolvedValue('0'); - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Insufficient source token balance for relay deposit', - ); + await submitRelayQuotes(request); - expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); }); it('polls relay status after execute', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index d694584141..2163793fa3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -378,10 +378,17 @@ async function submitTransactions( throw new Error(`Unsupported step kind: ${invalidKind}`); } - // In post-quote flows (e.g. Predict withdraw), the source tokens are held in - // the Safe — not the EOA — and only become available after the original tx - // executes as part of the batch. Skip the EOA balance check here. - if (!quote.request.isPostQuote && !quote.request.paymentOverride) { + // Skip the EOA balance check when the source tokens are not expected to be + // in the user's wallet at submit time: + // - isPostQuote: tokens are in a Safe/proxy, available after batch execution + // - paymentOverride: tokens come from a different source (e.g. money account) + // - isExecute: Relay's relayer handles the source-side transaction + const skipBalanceCheck = + quote.request.isPostQuote || + quote.request.paymentOverride || + quote.original.metamask.isExecute; + + if (!skipBalanceCheck) { await validateSourceBalance(quote, messenger); } From cab349c443a4a68dc7f53efc9784f24ccb15ca7b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 20:47:03 +0200 Subject: [PATCH 11/28] fix: use accountOverride for wallet address in fiat flow The fiat quoting and submission flows used transaction.txParams.from as the wallet address. For moneyAccountDeposit, txParams.from is the money account address on the target chain, not the user's EOA. This caused: - Ramps/Transak to receive the wrong deposit address - resolveSourceAmountRaw to look for on-chain ETH at the wrong address - Relay quotes to use the wrong from/user address - Balance validation to check the wrong account Use accountOverride (the user's selected EVM account) when available, matching the pattern already used in quotes.ts. Also revert the isExecute balance skip (no longer needed with correct address) and remove hasFiatStrategy from totals calculation. --- .../src/strategy/fiat/fiat-quotes.ts | 3 ++- .../src/strategy/fiat/fiat-submit.ts | 13 +++++++------ .../src/strategy/relay/relay-submit.ts | 15 ++++----------- .../src/utils/totals.ts | 16 ++-------------- 4 files changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 75a9163493..2e17be1841 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -49,7 +49,8 @@ export async function getFiatQuotes( const state = messenger.call('TransactionPayController:getState'); const transactionData = state.transactionData[transactionId]; const amountFiat = transactionData?.fiatPayment?.amountFiat; - const walletAddress = transaction.txParams.from as Hex; + const walletAddress = + transactionData?.accountOverride ?? (transaction.txParams.from as Hex); const requiredTokens = getRequiredTokens(transactionData?.tokens); const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger); 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 5294b836dd..8e9b26f456 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -51,14 +51,16 @@ export async function submitFiatQuotes( ): ReturnType['execute']> { const { messenger, transaction } = request; const transactionId = transaction.id; - const walletAddress = transaction.txParams.from as Hex | undefined; + const state = messenger.call('TransactionPayController:getState'); + const transactionData = state.transactionData[transactionId]; + const walletAddress = (transactionData?.accountOverride ?? + transaction.txParams.from) as Hex | undefined; if (!walletAddress) { throw new Error('Missing wallet address for fiat submission'); } - const state = messenger.call('TransactionPayController:getState'); - const fiatPayment = state.transactionData[transactionId]?.fiatPayment; + const fiatPayment = transactionData?.fiatPayment; const orderId = fiatPayment?.orderId; if (!orderId) { @@ -272,7 +274,8 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); - const walletAddress = transaction.txParams.from as Hex; + const baseRequest = quotes[0].request; + const walletAddress = baseRequest.from; const sourceAmountRaw = await resolveSourceAmountRaw({ messenger, @@ -281,8 +284,6 @@ async function submitRelayAfterFiatCompletion({ walletAddress, }); - const baseRequest = quotes[0].request; - // Phase 1: Discovery quote with EXACT_INPUT to find the actual target // token output for the settled source amount. const discoveryRequest: QuoteRequest = { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 2163793fa3..d694584141 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -378,17 +378,10 @@ async function submitTransactions( throw new Error(`Unsupported step kind: ${invalidKind}`); } - // Skip the EOA balance check when the source tokens are not expected to be - // in the user's wallet at submit time: - // - isPostQuote: tokens are in a Safe/proxy, available after batch execution - // - paymentOverride: tokens come from a different source (e.g. money account) - // - isExecute: Relay's relayer handles the source-side transaction - const skipBalanceCheck = - quote.request.isPostQuote || - quote.request.paymentOverride || - quote.original.metamask.isExecute; - - if (!skipBalanceCheck) { + // In post-quote flows (e.g. Predict withdraw), the source tokens are held in + // the Safe — not the EOA — and only become available after the original tx + // executes as part of the batch. Skip the EOA balance check here. + if (!quote.request.isPostQuote && !quote.request.paymentOverride) { await validateSourceBalance(quote, messenger); } diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 7b8bccf020..fab3cc6bd8 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -1,7 +1,6 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; -import { TransactionPayStrategy } from '../constants'; import type { FiatValue, TransactionPayControllerMessenger, @@ -41,9 +40,6 @@ export function calculateTotals({ const providerFiatFee = sumFiat( quotes.map((quote) => quote.fees.providerFiat ?? { fiat: '0', usd: '0' }), ); - const hasFiatStrategy = quotes.some( - (quote) => quote.strategy === TransactionPayStrategy.Fiat, - ); const sourceNetworkFeeMax = sumAmounts( quotes.map((quote) => quote.fees.sourceNetwork.max), @@ -80,22 +76,14 @@ export function calculateTotals({ .plus(metaMaskFee.fiat) .plus(sourceNetworkFeeEstimate.fiat) .plus(targetNetworkFee.fiat) - .plus( - (hasFiatStrategy || isMaxAmount) && hasQuotes - ? targetAmount.fiat - : amountFiat, - ) + .plus(isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat) .toString(10); const totalUsd = new BigNumber(providerFee.usd) .plus(metaMaskFee.usd) .plus(sourceNetworkFeeEstimate.usd) .plus(targetNetworkFee.usd) - .plus( - (hasFiatStrategy || isMaxAmount) && hasQuotes - ? targetAmount.usd - : amountUsd, - ) + .plus(isMaxAmount && hasQuotes ? targetAmount.usd : amountUsd) .toString(10); const estimatedDuration = Number( From 3ab8779bebd999e692c1bc5b50178d5fdea86550 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 22:53:11 +0200 Subject: [PATCH 12/28] fix: revert isExecute balance skip test changes Reverts the test changes from the isExecute balance skip commit since the source code was also reverted. Restores the original test that validates source balance for execute flows. --- .../src/strategy/relay/relay-submit.test.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index af222e84f6..c43dfb22e6 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1423,15 +1423,6 @@ describe('Relay Submit Utils', () => { ); }); - it('skips balance check when isExecute is true', async () => { - getLiveTokenBalanceMock.mockResolvedValue('0'); - request.quotes[0].original.metamask.isExecute = true; - - await submitRelayQuotes(request); - - expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); - }); - describe('HyperLiquid source', () => { it('calls submitHyperliquidWithdraw instead of submitTransactions', async () => { const { submitHyperliquidWithdraw: hlWithdrawMock } = jest.requireMock( @@ -1852,12 +1843,14 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).not.toHaveBeenCalled(); }); - it('skips source balance validation for execute flow', async () => { - getLiveTokenBalanceMock.mockResolvedValue('0'); + it('still validates source balance', async () => { + getLiveTokenBalanceMock.mockResolvedValue('500000'); - await submitRelayQuotes(request); + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Insufficient source token balance for relay deposit', + ); - expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); }); it('polls relay status after execute', async () => { From 16fe6bed4b913d023d934e3b9e24ae89d84d182d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 23:02:04 +0200 Subject: [PATCH 13/28] fix: remove hasFiatStrategy totals test The test validated the removed hasFiatStrategy path in calculateTotals. With fiat strategy now using amountFiat consistently, this test case is no longer applicable. --- .../src/utils/totals.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 7726b19c6b..d35efe9c0f 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -179,23 +179,6 @@ describe('Totals Utils', () => { expect(result.total.usd).toBe('71.68'); }); - it('returns adjusted total using targetAmount when fiat strategy quote is present', () => { - const fiatQuote: TransactionPayQuote = { - ...QUOTE_1_MOCK, - strategy: TransactionPayStrategy.Fiat, - }; - - const result = calculateTotals({ - quotes: [fiatQuote, QUOTE_2_MOCK], - tokens: [TOKEN_1_MOCK, TOKEN_2_MOCK], - messenger: MESSENGER_MOCK, - transaction: TRANSACTION_META_MOCK, - }); - - expect(result.total.fiat).toBe('65.5'); - expect(result.total.usd).toBe('71.68'); - }); - it('returns total excluding token amount not in quote', () => { const result = calculateTotals({ quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], From 5ea4519c0142b18d962950e58d862e148f84e125 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 23:24:46 +0200 Subject: [PATCH 14/28] fix: pass fiatPaymentAmount to totals for correct fiat total calculation Pass fiatPayment.amountFiat from quote context into calculateTotals so the fiat flow uses the user-entered fiat amount for the total instead of deriving it from token amounts or targetAmount. --- .../transaction-pay-controller/src/utils/quotes.ts | 4 +++- .../transaction-pay-controller/src/utils/totals.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index e332b63285..c339324ce5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -91,6 +91,7 @@ export async function updateQuotes( refundTo, sourceAmounts, tokens, + fiatPayment, } = transactionData; const from = accountOverride ?? (transaction.txParams.from as Hex); @@ -139,7 +140,7 @@ export async function updateQuotes( supports7702, getStrategies, messenger, - transactionData.fiatPayment?.selectedPaymentMethodId, + fiatPayment?.selectedPaymentMethodId, signal, ); @@ -149,6 +150,7 @@ export async function updateQuotes( } const totals = calculateTotals({ + fiatPaymentAmount: fiatPayment?.amountFiat, isMaxAmount, messenger, quotes: quotes as TransactionPayQuote[], diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index fab3cc6bd8..d5ca557563 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -15,6 +15,7 @@ import { calculateTransactionGasCost } from './gas'; * Calculate totals for a list of quotes and tokens. * * @param request - Request parameters. + * @param request.fiatPaymentAmount - The amount of the transaction in fiat. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.quotes - List of bridge quotes. * @param request.messenger - Controller messenger. @@ -23,12 +24,14 @@ import { calculateTransactionGasCost } from './gas'; * @returns The calculated totals in USD and fiat currency. */ export function calculateTotals({ + fiatPaymentAmount, isMaxAmount, quotes, messenger, tokens, transaction, }: { + fiatPaymentAmount?: string; isMaxAmount?: boolean; quotes: TransactionPayQuote[]; messenger: TransactionPayControllerMessenger; @@ -76,14 +79,21 @@ export function calculateTotals({ .plus(metaMaskFee.fiat) .plus(sourceNetworkFeeEstimate.fiat) .plus(targetNetworkFee.fiat) - .plus(isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat) + .plus( + fiatPaymentAmount ?? + (isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat), + ) .toString(10); const totalUsd = new BigNumber(providerFee.usd) .plus(metaMaskFee.usd) .plus(sourceNetworkFeeEstimate.usd) .plus(targetNetworkFee.usd) - .plus(isMaxAmount && hasQuotes ? targetAmount.usd : amountUsd) + .plus( + (fiatPaymentAmount ?? (isMaxAmount && hasQuotes)) + ? targetAmount.usd + : amountUsd, + ) .toString(10); const estimatedDuration = Number( From 660cac33905fc9e9ce592eb9e564fc3a0044b968 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 00:02:29 +0200 Subject: [PATCH 15/28] Fix the total derivation --- .../src/utils/totals.ts | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index d5ca557563..e2d135a3b0 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -1,6 +1,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; +import { TransactionPayStrategy } from '../constants'; import type { FiatValue, TransactionPayControllerMessenger, @@ -43,6 +44,9 @@ export function calculateTotals({ const providerFiatFee = sumFiat( quotes.map((quote) => quote.fees.providerFiat ?? { fiat: '0', usd: '0' }), ); + const hasFiatStrategy = quotes.some( + (quote) => quote.strategy === TransactionPayStrategy.Fiat, + ); const sourceNetworkFeeMax = sumAmounts( quotes.map((quote) => quote.fees.sourceNetwork.max), @@ -75,25 +79,36 @@ export function calculateTotals({ const amountUsd = sumProperty(quoteTokens, (token) => token.amountUsd); const hasQuotes = quotes.length > 0; + const paymentAmountFiat = getPaymentAmount({ + hasFiatStrategy, + fiatPaymentAmount, + isMaxAmount, + hasQuotes, + targetAmount: targetAmount.fiat, + tokenAmount: amountFiat, + }); + + const paymentAmountUsd = getPaymentAmount({ + hasFiatStrategy, + fiatPaymentAmount, + isMaxAmount, + hasQuotes, + targetAmount: targetAmount.usd, + tokenAmount: amountUsd, + }); + const totalFiat = new BigNumber(providerFee.fiat) .plus(metaMaskFee.fiat) .plus(sourceNetworkFeeEstimate.fiat) .plus(targetNetworkFee.fiat) - .plus( - fiatPaymentAmount ?? - (isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat), - ) + .plus(paymentAmountFiat) .toString(10); const totalUsd = new BigNumber(providerFee.usd) .plus(metaMaskFee.usd) .plus(sourceNetworkFeeEstimate.usd) .plus(targetNetworkFee.usd) - .plus( - (fiatPaymentAmount ?? (isMaxAmount && hasQuotes)) - ? targetAmount.usd - : amountUsd, - ) + .plus(paymentAmountUsd) .toString(10); const estimatedDuration = Number( @@ -131,6 +146,44 @@ export function calculateTotals({ }; } +/** + * Get the payment amount to include in totals. + * + * @param request - Request parameters. + * @param request.hasFiatStrategy - Whether a fiat strategy quote is present. + * @param request.fiatPaymentAmount - The fiat payment amount, if applicable. + * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. + * @param request.hasQuotes - Whether any quotes are present. + * @param request.targetAmount - The target amount from quotes. + * @param request.tokenAmount - The summed token amount. + * @returns The payment amount to include in totals. + */ +function getPaymentAmount({ + hasFiatStrategy, + fiatPaymentAmount, + isMaxAmount, + hasQuotes, + targetAmount, + tokenAmount, +}: { + hasFiatStrategy: boolean; + fiatPaymentAmount?: string; + isMaxAmount?: boolean; + hasQuotes: boolean; + targetAmount: string; + tokenAmount: string; +}): string { + if (hasFiatStrategy) { + return fiatPaymentAmount ?? '0'; + } + + if (isMaxAmount && hasQuotes) { + return targetAmount; + } + + return tokenAmount; +} + /** * Sum a list of fiat value. * From 44f7ca375ae107d0014e3483650de5803dad9092 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 00:06:47 +0200 Subject: [PATCH 16/28] test: add coverage for fiat strategy payment amount in totals --- .../src/utils/totals.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index d35efe9c0f..9fa93632b2 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -179,6 +179,41 @@ describe('Totals Utils', () => { expect(result.total.usd).toBe('71.68'); }); + it('returns total using fiatPaymentAmount when fiat strategy is present', () => { + const fiatQuote: TransactionPayQuote = { + ...QUOTE_1_MOCK, + strategy: TransactionPayStrategy.Fiat, + }; + + const result = calculateTotals({ + fiatPaymentAmount: '20.00', + quotes: [fiatQuote, QUOTE_2_MOCK], + tokens: [TOKEN_1_MOCK, TOKEN_2_MOCK], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.total.fiat).toBe('60.36'); + expect(result.total.usd).toBe('65.42'); + }); + + it('returns total with zero payment when fiat strategy is present but fiatPaymentAmount is undefined', () => { + const fiatQuote: TransactionPayQuote = { + ...QUOTE_1_MOCK, + strategy: TransactionPayStrategy.Fiat, + }; + + const result = calculateTotals({ + quotes: [fiatQuote, QUOTE_2_MOCK], + tokens: [TOKEN_1_MOCK, TOKEN_2_MOCK], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.total.fiat).toBe('40.36'); + expect(result.total.usd).toBe('45.42'); + }); + it('returns total excluding token amount not in quote', () => { const result = calculateTotals({ quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], From 0671fa6bc614e0441d111fe64c22e32f41d5fbbb Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 13:26:00 +0200 Subject: [PATCH 17/28] Address PR review feedback from matthewwalsh0 - Consolidate duplicate changelog entries for #8987 - Alphabetize destructured properties in updateQuotes - Rename getPaymentAmount to getSourceAmount for clarity - Simplify walletAddress in fiat-quotes to use transaction.txParams.from --- packages/transaction-pay-controller/CHANGELOG.md | 3 +-- .../src/strategy/fiat/fiat-quotes.ts | 3 +-- .../transaction-pay-controller/src/utils/quotes.ts | 2 +- .../transaction-pay-controller/src/utils/totals.ts | 12 ++++++------ 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c381b0d5a5..c3367c5d0b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `getAmountData` callback to `TransactionPayControllerOptions` for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) -- Add `TransactionPayController:getAmountData` messenger action ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Add optional `getAmountData` callback and `TransactionPayController:getAmountData` messenger action for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) - The package was already imported at runtime by `src/strategy/relay/hyperliquid-withdraw.ts` but wasn't declared in `package.json`; this PR fixes the omission. diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 2e17be1841..75a9163493 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -49,8 +49,7 @@ export async function getFiatQuotes( const state = messenger.call('TransactionPayController:getState'); const transactionData = state.transactionData[transactionId]; const amountFiat = transactionData?.fiatPayment?.amountFiat; - const walletAddress = - transactionData?.accountOverride ?? (transaction.txParams.from as Hex); + const walletAddress = transaction.txParams.from as Hex; const requiredTokens = getRequiredTokens(transactionData?.tokens); const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index c339324ce5..603892e619 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -88,10 +88,10 @@ export async function updateQuotes( isPolymarketDepositWallet, paymentOverride, paymentToken: originalPaymentToken, + fiatPayment, refundTo, sourceAmounts, tokens, - fiatPayment, } = transactionData; const from = accountOverride ?? (transaction.txParams.from as Hex); diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index e2d135a3b0..6297819179 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -79,7 +79,7 @@ export function calculateTotals({ const amountUsd = sumProperty(quoteTokens, (token) => token.amountUsd); const hasQuotes = quotes.length > 0; - const paymentAmountFiat = getPaymentAmount({ + const sourceAmountFiat = getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, isMaxAmount, @@ -88,7 +88,7 @@ export function calculateTotals({ tokenAmount: amountFiat, }); - const paymentAmountUsd = getPaymentAmount({ + const sourceAmountUsd = getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, isMaxAmount, @@ -101,14 +101,14 @@ export function calculateTotals({ .plus(metaMaskFee.fiat) .plus(sourceNetworkFeeEstimate.fiat) .plus(targetNetworkFee.fiat) - .plus(paymentAmountFiat) + .plus(sourceAmountFiat) .toString(10); const totalUsd = new BigNumber(providerFee.usd) .plus(metaMaskFee.usd) .plus(sourceNetworkFeeEstimate.usd) .plus(targetNetworkFee.usd) - .plus(paymentAmountUsd) + .plus(sourceAmountUsd) .toString(10); const estimatedDuration = Number( @@ -147,7 +147,7 @@ export function calculateTotals({ } /** - * Get the payment amount to include in totals. + * Get the source amount to include in totals. * * @param request - Request parameters. * @param request.hasFiatStrategy - Whether a fiat strategy quote is present. @@ -158,7 +158,7 @@ export function calculateTotals({ * @param request.tokenAmount - The summed token amount. * @returns The payment amount to include in totals. */ -function getPaymentAmount({ +function getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, isMaxAmount, From d9d35fdbd6ea4aca583a27179110ec478db10157 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 16:05:49 +0200 Subject: [PATCH 18/28] feat: skip discovery quote for simple fiat deposits (Perps, Predict) Transactions without nested calldata (e.g. Perps, Predict deposits) now use a single EXACT_INPUT relay quote after fiat settlement instead of the three-phase discovery + re-encoding + delegation flow. This reduces relay calls from 3 to 1, uses cheaper EXACT_INPUT fees, and avoids leftover dust on the source chain. The three-phase flow is preserved for moneyAccountDeposit and other transactions with nested calldata that requires re-encoding. --- .../src/strategy/fiat/fiat-submit.test.ts | 200 +++++++++++++++++- .../src/strategy/fiat/fiat-submit.ts | 165 ++++++++++++--- 2 files changed, 326 insertions(+), 39 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 2ca5533fe9..cd03f30dce 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 @@ -273,7 +273,7 @@ describe('submitFiatQuotes', () => { }); }); - it('polls completed fiat order then requotes and submits relay', async () => { + it('polls completed fiat order then submits single EXACT_INPUT relay for simple deposits', async () => { const order = getFiatOrderMock({ cryptoAmount: '1.2345', cryptoCurrency: { @@ -300,6 +300,69 @@ describe('submitFiatQuotes', () => { fiatAsset: FIAT_ASSET_MOCK, walletAddress: WALLET_ADDRESS_MOCK, }); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: '1234500000000000000', + sourceTokenAmount: '1234500000000000000', + }), + ]); + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [RELAY_QUOTE_RESULT_MOCK], + }), + ); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('uses three-phase flow with discovery and delegation for nested calldata transactions', async () => { + const nestedTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], + } as unknown as TransactionMeta; + + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + + const { callMock, request } = getRequest({ + transaction: nestedTransaction, + }); + + callMock.mockImplementation((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'RampsController:getOrder') { + return getFiatOrderMock(); + } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitFiatQuotes(request); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(2); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ @@ -318,10 +381,9 @@ describe('submitFiatQuotes', () => { targetAmountMinimum: '11900000', }), ]); - expect(submitRelayQuotesMock).toHaveBeenCalledWith( - expect.objectContaining({ - quotes: [RELAY_QUOTE_RESULT_MOCK], - }), + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), ); expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); @@ -755,10 +817,27 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); - it('throws if discovery relay quote returns no quotes', async () => { + it('throws if simple relay 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 discovery relay quote returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const { request } = getRequest({ + transaction: { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa', data: '0x1111' }, + { to: '0xbbb', data: '0x2222' }, + ], + } as unknown as TransactionMeta, + }); + await expect(submitFiatQuotes(request)).rejects.toThrow( 'No relay quotes returned for fiat discovery', ); @@ -768,13 +847,64 @@ describe('submitFiatQuotes', () => { getRelayQuotesMock .mockResolvedValueOnce([RELAY_QUOTE_RESULT_MOCK]) .mockResolvedValueOnce([]); - const { request } = getRequest(); + + const nestedTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], + } as unknown as TransactionMeta; + + const { callMock, request } = getRequest({ + transaction: nestedTransaction, + }); + + callMock.mockImplementation((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'RampsController:getOrder') { + return getFiatOrderMock(); + } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + }); + } + throw new Error(`Unexpected action: ${action}`); + }); await expect(submitFiatQuotes(request)).rejects.toThrow( 'No relay quotes returned for completed fiat order', ); }); + it('does not call getAmountData for simple deposits without nested calldata', async () => { + const { callMock, request } = getRequest(); + + await submitFiatQuotes(request); + + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.anything(), + ); + }); + it('throws if getAmountData returns no updates for transaction with nested calldata', async () => { const { callMock, request } = getRequest({ transaction: { @@ -882,7 +1012,7 @@ describe('submitFiatQuotes', () => { expect(txDraft.requiredAssets?.[0].amount).toBe('0xb59460'); }); - it('falls back to original transaction when getTransaction returns undefined', async () => { + it('falls back to original transaction when getTransaction returns undefined on simple path', async () => { getTransactionMock.mockReturnValue(undefined); const { request } = getRequest(); @@ -891,6 +1021,60 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); + it('falls back to original transaction when getTransaction returns undefined on three-phase path', async () => { + getTransactionMock.mockReturnValue(undefined); + + const nestedTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], + } as unknown as TransactionMeta; + + const { callMock, request } = getRequest({ + transaction: nestedTransaction, + }); + + callMock.mockImplementation((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'RampsController:getOrder') { + return getFiatOrderMock(); + } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitFiatQuotes(request); + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: nestedTransaction, + }), + ); + }); + it('throws if relay submit fails', async () => { submitRelayQuotesMock.mockRejectedValue(new Error('Relay submit failed')); 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 8e9b26f456..05b0517f83 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -284,8 +284,125 @@ async function submitRelayAfterFiatCompletion({ walletAddress, }); - // Phase 1: Discovery quote with EXACT_INPUT to find the actual target - // token output for the settled source amount. + const hasNestedCalldata = (transaction.nestedTransactions?.length ?? 0) >= 2; + + // Transactions with nested calldata (e.g. moneyAccountDeposit with + // approve + deposit) need a three-phase flow: discovery quote to learn + // the target amount, calldata re-encoding, then a delegation quote. + // Simple deposits (Perps, Predict) skip straight to a single EXACT_INPUT + // relay quote — cheaper fees, no leftover dust, one fewer request. + if (hasNestedCalldata) { + return await submitWithCalldataReEncoding({ + baseRequest, + request, + sourceAmountRaw, + transaction, + }); + } + + return await submitSimpleRelay({ + baseRequest, + request, + sourceAmountRaw, + transaction, + }); +} + +/** + * Submits a single EXACT_INPUT relay quote for simple deposits + * that don't require nested calldata re-encoding or delegation. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +async function submitSimpleRelay({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + + const originalRelayQuote = request.quotes[0].original.relayQuote; + + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + }; + + const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [relayRequest], + transaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: relayQuotes[0].original, + transactionId, + }); + + log('Submitting simple relay after fiat settlement', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + return await submitRelayQuotes({ + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }); +} + +/** + * Submits relay quotes using the three-phase flow for transactions with nested + * calldata that needs re-encoding (e.g. moneyAccountDeposit with approve + deposit). + * + * Phase 1: Discovery quote (EXACT_INPUT) to learn the target token output. + * Phase 2: Delegate calldata re-encoding to the client via getAmountData. + * Phase 3: Delegation quote (EXACT_OUTPUT) with updated nested transaction data. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +async function submitWithCalldataReEncoding({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + const discoveryRequest: QuoteRequest = { ...baseRequest, isMaxAmount: false, @@ -308,46 +425,41 @@ async function submitRelayAfterFiatCompletion({ const discoveryRelay = discoveryQuotes[0].original; const settledTargetRaw = discoveryRelay.details.currencyOut.minimumAmount; - const originalRelayQuote = quotes[0].original.relayQuote; + const originalRelayQuote = request.quotes[0].original.relayQuote; validateRelayRateDrift({ originalQuote: originalRelayQuote, discoveryQuote: discoveryRelay, transactionId, }); - // Phase 2: Delegate calldata re-encoding to the client via getAmountData. const { updates } = await messenger.call( 'TransactionPayController:getAmountData', { amount: settledTargetRaw, transaction }, ); - const hasNestedCalldata = (transaction.nestedTransactions?.length ?? 0) >= 2; - if (hasNestedCalldata && !updates.length) { + if (!updates.length) { throw new Error( 'getAmountData returned no updates for transaction with nested calldata', ); } - if (updates.length) { - updateTransaction( - { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, - (tx) => { - for (const { nestedTransactionIndex, data } of updates) { - if (tx.nestedTransactions?.[nestedTransactionIndex]) { - tx.nestedTransactions[nestedTransactionIndex].data = data; - } - } - if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}`; + updateTransaction( + { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; } - }, - ); - } + } + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}`; + } + }, + ); const updatedTransaction = getTransaction(transactionId, messenger) ?? transaction; - // Phase 3: Real relay quote with delegation (standard crypto-like flow). const relayRequest: QuoteRequest = { ...baseRequest, isMaxAmount: false, @@ -373,22 +485,13 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); - const relaySubmitRequest: PayStrategyExecuteRequest = { + return await submitRelayQuotes({ accountSupports7702: request.accountSupports7702, isSmartTransaction: request.isSmartTransaction, messenger, quotes: relayQuotes, transaction: updatedTransaction, - }; - - const relayResult = await submitRelayQuotes(relaySubmitRequest); - - log('Relay submission completed after fiat order', { - relayResult, - transactionId, }); - - return relayResult; } /** From a65eb72cb0a16107cde30d5459dc46b2f07cc2a5 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 23:15:56 +0200 Subject: [PATCH 19/28] feat: fee-as-buffer strategy, simple relay path, and file split for fiat submit - Add fee-as-buffer strategy to prevent EXACT_OUTPUT cost overruns after fiat settlement by reserving the original relay fee from the discovery source amount and adding the discovery fee back to the final target. - Simple deposits (Perps, Predict) skip the three-phase discovery flow and use a single EXACT_INPUT relay quote for cheaper fees and no dust. - Split fiat-submit.ts into focused modules: - fiat-submit.ts: orchestration (polling, validation, routing) - fiat-submit-simple.ts: single EXACT_INPUT relay path - fiat-submit-with-calldata.ts: three-phase flow with fee buffer - utils.ts: shared validateRelayRateDrift, extractProviderCode - Add configurable feeReserveMultiplier and maxRateDriftPercent via confirmations_pay_fiat feature flag with safe defaults (1 and 10%). --- .../transaction-pay-controller/CHANGELOG.md | 6 +- .../strategy/fiat/fiat-submit-simple.test.ts | 255 +++++++++ .../src/strategy/fiat/fiat-submit-simple.ts | 79 +++ .../fiat/fiat-submit-with-calldata.test.ts | 495 ++++++++++++++++++ .../fiat/fiat-submit-with-calldata.ts | 253 +++++++++ .../src/strategy/fiat/fiat-submit.test.ts | 359 +------------ .../src/strategy/fiat/fiat-submit.ts | 291 +--------- .../src/strategy/fiat/utils.ts | 96 ++++ .../src/utils/feature-flags.test.ts | 90 ++++ .../src/utils/feature-flags.ts | 55 ++ 10 files changed, 1358 insertions(+), 621 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c3367c5d0b..768504c30e 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,14 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `getAmountData` callback and `TransactionPayController:getAmountData` messenger action for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) - The package was already imported at runtime by `src/strategy/relay/hyperliquid-withdraw.ts` but wasn't declared in `package.json`; this PR fixes the omission. ### Changed -- Fiat submit now uses a three-phase relay flow after on-ramp settlement: discovery quote, calldata update via `getAmountData`, then delegation quote ([#8987](https://github.com/MetaMask/core/pull/8987)) -- Replace `validateRelaySlippage` with `validateRelayRateDrift` which compares USD exchange rates instead of absolute output amounts ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Fiat submit now uses a three-phase relay flow with fee-as-buffer strategy after on-ramp settlement, and simple deposits (Perps, Predict) skip to a single EXACT_INPUT relay quote for cheaper fees ([#8987](https://github.com/MetaMask/core/pull/8987)) - Fiat quote submission now treats the provider code (e.g. `transak-native`) as the canonical form when resolving the provider from a ramps quote, while continuing to accept the legacy path form (e.g. `/providers/transak-native`) for backwards compatibility ([#9004](https://github.com/MetaMask/core/pull/9004)) - Live token balance queries now respect the `confirmations_pay_extended.excludeChainIdsFromInfura` feature flag, skipping the Infura endpoint preference for excluded chains ([#8992](https://github.com/MetaMask/core/pull/8992)) - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.5.0` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8999](https://github.com/MetaMask/core/pull/8999)) @@ -24,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix fiat `moneyAccountDeposit` failing with `"Max amount quotes do not support included transactions"` by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Fix fiat `moneyAccountDeposit` failing after on-ramp settlement by adding `getAmountData` callback for calldata re-encoding and correcting wallet address, quote amount, and slippage validation ([#8987](https://github.com/MetaMask/core/pull/8987)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) - Bump `@metamask/ramps-controller` from `^14.1.0` to `^14.1.1` ([#8989](https://github.com/MetaMask/core/pull/8989)) - Bump `@metamask/bridge-status-controller` from `^72.0.0` to `^72.0.2` ([#8990](https://github.com/MetaMask/core/pull/8990), [#8999](https://github.com/MetaMask/core/pull/8999)) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts new file mode 100644 index 0000000000..2f235ed05c --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts @@ -0,0 +1,255 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { getFiatMaxRateDriftPercent } from '../../utils/feature-flags'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import { submitSimpleRelay } from './fiat-submit-simple'; +import type { FiatQuote } from './types'; + +jest.mock('../../utils/feature-flags'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: WALLET_ADDRESS_MOCK }, + type: TransactionType.predictDeposit, +} as TransactionMeta; + +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_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: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, + }, + } 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 buildFiatQuote(): TransactionPayQuote { + return { + ...RELAY_QUOTE_MOCK, + original: { + rampsQuote: {} as never, + relayQuote: RELAY_QUOTE_MOCK.original as unknown as RelayQuote, + }, + strategy: TransactionPayStrategy.Fiat, + } as unknown as TransactionPayQuote; +} + +function buildRequest( + overrides?: Partial>, +): PayStrategyExecuteRequest { + return { + accountSupports7702: false, + isSmartTransaction: () => false, + messenger: { call: jest.fn() } as never, + quotes: [buildFiatQuote()], + transaction: TRANSACTION_MOCK, + ...overrides, + }; +} + +describe('submitSimpleRelay', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + const getFiatMaxRateDriftPercentMock = jest.mocked( + getFiatMaxRateDriftPercent, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); + getFiatMaxRateDriftPercentMock.mockReturnValue(10); + }); + + it('builds an EXACT_INPUT relay request with the full settled amount', async () => { + const req = buildRequest(); + + await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '5000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: '5000000000000000000', + sourceTokenAmount: '5000000000000000000', + }), + ]); + }); + + it('submits relay quotes after rate drift validation', async () => { + const req = buildRequest(); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [RELAY_QUOTE_MOCK], + transaction: TRANSACTION_MOCK, + }), + ); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('throws when relay returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const req = buildRequest(); + + await expect( + submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for completed fiat order'); + }); + + it('throws when rate drift exceeds configured threshold', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(5); + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '10000000', + amountUsd: '2.00', + minimumAmount: '9800000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const req = buildRequest(); + + await expect( + submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow(/Relay rate drift too high/u); + }); + + it('passes rate drift when discovery rate is better than original', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '14000000', + amountUsd: '6.00', + minimumAmount: '13800000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const req = buildRequest(); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('skips rate drift check when original relay amounts are zero', async () => { + const fiatQuote = buildFiatQuote(); + fiatQuote.original.relayQuote = { + details: { + currencyIn: { amount: '0', amountUsd: '0' }, + currencyOut: { amount: '0', amountUsd: '0', minimumAmount: '0' }, + }, + } as unknown as RelayQuote; + + const req = buildRequest({ quotes: [fiatQuote] }); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('reads maxRateDriftPercent from feature flags', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(25); + const req = buildRequest(); + + await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getFiatMaxRateDriftPercentMock).toHaveBeenCalledWith(req.messenger); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts new file mode 100644 index 0000000000..df45610abf --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts @@ -0,0 +1,79 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { PayStrategyExecuteRequest, QuoteRequest } from '../../types'; +import { getFiatMaxRateDriftPercent } from '../../utils/feature-flags'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { FiatQuote } from './types'; +import { validateRelayRateDrift } from './utils'; + +const log = createModuleLogger(projectLogger, 'fiat-submit-simple'); + +/** + * Submits a single EXACT_INPUT relay quote for simple deposits + * that don't require nested calldata re-encoding or delegation. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +export async function submitSimpleRelay({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + + const originalRelayQuote = request.quotes[0].original.relayQuote; + + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + }; + + const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [relayRequest], + transaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: relayQuotes[0].original, + maxRateDriftPercent: getFiatMaxRateDriftPercent(messenger), + transactionId, + }); + + log('Submitting simple relay after fiat settlement', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + return await submitRelayQuotes({ + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }); +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts new file mode 100644 index 0000000000..f90eabccc5 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts @@ -0,0 +1,495 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, +} from '../../utils/feature-flags'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; +import type { FiatQuote } from './types'; + +jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/transaction'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: WALLET_ADDRESS_MOCK }, + type: TransactionType.batch, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], +} as unknown as TransactionMeta; + +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_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: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, + }, + } 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 buildFiatQuote( + relayQuoteOverride?: Partial, +): TransactionPayQuote { + const relayQuote = { + ...(RELAY_QUOTE_MOCK.original as unknown as RelayQuote), + ...relayQuoteOverride, + } as RelayQuote; + return { + ...RELAY_QUOTE_MOCK, + original: { rampsQuote: {} as never, relayQuote }, + strategy: TransactionPayStrategy.Fiat, + } as unknown as TransactionPayQuote; +} + +function buildCallMock({ + getAmountDataUpdates = [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + featureFlags = {}, +}: { + getAmountDataUpdates?: { nestedTransactionIndex: number; data: string }[]; + featureFlags?: Record; +} = {}): jest.Mock { + return jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: getAmountDataUpdates }); + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: featureFlags }; + } + throw new Error(`Unexpected action: ${action}`); + }); +} + +function buildRequest({ + callMock = buildCallMock(), + quotes = [buildFiatQuote()], + transaction = TRANSACTION_MOCK, +}: { + callMock?: jest.Mock; + quotes?: TransactionPayQuote[]; + transaction?: TransactionMeta; +} = {}): PayStrategyExecuteRequest { + return { + accountSupports7702: false, + isSmartTransaction: () => false, + messenger: { call: callMock } as never, + quotes, + transaction, + }; +} + +describe('submitWithCalldataReEncoding', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + const getTransactionMock = jest.mocked(getTransaction); + const updateTransactionMock = jest.mocked(updateTransaction); + const getFiatFeeReserveMultiplierMock = jest.mocked( + getFiatFeeReserveMultiplier, + ); + const getFiatMaxRateDriftPercentMock = jest.mocked( + getFiatMaxRateDriftPercent, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); + getTransactionMock.mockReturnValue(TRANSACTION_MOCK); + getFiatFeeReserveMultiplierMock.mockReturnValue(1); + getFiatMaxRateDriftPercentMock.mockReturnValue(10); + }); + + it('performs three-phase flow: discovery, calldata re-encoding, delegation quote', async () => { + const request = buildRequest(); + + const result = await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Phase 1: discovery (EXACT_INPUT) + expect(getRelayQuotesMock).toHaveBeenCalledTimes(2); + expect(getRelayQuotesMock.mock.calls[0][0].requests[0]).toStrictEqual( + expect.objectContaining({ + isPostQuote: true, + isMaxAmount: false, + }), + ); + + // Phase 3: delegation (EXACT_OUTPUT) + expect(getRelayQuotesMock.mock.calls[1][0].requests[0]).toStrictEqual( + expect.objectContaining({ + isPostQuote: false, + isMaxAmount: false, + }), + ); + + expect(submitRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('reserves original relay fee from discovery source amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Fee quote: sourceUsd=5.00, targetUsd=4.85, sourceRaw=1e18 + // feeUsd = 0.15, feeReserveRaw = 0.15 / (5.00/1e18) = 3e16 + // discoverySource = 1e18 - 3e16 = 970000000000000000 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('970000000000000000'); + expect(discoveryRequest.sourceBalanceRaw).toBe('1000000000000000000'); + }); + + it('applies fee reserve multiplier from feature flag', async () => { + getFiatFeeReserveMultiplierMock.mockReturnValue(1.5); + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // feeReserveRaw = 3e16 * 1.5 = 4.5e16 + // discoverySource = 1e18 - 4.5e16 = 955000000000000000 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('955000000000000000'); + }); + + it('clamps discovery source to minimum 1 when fee reserve exceeds settled amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '10000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // feeReserve (3e16) > settled (1e16) → clamped to 1 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1'); + }); + + it('skips fee reserve when original relay quote has zero source USD', async () => { + const fiatQuote = buildFiatQuote({ + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '0' }, + currencyOut: { + amount: '12000000', + amountUsd: '0', + minimumAmount: '11900000', + }, + }, + } as unknown as Partial); + + const request = buildRequest({ quotes: [fiatQuote] }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Zero USD → fee reserve = 0 → full amount used + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1000000000000000000'); + }); + + it('skips fee reserve when original relay fee is not positive', async () => { + const fiatQuote = buildFiatQuote({ + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '4.85' }, + currencyOut: { + amount: '12000000', + amountUsd: '5.00', + minimumAmount: '11900000', + }, + }, + } as unknown as Partial); + + const request = buildRequest({ quotes: [fiatQuote] }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1000000000000000000'); + }); + + it('adds discovery fee back to adjusted target amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Discovery returns minimumAmount=11900000 with sourceUsd=5.00, targetUsd=4.85 + // discoveryFeeUsd=0.15, usdPerTargetRaw=4.85/11900000 + // discoveryFeeInTargetRaw=0.15/(4.85/11900000)=368041 + // adjustedTarget=11900000+368041=12268041 + const finalRequest = getRelayQuotesMock.mock.calls[1][0].requests[0]; + expect(finalRequest.targetAmountMinimum).toBe('12268041'); + }); + + it('passes adjusted target to getAmountData', async () => { + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '12268041' }), + ); + }); + + it('updates nested calldata and requiredAssets with adjusted amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + const settledAmountCall = updateTransactionMock.mock.calls.find( + ([opts]) => opts.note === 'Fiat deposit: update settled amount', + ); + expect(settledAmountCall).toBeDefined(); + + const txDraft = { + nestedTransactions: [ + { to: '0xaaa', data: '0x1111' }, + { to: '0xbbb', data: '0x2222' }, + ], + requiredAssets: [{ address: '0xaaa', amount: '0x0' }], + } as unknown as TransactionMeta; + + const updateFn = settledAmountCall?.[1]; + (updateFn as (tx: TransactionMeta) => void)(txDraft); + + expect(txDraft.nestedTransactions?.[0].data).toBe('0xNewApprove'); + expect(txDraft.nestedTransactions?.[1].data).toBe('0xNewDeposit'); + expect(txDraft.requiredAssets?.[0].amount).toBe('0xbb3209'); + }); + + it('throws when discovery relay returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const request = buildRequest(); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for fiat discovery'); + }); + + it('throws when final relay re-quote returns no quotes', async () => { + getRelayQuotesMock + .mockResolvedValueOnce([RELAY_QUOTE_MOCK]) + .mockResolvedValueOnce([]); + const request = buildRequest(); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for completed fiat order'); + }); + + it('throws when getAmountData returns no updates', async () => { + const callMock = buildCallMock({ getAmountDataUpdates: [] }); + const request = buildRequest({ callMock }); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow( + 'getAmountData returned no updates for transaction with nested calldata', + ); + }); + + it('falls back to original transaction when getTransaction returns undefined', async () => { + getTransactionMock.mockReturnValue(undefined); + const request = buildRequest(); + + const result = await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ transaction: TRANSACTION_MOCK }), + ); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('falls back to discovery minimum when discovery quote has zero target USD', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '0' }, + currencyOut: { + amount: '12000000', + amountUsd: '0', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), + ); + }); + + it('falls back to discovery minimum when discovery fee is not positive', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '4.85' }, + currencyOut: { + amount: '12000000', + amountUsd: '5.00', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), + ); + }); + + it('reads maxRateDriftPercent from feature flags', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(15); + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getFiatMaxRateDriftPercentMock).toHaveBeenCalledWith( + request.messenger, + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts new file mode 100644 index 0000000000..3e57317e6e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts @@ -0,0 +1,253 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { projectLogger } from '../../logger'; +import type { PayStrategyExecuteRequest, QuoteRequest } from '../../types'; +import { + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, +} from '../../utils/feature-flags'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import type { FiatQuote } from './types'; +import { validateRelayRateDrift } from './utils'; + +const log = createModuleLogger(projectLogger, 'fiat-submit-calldata'); + +/** + * Submits relay quotes using the three-phase flow for transactions with nested + * calldata that needs re-encoding (e.g. moneyAccountDeposit with approve + deposit). + * + * Phase 1: Discovery quote (EXACT_INPUT) to learn the target token output. + * Phase 2: Delegate calldata re-encoding to the client via getAmountData. + * Phase 3: Delegation quote (EXACT_OUTPUT) with updated nested transaction data. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +export async function submitWithCalldataReEncoding({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + + const feeReserveRaw = calculateFeeReserve({ + feeQuote: request.quotes[0].original.relayQuote, + multiplier: getFiatFeeReserveMultiplier(messenger), + }); + + const discoverySourceAmount = BigNumber.max( + new BigNumber(sourceAmountRaw).minus(feeReserveRaw), + 1, + ).toFixed(0); + + log('Fee reserve for discovery', { + feeReserveRaw: feeReserveRaw.toFixed(0), + discoverySourceAmount, + sourceAmountRaw, + transactionId, + }); + + const discoveryRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: discoverySourceAmount, + }; + + const discoveryQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [discoveryRequest], + transaction, + }); + + if (!discoveryQuotes.length) { + throw new Error('No relay quotes returned for fiat discovery'); + } + + const discoveryRelay = discoveryQuotes[0].original; + + const originalRelayQuote = request.quotes[0].original.relayQuote; + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: discoveryRelay, + maxRateDriftPercent: getFiatMaxRateDriftPercent(messenger), + transactionId, + }); + + const adjustedTargetRaw = calculateAdjustedTarget(discoveryRelay); + + log('Adjusted target for final quote', { + discoveryMinimum: discoveryRelay.details.currencyOut.minimumAmount, + adjustedTargetRaw, + transactionId, + }); + + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { amount: adjustedTargetRaw, transaction }, + ); + + if (!updates.length) { + throw new Error( + 'getAmountData returned no updates for transaction with nested calldata', + ); + } + + updateTransaction( + { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${new BigNumber(adjustedTargetRaw).toString(16)}`; + } + }, + ); + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + targetAmountMinimum: adjustedTargetRaw, + }; + + const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [relayRequest], + transaction: updatedTransaction, + }); + + 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, + }); + + return await submitRelayQuotes({ + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction: updatedTransaction, + }); +} + +/** + * Calculates the fee reserve in raw source token units from the original + * relay fee quote. This reserve is subtracted from the discovery quote's + * source amount so the final EXACT_OUTPUT quote stays within the settled + * balance. + * + * @param options - Calculation options. + * @param options.feeQuote - The original relay quote from the fee/quoting phase. + * @param options.multiplier - Multiplier applied to the fee reserve (default 1). + * @returns The fee reserve in raw source token units. + */ +function calculateFeeReserve({ + feeQuote, + multiplier, +}: { + feeQuote: RelayQuote; + multiplier: number; +}): BigNumber { + const sourceRaw = new BigNumber(feeQuote.details.currencyIn.amount); + const sourceUsd = new BigNumber(feeQuote.details.currencyIn.amountUsd); + const targetUsd = new BigNumber(feeQuote.details.currencyOut.amountUsd); + + if (!sourceUsd.gt(0) || !sourceRaw.gt(0)) { + return new BigNumber(0); + } + + // Fee in USD = what the relay consumed beyond the target value + const feeUsd = sourceUsd.minus(targetUsd); + + if (!feeUsd.gt(0)) { + return new BigNumber(0); + } + + // Convert USD fee to raw source token units using the quote's own rate + const usdPerSourceRaw = sourceUsd.dividedBy(sourceRaw); + + return feeUsd + .dividedBy(usdPerSourceRaw) + .multipliedBy(multiplier) + .decimalPlaces(0, BigNumber.ROUND_UP); +} + +/** + * Calculates the adjusted target amount for the final EXACT_OUTPUT quote + * by adding the discovery quote's fee back to its minimum output. + * + * The discovery quote (EXACT_INPUT) reports a smaller fee than the final + * EXACT_OUTPUT quote will charge. Adding the discovery fee back to the + * minimum output compensates for this differential, ensuring the final + * quote targets a realistic amount. + * + * @param discoveryQuote - The relay quote from the discovery phase. + * @returns The adjusted target amount in raw target token units. + */ +function calculateAdjustedTarget(discoveryQuote: RelayQuote): string { + const targetMinRaw = new BigNumber( + discoveryQuote.details.currencyOut.minimumAmount, + ); + const targetUsd = new BigNumber(discoveryQuote.details.currencyOut.amountUsd); + const sourceUsd = new BigNumber(discoveryQuote.details.currencyIn.amountUsd); + + if (!targetUsd.gt(0) || !targetMinRaw.gt(0)) { + return targetMinRaw.toFixed(0); + } + + // Discovery fee in USD + const discoveryFeeUsd = sourceUsd.minus(targetUsd); + + if (!discoveryFeeUsd.gt(0)) { + return targetMinRaw.toFixed(0); + } + + // Convert fee from USD to raw target token units + const usdPerTargetRaw = targetUsd.dividedBy(targetMinRaw); + const discoveryFeeInTargetRaw = discoveryFeeUsd + .dividedBy(usdPerTargetRaw) + .decimalPlaces(0, BigNumber.ROUND_DOWN); + + const adjusted = targetMinRaw.plus(discoveryFeeInTargetRaw); + + log('calculateAdjustedTarget', { + targetMinRaw: targetMinRaw.toFixed(0), + discoveryFeeUsd: discoveryFeeUsd.toFixed(6), + discoveryFeeInTargetRaw: discoveryFeeInTargetRaw.toFixed(0), + adjustedTargetRaw: adjusted.toFixed(0), + }); + + return adjusted.toFixed(0); +} 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 cd03f30dce..5a9f61fbce 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 @@ -15,7 +15,7 @@ import type { TransactionPayQuote, } from '../../types'; import { buildCaipAssetType } from '../../utils/token'; -import { getTransaction, updateTransaction } from '../../utils/transaction'; +import { updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -24,7 +24,11 @@ import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; -jest.mock('./utils'); +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + deriveFiatAssetForFiatPayment: jest.fn(), + resolveSourceAmountRaw: jest.fn(), +})); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); @@ -232,6 +236,10 @@ function getRequest({ return Promise.resolve({ updates: [] }); } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + throw new Error(`Unexpected action: ${action}`); }); @@ -254,7 +262,6 @@ describe('submitFiatQuotes', () => { deriveFiatAssetForFiatPayment, ); const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw); - const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); @@ -266,7 +273,6 @@ describe('submitFiatQuotes', () => { buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); - getTransactionMock.mockReturnValue(TRANSACTION_MOCK); getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0x1234', @@ -358,6 +364,9 @@ describe('submitFiatQuotes', () => { ], }); } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -369,7 +378,7 @@ describe('submitFiatQuotes', () => { isMaxAmount: false, isPostQuote: true, sourceBalanceRaw: '1234500000000000000', - sourceTokenAmount: '1234500000000000000', + sourceTokenAmount: '1204500000000000000', }), ]); expect(getRelayQuotesMock.mock.calls[1][0].requests).toStrictEqual([ @@ -378,12 +387,12 @@ describe('submitFiatQuotes', () => { isPostQuote: false, sourceBalanceRaw: '1234500000000000000', sourceTokenAmount: '1234500000000000000', - targetAmountMinimum: '11900000', + targetAmountMinimum: '12268041', }), ]); expect(callMock).toHaveBeenCalledWith( 'TransactionPayController:getAmountData', - expect.objectContaining({ amount: '11900000' }), + expect.objectContaining({ amount: '12268041' }), ); expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); @@ -464,6 +473,9 @@ describe('submitFiatQuotes', () => { }, }; } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -581,6 +593,9 @@ describe('submitFiatQuotes', () => { return Promise.resolve({ updates: [] }); } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -638,6 +653,9 @@ describe('submitFiatQuotes', () => { return Promise.resolve({ updates: [] }); } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -756,331 +774,4 @@ describe('submitFiatQuotes', () => { 'Computed fiat order source amount is not positive', ); }); - - it('skips rate drift check when original relay amounts are zero', async () => { - const { request } = getRequest(); - request.quotes[0].original.relayQuote = { - details: { - currencyIn: { amount: '0', amountUsd: '0' }, - currencyOut: { amount: '0', amountUsd: '0', minimumAmount: '11900000' }, - }, - } as unknown as RelayQuote; - - const result = await submitFiatQuotes(request); - - expect(result).toStrictEqual({ transactionHash: '0x1234' }); - }); - - it('throws if relay rate drift exceeds threshold', async () => { - getRelayQuotesMock.mockResolvedValue([ - { - ...RELAY_QUOTE_RESULT_MOCK, - original: { - details: { - currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, - currencyOut: { - amount: '10000000', - amountUsd: '2.00', - minimumAmount: '9800000', - }, - }, - } as unknown as RelayQuote, - }, - ]); - const { request } = getRequest(); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - /Relay rate drift too high/u, - ); - }); - - it('allows rate drift when discovery rate is better than original', async () => { - getRelayQuotesMock.mockResolvedValue([ - { - ...RELAY_QUOTE_RESULT_MOCK, - original: { - details: { - currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, - currencyOut: { - amount: '14000000', - amountUsd: '6.00', - minimumAmount: '13800000', - }, - }, - } as unknown as RelayQuote, - }, - ]); - const { request } = getRequest(); - - const result = await submitFiatQuotes(request); - - expect(result).toStrictEqual({ transactionHash: '0x1234' }); - }); - - it('throws if simple relay 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 discovery relay quote returns no quotes', async () => { - getRelayQuotesMock.mockResolvedValue([]); - const { request } = getRequest({ - transaction: { - ...TRANSACTION_MOCK, - nestedTransactions: [ - { to: '0xaaa', data: '0x1111' }, - { to: '0xbbb', data: '0x2222' }, - ], - } as unknown as TransactionMeta, - }); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - 'No relay quotes returned for fiat discovery', - ); - }); - - it('throws if final relay re-quote returns no quotes', async () => { - getRelayQuotesMock - .mockResolvedValueOnce([RELAY_QUOTE_RESULT_MOCK]) - .mockResolvedValueOnce([]); - - const nestedTransaction = { - ...TRANSACTION_MOCK, - nestedTransactions: [ - { to: '0xaaa' as Hex, data: '0x1111' as Hex }, - { to: '0xbbb' as Hex, data: '0x2222' as Hex }, - ], - } as unknown as TransactionMeta; - - const { callMock, request } = getRequest({ - transaction: nestedTransaction, - }); - - callMock.mockImplementation((action: string) => { - if (action === 'TransactionPayController:getState') { - return { - transactionData: { - [TRANSACTION_ID_MOCK]: { - fiatPayment: { - orderId: ORDER_ID_MOCK, - rampsQuote: RAMPS_QUOTE_MOCK, - }, - isLoading: false, - tokens: [], - }, - }, - }; - } - if (action === 'RampsController:getOrder') { - return getFiatOrderMock(); - } - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [ - { nestedTransactionIndex: 0, data: '0xNewApprove' }, - { nestedTransactionIndex: 1, data: '0xNewDeposit' }, - ], - }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - 'No relay quotes returned for completed fiat order', - ); - }); - - it('does not call getAmountData for simple deposits without nested calldata', async () => { - const { callMock, request } = getRequest(); - - await submitFiatQuotes(request); - - expect(callMock).not.toHaveBeenCalledWith( - 'TransactionPayController:getAmountData', - expect.anything(), - ); - }); - - it('throws if getAmountData returns no updates for transaction with nested calldata', async () => { - const { callMock, request } = getRequest({ - transaction: { - ...TRANSACTION_MOCK, - nestedTransactions: [ - { to: '0xaaa', data: '0x1234' }, - { to: '0xbbb', data: '0x5678' }, - ], - } as unknown as TransactionMeta, - }); - - callMock.mockImplementation((action: string) => { - if (action === 'TransactionPayController:getState') { - return { - transactionData: { - [TRANSACTION_ID_MOCK]: { - fiatPayment: { - orderId: ORDER_ID_MOCK, - rampsQuote: RAMPS_QUOTE_MOCK, - }, - isLoading: false, - tokens: [], - }, - }, - }; - } - if (action === 'RampsController:getOrder') { - return getFiatOrderMock(); - } - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ updates: [] }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - 'getAmountData returned no updates for transaction with nested calldata', - ); - }); - - it('applies getAmountData updates to nested calldata and requiredAssets', async () => { - const nestedTransaction = { - ...TRANSACTION_MOCK, - nestedTransactions: [ - { to: '0xaaa' as Hex, data: '0x1111' as Hex }, - { to: '0xbbb' as Hex, data: '0x2222' as Hex }, - ], - requiredAssets: [{ address: '0xaaa' as Hex, amount: '0x0' as Hex }], - } as unknown as TransactionMeta; - - const { callMock, request } = getRequest({ - transaction: nestedTransaction, - }); - - callMock.mockImplementation((action: string) => { - if (action === 'TransactionPayController:getState') { - return { - transactionData: { - [TRANSACTION_ID_MOCK]: { - fiatPayment: { - orderId: ORDER_ID_MOCK, - rampsQuote: RAMPS_QUOTE_MOCK, - }, - isLoading: false, - tokens: [], - }, - }, - }; - } - if (action === 'RampsController:getOrder') { - return getFiatOrderMock(); - } - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [ - { nestedTransactionIndex: 0, data: '0xNewApprove' }, - { nestedTransactionIndex: 1, data: '0xNewDeposit' }, - ], - }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await submitFiatQuotes(request); - - const settledAmountCall = updateTransactionMock.mock.calls.find( - ([opts]) => opts.note === 'Fiat deposit: update settled amount', - ); - expect(settledAmountCall).toBeDefined(); - - const txDraft = { - nestedTransactions: [ - { to: '0xaaa', data: '0x1111' }, - { to: '0xbbb', data: '0x2222' }, - ], - requiredAssets: [{ address: '0xaaa', amount: '0x0' }], - } as unknown as TransactionMeta; - - const updateFn = settledAmountCall?.[1]; - expect(updateFn).toBeDefined(); - (updateFn as (tx: TransactionMeta) => void)(txDraft); - - expect(txDraft.nestedTransactions?.[0].data).toBe('0xNewApprove'); - expect(txDraft.nestedTransactions?.[1].data).toBe('0xNewDeposit'); - expect(txDraft.requiredAssets?.[0].amount).toBe('0xb59460'); - }); - - it('falls back to original transaction when getTransaction returns undefined on simple path', async () => { - getTransactionMock.mockReturnValue(undefined); - const { request } = getRequest(); - - const result = await submitFiatQuotes(request); - - expect(result).toStrictEqual({ transactionHash: '0x1234' }); - }); - - it('falls back to original transaction when getTransaction returns undefined on three-phase path', async () => { - getTransactionMock.mockReturnValue(undefined); - - const nestedTransaction = { - ...TRANSACTION_MOCK, - nestedTransactions: [ - { to: '0xaaa' as Hex, data: '0x1111' as Hex }, - { to: '0xbbb' as Hex, data: '0x2222' as Hex }, - ], - } as unknown as TransactionMeta; - - const { callMock, request } = getRequest({ - transaction: nestedTransaction, - }); - - callMock.mockImplementation((action: string) => { - if (action === 'TransactionPayController:getState') { - return { - transactionData: { - [TRANSACTION_ID_MOCK]: { - fiatPayment: { - orderId: ORDER_ID_MOCK, - rampsQuote: RAMPS_QUOTE_MOCK, - }, - isLoading: false, - tokens: [], - }, - }, - }; - } - if (action === 'RampsController:getOrder') { - return getFiatOrderMock(); - } - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [ - { nestedTransactionIndex: 0, data: '0xNewApprove' }, - { nestedTransactionIndex: 1, data: '0xNewDeposit' }, - ], - }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - const result = await submitFiatQuotes(request); - - expect(result).toStrictEqual({ transactionHash: '0x1234' }); - expect(submitRelayQuotesMock).toHaveBeenCalledWith( - expect.objectContaining({ - transaction: nestedTransaction, - }), - ); - }); - - 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 05b0517f83..f340811d82 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -5,29 +5,29 @@ import type { import { RampsOrderStatus } from '@metamask/ramps-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { projectLogger } from '../../logger'; import type { PayStrategy, PayStrategyExecuteRequest, - QuoteRequest, TransactionPayControllerMessenger, } from '../../types'; import { buildCaipAssetType } from '../../utils/token'; -import { getTransaction, updateTransaction } from '../../utils/transaction'; -import { getRelayQuotes } from '../relay/relay-quotes'; -import { submitRelayQuotes } from '../relay/relay-submit'; -import type { RelayQuote } from '../relay/types'; +import { updateTransaction } from '../../utils/transaction'; import type { TransactionPayFiatAsset } from './constants'; +import { submitSimpleRelay } from './fiat-submit-simple'; +import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; +import { + deriveFiatAssetForFiatPayment, + extractProviderCode, + resolveSourceAmountRaw, +} from './utils'; const log = createModuleLogger(projectLogger, 'fiat-submit'); const ORDER_POLL_INTERVAL_MS = 1000; const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; -const MAX_RATE_DRIFT_PERCENT = 10; const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ RampsOrderStatus.Cancelled, @@ -108,29 +108,6 @@ export async function submitFiatQuotes( return await submitRelayAfterFiatCompletion({ order, request }); } -/** - * Extracts the provider code from a ramps provider string. - * - * Accepts the canonical provider code (e.g. `transak-native`) and, for - * backwards compatibility, the legacy path form (e.g. `/providers/transak-native`). - * - * @param provider - Canonical provider code, or legacy provider path. - * @returns The provider code, or `null` if the format is invalid. - */ -function extractProviderCode(provider: string | undefined): string | null { - if (!provider) { - return null; - } - - const parts = provider.split('/').filter(Boolean); - - if (parts[0] === 'providers') { - return parts[1] ?? null; - } - - return parts.length === 1 ? parts[0] : null; -} - /** * Validates that the completed order's crypto asset matches the expected fiat asset. * @@ -307,255 +284,3 @@ async function submitRelayAfterFiatCompletion({ transaction, }); } - -/** - * Submits a single EXACT_INPUT relay quote for simple deposits - * that don't require nested calldata re-encoding or delegation. - * - * @param options - The submission options. - * @param options.baseRequest - The base quote request from the original fiat quote. - * @param options.request - The original fiat strategy execute request. - * @param options.sourceAmountRaw - The settled source amount in atomic units. - * @param options.transaction - The transaction metadata. - * @returns An object containing the relay transaction hash if available. - */ -async function submitSimpleRelay({ - baseRequest, - request, - sourceAmountRaw, - transaction, -}: { - baseRequest: QuoteRequest; - request: PayStrategyExecuteRequest; - sourceAmountRaw: string; - transaction: PayStrategyExecuteRequest['transaction']; -}): Promise<{ transactionHash?: Hex }> { - const { messenger } = request; - const transactionId = transaction.id; - - const originalRelayQuote = request.quotes[0].original.relayQuote; - - const relayRequest: QuoteRequest = { - ...baseRequest, - isMaxAmount: false, - isPostQuote: true, - sourceBalanceRaw: sourceAmountRaw, - sourceTokenAmount: sourceAmountRaw, - }; - - const relayQuotes = await getRelayQuotes({ - accountSupports7702: request.accountSupports7702, - messenger, - requests: [relayRequest], - transaction, - }); - - if (!relayQuotes.length) { - throw new Error('No relay quotes returned for completed fiat order'); - } - - validateRelayRateDrift({ - originalQuote: originalRelayQuote, - discoveryQuote: relayQuotes[0].original, - transactionId, - }); - - log('Submitting simple relay after fiat settlement', { - relayQuoteCount: relayQuotes.length, - transactionId, - }); - - return await submitRelayQuotes({ - accountSupports7702: request.accountSupports7702, - isSmartTransaction: request.isSmartTransaction, - messenger, - quotes: relayQuotes, - transaction, - }); -} - -/** - * Submits relay quotes using the three-phase flow for transactions with nested - * calldata that needs re-encoding (e.g. moneyAccountDeposit with approve + deposit). - * - * Phase 1: Discovery quote (EXACT_INPUT) to learn the target token output. - * Phase 2: Delegate calldata re-encoding to the client via getAmountData. - * Phase 3: Delegation quote (EXACT_OUTPUT) with updated nested transaction data. - * - * @param options - The submission options. - * @param options.baseRequest - The base quote request from the original fiat quote. - * @param options.request - The original fiat strategy execute request. - * @param options.sourceAmountRaw - The settled source amount in atomic units. - * @param options.transaction - The transaction metadata. - * @returns An object containing the relay transaction hash if available. - */ -async function submitWithCalldataReEncoding({ - baseRequest, - request, - sourceAmountRaw, - transaction, -}: { - baseRequest: QuoteRequest; - request: PayStrategyExecuteRequest; - sourceAmountRaw: string; - transaction: PayStrategyExecuteRequest['transaction']; -}): Promise<{ transactionHash?: Hex }> { - const { messenger } = request; - const transactionId = transaction.id; - - const discoveryRequest: QuoteRequest = { - ...baseRequest, - isMaxAmount: false, - isPostQuote: true, - sourceBalanceRaw: sourceAmountRaw, - sourceTokenAmount: sourceAmountRaw, - }; - - const discoveryQuotes = await getRelayQuotes({ - accountSupports7702: request.accountSupports7702, - messenger, - requests: [discoveryRequest], - transaction, - }); - - if (!discoveryQuotes.length) { - throw new Error('No relay quotes returned for fiat discovery'); - } - - const discoveryRelay = discoveryQuotes[0].original; - const settledTargetRaw = discoveryRelay.details.currencyOut.minimumAmount; - - const originalRelayQuote = request.quotes[0].original.relayQuote; - validateRelayRateDrift({ - originalQuote: originalRelayQuote, - discoveryQuote: discoveryRelay, - transactionId, - }); - - const { updates } = await messenger.call( - 'TransactionPayController:getAmountData', - { amount: settledTargetRaw, transaction }, - ); - - if (!updates.length) { - throw new Error( - 'getAmountData returned no updates for transaction with nested calldata', - ); - } - - updateTransaction( - { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, - (tx) => { - for (const { nestedTransactionIndex, data } of updates) { - if (tx.nestedTransactions?.[nestedTransactionIndex]) { - tx.nestedTransactions[nestedTransactionIndex].data = data; - } - } - if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${new BigNumber(settledTargetRaw).toString(16)}`; - } - }, - ); - - const updatedTransaction = - getTransaction(transactionId, messenger) ?? transaction; - - const relayRequest: QuoteRequest = { - ...baseRequest, - isMaxAmount: false, - isPostQuote: false, - sourceBalanceRaw: sourceAmountRaw, - sourceTokenAmount: sourceAmountRaw, - targetAmountMinimum: settledTargetRaw, - }; - - const relayQuotes = await getRelayQuotes({ - accountSupports7702: request.accountSupports7702, - messenger, - requests: [relayRequest], - transaction: updatedTransaction, - }); - - 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, - }); - - return await submitRelayQuotes({ - accountSupports7702: request.accountSupports7702, - isSmartTransaction: request.isSmartTransaction, - messenger, - quotes: relayQuotes, - transaction: updatedTransaction, - }); -} - -/** - * Validates that the relay exchange rate hasn't drifted significantly between - * the original quoting phase and the post-settlement discovery quote. - * - * Compares the USD output/input ratio from both quotes. This normalises for - * different source amounts (quoting phase uses a theoretical amount, discovery - * uses the actual settled amount) so the comparison reflects genuine rate - * movement rather than amount differences. - * - * @param options - The validation options. - * @param options.originalQuote - Relay quote from the original quoting phase. - * @param options.discoveryQuote - Relay quote from the post-settlement discovery. - * @param options.transactionId - Transaction ID for error reporting. - */ -function validateRelayRateDrift({ - originalQuote, - discoveryQuote, - transactionId, -}: { - originalQuote: RelayQuote; - discoveryQuote: RelayQuote; - transactionId: string; -}): void { - const originalIn = new BigNumber(originalQuote.details.currencyIn.amountUsd); - const originalOut = new BigNumber( - originalQuote.details.currencyOut.amountUsd, - ); - const discoveryIn = new BigNumber( - discoveryQuote.details.currencyIn.amountUsd, - ); - const discoveryOut = new BigNumber( - discoveryQuote.details.currencyOut.amountUsd, - ); - - if ( - !originalIn.gt(0) || - !originalOut.gt(0) || - !discoveryIn.gt(0) || - !discoveryOut.gt(0) - ) { - return; - } - - const originalRate = originalOut.dividedBy(originalIn); - const discoveryRate = discoveryOut.dividedBy(discoveryIn); - - const driftPercent = originalRate - .minus(discoveryRate) - .dividedBy(originalRate) - .multipliedBy(100); - - log('Relay rate drift check', { - originalRate: originalRate.toFixed(6), - discoveryRate: discoveryRate.toFixed(6), - driftPercent: driftPercent.toFixed(2), - transactionId, - }); - - if (driftPercent.gt(MAX_RATE_DRIFT_PERCENT)) { - throw new Error( - `Relay rate drift too high for transaction ` + - `${driftPercent.toFixed(2)}% exceeds ${MAX_RATE_DRIFT_PERCENT}% max`, - ); - } -} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index c3615e1dab..253da904b7 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -10,6 +10,7 @@ import type { TransactionPayControllerMessenger } from '../../types'; import { getFiatAssetPerTransactionType } from '../../utils/feature-flags'; import { getTokenInfo } from '../../utils/token'; import { getTransferredAmountFromTxHash } from '../../utils/transaction'; +import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; @@ -138,3 +139,98 @@ export function getRawSourceAmountFromOrderCryptoAmount({ return rawAmount; } + +/** + * Validates that the relay exchange rate hasn't drifted significantly between + * the original quoting phase and the post-settlement discovery quote. + * + * Compares the USD output/input ratio from both quotes. This normalises for + * different source amounts (quoting phase uses a theoretical amount, discovery + * uses the actual settled amount) so the comparison reflects genuine rate + * movement rather than amount differences. + * + * @param options - The validation options. + * @param options.originalQuote - Relay quote from the original quoting phase. + * @param options.discoveryQuote - Relay quote from the post-settlement discovery. + * @param options.maxRateDriftPercent - Maximum allowed rate drift percentage. + * @param options.transactionId - Transaction ID for error reporting. + */ +export function validateRelayRateDrift({ + originalQuote, + discoveryQuote, + maxRateDriftPercent, + transactionId, +}: { + originalQuote: RelayQuote; + discoveryQuote: RelayQuote; + maxRateDriftPercent: number; + transactionId: string; +}): void { + const originalIn = new BigNumber(originalQuote.details.currencyIn.amountUsd); + const originalOut = new BigNumber( + originalQuote.details.currencyOut.amountUsd, + ); + const discoveryIn = new BigNumber( + discoveryQuote.details.currencyIn.amountUsd, + ); + const discoveryOut = new BigNumber( + discoveryQuote.details.currencyOut.amountUsd, + ); + + if ( + !originalIn.gt(0) || + !originalOut.gt(0) || + !discoveryIn.gt(0) || + !discoveryOut.gt(0) + ) { + return; + } + + const originalRate = originalOut.dividedBy(originalIn); + const discoveryRate = discoveryOut.dividedBy(discoveryIn); + + const driftPercent = originalRate + .minus(discoveryRate) + .dividedBy(originalRate) + .multipliedBy(100); + + log('Relay rate drift check', { + originalRate: originalRate.toFixed(6), + discoveryRate: discoveryRate.toFixed(6), + driftPercent: driftPercent.toFixed(2), + maxRateDriftPercent, + transactionId, + }); + + if (driftPercent.gt(maxRateDriftPercent)) { + throw new Error( + `Relay rate drift too high for transaction ` + + `${driftPercent.toFixed(2)}% exceeds ${maxRateDriftPercent}% max`, + ); + } +} + +/** + * Extracts the provider code from a ramps provider string. + * + * Accepts the canonical provider code (e.g. `transak-native`) and, for + * backwards compatibility, the legacy path form (e.g. `/providers/transak-native`). + * + * @param provider - Canonical provider code, or legacy provider path. + * @returns The provider code, or `null` if the format is invalid. + */ +export function extractProviderCode( + provider: string | undefined, +): string | null { + if (!provider) { + return null; + } + + const parts = provider.split('/').filter(Boolean); + + if (parts[0] === 'providers') { + return parts[1] ?? null; + } + + return parts.length === 1 ? parts[0] : null; +} diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 076fd56ed5..5db255055e 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -17,6 +17,8 @@ import { getAssetsUnifyStateFeature, getFallbackGas, getFiatAssetPerTransactionType, + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, DEFAULT_RELAY_EXECUTE_URL, getServerPollingInterval, getServerPollingTimeout, @@ -1515,4 +1517,92 @@ describe('Feature Flags Utils', () => { expect(result).toStrictEqual(FIAT_ASSET_MOCK); }); }); + + describe('getFiatFeeReserveMultiplier', () => { + it('returns 1 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1); + }); + + it('returns the configured multiplier from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: 1.5 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1.5); + }); + + it('returns 1 when multiplier is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: 0 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1); + }); + + it('returns 1 when multiplier is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: -2 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1); + }); + }); + + describe('getFiatMaxRateDriftPercent', () => { + it('returns 10 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + + it('returns the configured value from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: 25 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(25); + }); + + it('returns 10 when value is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: 0 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + + it('returns 10 when value is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: -5 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 8ef214c4a6..b0f168ab05 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -91,6 +91,8 @@ type FiatFlags = { assetPerTransactionType?: Partial< Record >; + feeReserveMultiplier?: number; + maxRateDriftPercent?: number; }; type StrategyRoutingConfig = { @@ -821,6 +823,59 @@ export function getFiatAssetPerTransactionType( ); } +const DEFAULT_FEE_RESERVE_MULTIPLIER = 1; +const DEFAULT_MAX_RATE_DRIFT_PERCENT = 10; + +/** + * Returns the fee reserve multiplier for fiat three-phase submit. + * + * Controls how much of the original relay fee is reserved from the discovery + * quote source amount to prevent EXACT_OUTPUT cost overruns. + * Defaults to 1 (100% of the original fee). + * + * @param messenger - Controller messenger. + * @returns The fee reserve multiplier. + */ +export function getFiatFeeReserveMultiplier( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const fiatFlags = state.remoteFeatureFlags?.confirmations_pay_fiat as + | FiatFlags + | undefined; + + const multiplier = fiatFlags?.feeReserveMultiplier; + + return typeof multiplier === 'number' && multiplier > 0 + ? multiplier + : DEFAULT_FEE_RESERVE_MULTIPLIER; +} + +/** + * Returns the maximum allowed relay rate drift percentage for fiat submit. + * + * Controls how much the relay exchange rate can drift between the original + * quoting phase and the post-settlement discovery quote before failing. + * Defaults to 10%. + * + * @param messenger - Controller messenger. + * @returns The maximum rate drift percentage. + */ +export function getFiatMaxRateDriftPercent( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const fiatFlags = state.remoteFeatureFlags?.confirmations_pay_fiat as + | FiatFlags + | undefined; + + const maxDrift = fiatFlags?.maxRateDriftPercent; + + return typeof maxDrift === 'number' && maxDrift > 0 + ? maxDrift + : DEFAULT_MAX_RATE_DRIFT_PERCENT; +} + /** * Checks if a chain supports EIP-7702. * From 9f3d322fbcb5d00985e33c8500234220a01aeab4 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 00:43:06 +0200 Subject: [PATCH 20/28] fix: decouple processTransactions from isPostQuote in relay-quotes --- .../src/strategy/relay/relay-quotes.ts | 13 +++++++++---- packages/transaction-pay-controller/src/types.ts | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) 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 4d1bb5fca2..a1e9e0c564 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -259,11 +259,16 @@ async function getSingleQuote( await applyPolymarketDepositWalletOverrides(body, request, messenger); } - // Skip transaction processing for post-quote flows - the original transaction - // will be included in the batch separately, not as part of the quote. - // Skip for Polymarket deposit wallet flows - the source is already a + // Skip transaction processing when skipProcessTransactions (defaulting to + // isPostQuote) is true — the original transaction will be included in the + // batch separately, not as part of the quote. + // Skip for Polymarket deposit wallet flows — the source is already a // bridged token transfer, not a contract call to embed. - if (!request.isPostQuote && !request.isPolymarketDepositWallet) { + const shouldProcessTransactions = + !(request.skipProcessTransactions ?? request.isPostQuote) && + !request.isPolymarketDepositWallet; + + if (shouldProcessTransactions) { await processTransactions(transaction, request, body, messenger); } else if (request.refundTo) { // For post-quote flows, honour the caller-specified refund address so that diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 458bafee6c..6e446d7d31 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -491,6 +491,9 @@ export type QuoteRequest = { */ refundTo?: Hex; + /** Whether to skip processTransactions in relay-quotes. Defaults to `isPostQuote`. */ + skipProcessTransactions?: boolean; + /** Balance of the source token in atomic format without factoring token decimals. */ sourceBalanceRaw: string; From 056e2bce3c45064ffec8f457e0b9eeb0cfe2a605 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 00:43:16 +0200 Subject: [PATCH 21/28] fix: set skipProcessTransactions for simple fiat relay to fix recipient routing --- .../src/strategy/fiat/fiat-submit-simple.test.ts | 1 + .../src/strategy/fiat/fiat-submit-simple.ts | 1 + .../src/strategy/fiat/fiat-submit.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts index 2f235ed05c..1622bc6c39 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts @@ -120,6 +120,7 @@ describe('submitSimpleRelay', () => { expect.objectContaining({ isMaxAmount: false, isPostQuote: true, + skipProcessTransactions: false, sourceBalanceRaw: '5000000000000000000', sourceTokenAmount: '5000000000000000000', }), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts index df45610abf..c763085b5a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts @@ -42,6 +42,7 @@ export async function submitSimpleRelay({ ...baseRequest, isMaxAmount: false, isPostQuote: true, + skipProcessTransactions: false, sourceBalanceRaw: sourceAmountRaw, sourceTokenAmount: sourceAmountRaw, }; 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 5a9f61fbce..4a904bb186 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 @@ -311,6 +311,7 @@ describe('submitFiatQuotes', () => { expect.objectContaining({ isMaxAmount: false, isPostQuote: true, + skipProcessTransactions: false, sourceBalanceRaw: '1234500000000000000', sourceTokenAmount: '1234500000000000000', }), From 26dbbe49f0e7576e30242466ad201623db6de470 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 02:25:36 +0200 Subject: [PATCH 22/28] fix: sum nested call values for EIP-7702 batch transaction --- packages/transaction-controller/src/utils/eip7702.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 91b39c390d..672fc6825a 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -205,9 +205,15 @@ export function generateEIP7702BatchTransaction( log('Transaction data', data); + const totalValue = transactions.reduce( + (sum, tx) => sum + BigInt(tx.value ?? '0x0'), + BigInt(0), + ); + return { data, to: from, + ...(totalValue > BigInt(0) ? { value: toHex(totalValue) } : {}), }; } From eb1521d8a6fafdd1ad3fc535621740dce1deafdf Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 02:25:50 +0200 Subject: [PATCH 23/28] fix: post-quote gas handling for zero-balance fiat-funded accounts --- .../src/strategy/relay/relay-quotes.test.ts | 129 +++++++++++------- .../src/strategy/relay/relay-quotes.ts | 73 +++++++++- .../src/strategy/relay/relay-submit.ts | 2 + .../src/utils/feature-flags.ts | 25 ++++ 4 files changed, 174 insertions(+), 55 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index c20aa399dc..d76837ce85 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -929,12 +929,18 @@ describe('Relay Quotes Utils', () => { expect(body.refundTo).toBeUndefined(); }); - it('estimates only relay transactions for post-quote', async () => { + it('includes original transaction in batch gas estimation for post-quote', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [100000], + totalGasEstimate: 100000, + totalGasLimit: 100000, + }); + const postQuoteTransaction = { ...TRANSACTION_META_MOCK, chainId: '0x1' as Hex, @@ -960,12 +966,16 @@ describe('Relay Quotes Utils', () => { transaction: postQuoteTransaction, }); - // Original transaction should NOT be included in gas estimation. - // Only relay step params are estimated. - expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ data: '0xaaa' }), + ]), + }), + ); }); - it('adds original transaction gas to single relay gas limit for post-quote', async () => { + it('returns combined 7702 gas limit for post-quote with original tx', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, @@ -973,6 +983,12 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [100000], + totalGasEstimate: 100000, + totalGasLimit: 100000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -996,13 +1012,11 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - expect(result[0].original.metamask.gasLimits).toStrictEqual([ - 79000, 21000, - ]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toStrictEqual([100000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => { + it('prefers nestedTransactions gas over txParams.gas for post-quote batch estimation', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, @@ -1010,6 +1024,12 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [71000], + totalGasEstimate: 71000, + totalGasLimit: 71000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -1034,13 +1054,19 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - expect(result[0].original.metamask.gasLimits).toStrictEqual([ - 50000, 21000, - ]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ gas: '0xC350' }), + ]), + }), + ); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([71000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('adds original transaction gas to EIP-7702 combined gas limit for post-quote', async () => { + it('returns combined 7702 gas limit for post-quote with multi-step relay', async () => { const multiStepQuote = { ...QUOTE_MOCK, steps: [ @@ -1067,10 +1093,9 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); - // EIP-7702: batch returns single combined gas for multiple relay txs estimateGasBatchMock.mockResolvedValue({ - totalGasLimit: 51000, - gasLimits: [51000], + totalGasLimit: 130000, + gasLimits: [130000], }); const result = await getRelayQuotes({ @@ -1096,12 +1121,11 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // EIP-7702: original tx gas (79000) added to combined relay gas (51000) expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); expect(result[0].original.metamask.is7702).toBe(true); }); - it('prepends original transaction gas to multiple relay gas limits for post-quote', async () => { + it('returns per-tx gas limits for post-quote with multi-step non-7702 relay', async () => { const multiStepQuote = { ...QUOTE_MOCK, steps: [ @@ -1128,10 +1152,9 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); - // Multiple gas limits (not 7702 combined): batch returns per-tx limits estimateGasBatchMock.mockResolvedValue({ - totalGasLimit: 51000, - gasLimits: [21000, 30000], + totalGasLimit: 130000, + gasLimits: [79000, 21000, 30000], }); const result = await getRelayQuotes({ @@ -1157,19 +1180,24 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // Original tx gas (79000) prepended to relay gas limits [21000, 30000] expect(result[0].original.metamask.gasLimits).toStrictEqual([ 79000, 21000, 30000, ]); expect(result[0].original.metamask.is7702).toBe(false); }); - it('skips original transaction gas when txParams.gas is missing for post-quote', async () => { + it('includes original tx without gas in batch estimation for post-quote', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [80000], + totalGasEstimate: 80000, + totalGasLimit: 80000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -1192,15 +1220,12 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // No gas on txParams or nestedTransactions — only relay gas limits - expect(result[0].original.metamask.gasLimits).toStrictEqual([21000]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(estimateGasBatchMock).toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([80000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('preserves estimate vs limit distinction when using fallback gas for post-quote', async () => { - // Use a quote whose relay step has NO gas param so the single-path - // estimation is attempted; make it fail to trigger the fallback path - // where estimate (900 000) != max (1 500 000). + it('uses batch estimation with original tx for post-quote even when relay step has no gas', async () => { const noGasQuote = cloneDeep(QUOTE_MOCK); delete noGasQuote.steps[0].items[0].data.gas; @@ -1209,9 +1234,15 @@ describe('Relay Quotes Utils', () => { json: async () => noGasQuote, } as never); - estimateGasMock.mockRejectedValue(new Error('Estimation failed')); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [1000000], + totalGasEstimate: 1000000, + totalGasLimit: 1000000, + }); - await getRelayQuotes({ + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ accountSupports7702: true, messenger, requests: [ @@ -1228,25 +1259,15 @@ describe('Relay Quotes Utils', () => { from: FROM_MOCK, to: '0x9' as Hex, data: '0xaaa' as Hex, - gas: '0x13498', // 79 000 + gas: '0x13498', value: '0', }, } as TransactionMeta, }); - // Fallback: estimate=900000, max=1500000. - // With originalTxGas=79000 added independently: - // estimate call should receive 900000+79000 = 979000 - // max call should receive 1500000+79000 = 1579000 - const estimateCall = calculateGasCostMock.mock.calls.find( - ([args]) => !args.isMax, - ); - const maxCall = calculateGasCostMock.mock.calls.find( - ([args]) => args.isMax, - ); - - expect(estimateCall?.[0].gas).toBe(979000); - expect(maxCall?.[0].gas).toBe(1579000); + expect(estimateGasBatchMock).toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([1000000]); + expect(result[0].original.metamask.is7702).toBe(true); }); it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { @@ -1793,9 +1814,11 @@ describe('Relay Quotes Utils', () => { const secondBody = JSON.parse( (secondCall[1] as RequestInit).body as string, ); + // Gas cost is buffered by the default postQuoteGasBuffer (1.1) + // ceil(2725000000000000 * 1.1) = 2997500000000000 + const bufferedGas = BigInt('2997500000000000'); const adjustedAmount = ( - BigInt(QUOTE_REQUEST_MOCK.sourceTokenAmount) - - BigInt('2725000000000000') + BigInt(QUOTE_REQUEST_MOCK.sourceTokenAmount) - bufferedGas ).toString(); expect(secondBody.amount).toBe(adjustedAmount); @@ -2320,6 +2343,12 @@ describe('Relay Quotes Utils', () => { json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [42000], + totalGasEstimate: 42000, + totalGasLimit: 42000, + }); + getTokenBalanceMock.mockReturnValue('1724999999999999'); getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index a1e9e0c564..7ed79e891d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -32,6 +32,7 @@ import type { import { getFiatValueFromUsd } from '../../utils/amounts'; import { getFeatureFlags, + getPostQuoteGasBuffer, getRelayOriginGasOverhead, getSlippage, isEIP7702Chain, @@ -139,11 +140,26 @@ async function getQuoteWithPostQuoteGasHandling( ): Promise> { const phase1Quote = await getSingleQuote(request, fullRequest); - if (!request.isPostQuote || !phase1Quote.fees.isSourceGasFeeToken) { + if (!request.isPostQuote) { return phase1Quote; } - const gasCostRaw = phase1Quote.fees.sourceNetwork.max.raw; + // Gas must be subtracted from the source amount when the user's balance + // is fully committed to the swap. This applies when gas is paid via an + // ERC-20 fee token (isSourceGasFeeToken) OR when the source itself is + // the native gas token (gas comes from the same pool as the swap value). + const isSourceNative = + request.sourceTokenAddress.toLowerCase() === + NATIVE_TOKEN_ADDRESS.toLowerCase(); + + if (!phase1Quote.fees.isSourceGasFeeToken && !isSourceNative) { + return phase1Quote; + } + + const gasBuffer = getPostQuoteGasBuffer(fullRequest.messenger); + const gasCostRaw = new BigNumber(phase1Quote.fees.sourceNetwork.max.raw) + .multipliedBy(gasBuffer) + .integerValue(BigNumber.ROUND_UP); const adjustedSourceAmount = new BigNumber(request.sourceTokenAmount) .minus(gasCostRaw) @@ -698,14 +714,29 @@ async function calculateSourceNetworkCost( const useFromOverride = isPredictWithdraw && hasDepositStep; const fromOverride = useFromOverride ? request.refundTo : undefined; - const relayOnlyGas = await calculateSourceNetworkGasLimit( - relayParams, + // For post-quote flows the original transaction will be prepended to the + // batch at submission time. Include it in the gas estimation so + // estimateGasBatch sees the full batch and can detect EIP-7702 support. + // Without this, a single relay step is estimated alone, gets is7702=false, + // and the batch falls back to separate type-0x2 transactions that each + // need native gas — breaking zero-balance fiat-funded accounts. + const originalTxGasParams = getOriginalTxGasParams(request, transaction); + const allGasParams = originalTxGasParams + ? [originalTxGasParams, ...relayParams] + : relayParams; + + const gasResult = await calculateSourceNetworkGasLimit( + allGasParams, messenger, fromOverride, ); + // When the original tx was NOT included in gas estimation (no gas params + // available), fall back to the legacy prepend-after-the-fact approach. const { gasLimits, is7702, totalGasEstimate, totalGasLimit } = - combinePrependedGas(relayOnlyGas, request, transaction); + originalTxGasParams + ? gasResult + : combinePrependedGas(gasResult, request, transaction); log('Gas limit', { is7702, @@ -903,6 +934,38 @@ function toRelayQuoteGasTransaction( }; } +type RelayStepData = RelayTransactionStep['items'][0]['data']; + +function getOriginalTxGasParams( + request: QuoteRequest, + transaction: TransactionMeta, +): RelayStepData | undefined { + if (!request.isPostQuote) { + return undefined; + } + + const { txParams } = transaction; + const to = txParams.to as Hex | undefined; + + if (!to) { + return undefined; + } + + const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; + const gas = nestedGas ?? txParams.gas; + + return { + chainId: Number(transaction.chainId), + data: (txParams.data as Hex) ?? ('0x' as Hex), + from: txParams.from as Hex, + gas: gas ? String(gas) : undefined, + maxFeePerGas: '0', + maxPriorityFeePerGas: '0', + to, + value: (txParams.value as string) ?? '0', + }; +} + type RelayGasResult = { totalGasEstimate: number; totalGasLimit: number; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index d694584141..ee53b51a53 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -429,6 +429,8 @@ async function submitTransactions( : ({ data: transaction.txParams.data as Hex | undefined, from: transaction.txParams.from, + maxFeePerGas: normalizedParams[0]?.maxFeePerGas, + maxPriorityFeePerGas: normalizedParams[0]?.maxPriorityFeePerGas, to: transaction.txParams.to, value: transaction.txParams.value as Hex | undefined, } as TransactionParams); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index b0f168ab05..81958027db 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -95,6 +95,10 @@ type FiatFlags = { maxRateDriftPercent?: number; }; +type PostQuoteFlags = { + gasBuffer?: number; +}; + type StrategyRoutingConfig = { payStrategies: { across: { @@ -825,6 +829,7 @@ export function getFiatAssetPerTransactionType( const DEFAULT_FEE_RESERVE_MULTIPLIER = 1; const DEFAULT_MAX_RATE_DRIFT_PERCENT = 10; +const DEFAULT_POST_QUOTE_GAS_BUFFER = 1.1; /** * Returns the fee reserve multiplier for fiat three-phase submit. @@ -876,6 +881,26 @@ export function getFiatMaxRateDriftPercent( : DEFAULT_MAX_RATE_DRIFT_PERCENT; } +/** + * Returns the gas buffer multiplier for post-quote gas reservations. + * + * @param messenger - Controller messenger. + * @returns The gas buffer multiplier. + */ +export function getPostQuoteGasBuffer( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const postQuoteFlags = state.remoteFeatureFlags + ?.confirmations_pay_post_quote as PostQuoteFlags | undefined; + + const buffer = postQuoteFlags?.gasBuffer; + + return typeof buffer === 'number' && buffer > 0 + ? buffer + : DEFAULT_POST_QUOTE_GAS_BUFFER; +} + /** * Checks if a chain supports EIP-7702. * From d477e3369e80f5a9abc8a9496bf84fc314ca4ebe Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 02:29:40 +0200 Subject: [PATCH 24/28] test: add coverage for EIP-7702 batch value summing and update changelog --- packages/transaction-controller/CHANGELOG.md | 5 +++ .../src/utils/eip7702.test.ts | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index fd3aeac4a5..05e873e61d 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix EIP-7702 batch transactions not forwarding native value from nested calls ([#8987](https://github.com/MetaMask/core/pull/8987)) + - `generateEIP7702BatchTransaction` now sums the `value` fields of all nested calls and sets the total as the top-level transaction value. Previously the top-level value was always omitted, causing batches that include native token transfers (e.g. POL swaps) to revert. + ## [66.0.1] ### Changed diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 155eb007ee..91ec34cbc0 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -627,6 +627,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); @@ -665,6 +666,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); @@ -689,9 +691,47 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); + it('omits value when all nested calls have zero value', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [ + { + data: '0x1234', + to: ADDRESS_2_MOCK, + value: '0x0', + }, + { + data: '0x9abc', + to: ADDRESS_3_MOCK, + }, + ]); + + expect(result).toStrictEqual({ + data: expect.any(String), + to: ADDRESS_MOCK, + }); + expect(result.value).toBeUndefined(); + }); + + it('sums values from nested calls with mixed zero and non-zero values', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [ + { + data: '0x1234', + to: ADDRESS_2_MOCK, + value: '0x0', + }, + { + data: '0x9abc', + to: ADDRESS_3_MOCK, + value: '0x5678', + }, + ]); + + expect(result.value).toBe('0x5678'); + }); + it('uses non-atomic mode when atomic is false', () => { const result = generateEIP7702BatchTransaction( ADDRESS_MOCK, @@ -713,6 +753,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_NON_ATOMIC_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); }); From 15bd40450683bdaf7be9f6a83ccd8c544115120a Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 03:12:25 +0200 Subject: [PATCH 25/28] test: achieve 100% coverage for relay-quotes, feature-flags, and fiat-submit-with-calldata --- .../fiat/fiat-submit-with-calldata.test.ts | 4 + .../fiat/fiat-submit-with-calldata.ts | 14 +- .../src/strategy/relay/relay-quotes.test.ts | 129 +++++++++++++++++- .../src/utils/feature-flags.test.ts | 45 ++++++ 4 files changed, 189 insertions(+), 3 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts index f90eabccc5..20e7038e3e 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts @@ -12,6 +12,7 @@ import { getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, } from '../../utils/feature-flags'; +import { getNetworkClientId } from '../../utils/provider'; import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; @@ -20,6 +21,7 @@ import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/provider'); jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -133,6 +135,7 @@ describe('submitWithCalldataReEncoding', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getTransactionMock = jest.mocked(getTransaction); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const updateTransactionMock = jest.mocked(updateTransaction); const getFiatFeeReserveMultiplierMock = jest.mocked( getFiatFeeReserveMultiplier, @@ -146,6 +149,7 @@ describe('submitWithCalldataReEncoding', () => { getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); getTransactionMock.mockReturnValue(TRANSACTION_MOCK); + getNetworkClientIdMock.mockReturnValue('polygon-mainnet'); getFiatFeeReserveMultiplierMock.mockReturnValue(1); getFiatMaxRateDriftPercentMock.mockReturnValue(10); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts index 3e57317e6e..6286a4bd1d 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts @@ -8,6 +8,7 @@ import { getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, } from '../../utils/feature-flags'; +import { getNetworkClientId } from '../../utils/provider'; import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; @@ -137,11 +138,22 @@ export async function submitWithCalldataReEncoding({ targetAmountMinimum: adjustedTargetRaw, }; + // The transaction's chainId is the target chain (e.g. Monad 0x8f), but the + // relay deposit executes on the source chain (e.g. Ethereum 0x1). + // Override chainId and networkClientId so that processTransactions / + // getDelegationTransaction operate on the correct (source) chain. + const { sourceChainId } = baseRequest; + const sourceNetworkClientId = getNetworkClientId(messenger, sourceChainId); + const relayQuotes = await getRelayQuotes({ accountSupports7702: request.accountSupports7702, messenger, requests: [relayRequest], - transaction: updatedTransaction, + transaction: { + ...updatedTransaction, + chainId: sourceChainId, + networkClientId: sourceNetworkClientId, + }, }); if (!relayQuotes.length) { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index d76837ce85..c45d4f375a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -1270,6 +1270,44 @@ describe('Relay Quotes Utils', () => { expect(result[0].original.metamask.is7702).toBe(true); }); + it('defaults data and value for original tx when not present on txParams', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [80000], + totalGasEstimate: 80000, + totalGasLimit: 80000, + }); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + }, + } as TransactionMeta, + }); + + const batchCall = estimateGasBatchMock.mock.calls[0][0]; + const originalTxParams = batchCall.transactions[0]; + expect(originalTxParams.data).toBe('0x'); + expect(originalTxParams.value).toBe('0'); + }); + it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { successfulFetchMock.mockResolvedValue({ ok: true, @@ -1289,11 +1327,98 @@ describe('Relay Quotes Utils', () => { transaction: TRANSACTION_META_MOCK, }); - // With no txParams.to the original tx should be skipped, so only - // the relay step params are sent to gas estimation (single path). expect(estimateGasBatchMock).not.toHaveBeenCalled(); }); + it('falls back to legacy gas combining when txParams.to is missing but gas is present for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + gas: '0x13498', + }, + } as TransactionMeta, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 79000, 21000, + ]); + expect(result[0].original.metamask.is7702).toBe(false); + }); + + it('falls back to legacy 7702 gas combining when txParams.to is missing for post-quote with multi-step relay', async () => { + const multiStepQuote = { + ...QUOTE_MOCK, + steps: [ + { + ...STEP_MOCK, + items: [ + STEP_MOCK.items[0], + { + ...STEP_MOCK.items[0], + data: { + ...STEP_MOCK.items[0].data, + gas: '30000', + }, + }, + ], + }, + ], + } as RelayQuote; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => multiStepQuote, + } as never); + + getGasBufferMock.mockReturnValue(1); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + gas: '0x13498', + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); + expect(result[0].original.metamask.is7702).toBe(true); + }); + it('uses refundTo as from for single gas estimation in predictWithdraw post-quote', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); delete quoteMock.steps[0].items[0].data.gas; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 5db255055e..183f6997a8 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -19,6 +19,7 @@ import { getFiatAssetPerTransactionType, getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, + getPostQuoteGasBuffer, DEFAULT_RELAY_EXECUTE_URL, getServerPollingInterval, getServerPollingTimeout, @@ -1605,4 +1606,48 @@ describe('Feature Flags Utils', () => { expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); }); }); + + describe('getPostQuoteGasBuffer', () => { + it('returns 1.1 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + + it('returns the configured value from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: 1.5 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.5); + }); + + it('returns 1.1 when value is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: 0 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + + it('returns 1.1 when value is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: -1 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + }); }); From 8a99f76f81a1705a7b9d43d7627352d16ff9edca Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 09:20:47 +0200 Subject: [PATCH 26/28] revert: remove unvalidated chainId/networkClientId override in fiat-submit-with-calldata --- .../fiat/fiat-submit-with-calldata.test.ts | 4 ---- .../src/strategy/fiat/fiat-submit-with-calldata.ts | 14 +------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts index 20e7038e3e..f90eabccc5 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts @@ -12,7 +12,6 @@ import { getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, } from '../../utils/feature-flags'; -import { getNetworkClientId } from '../../utils/provider'; import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; @@ -21,7 +20,6 @@ import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); -jest.mock('../../utils/provider'); jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -135,7 +133,6 @@ describe('submitWithCalldataReEncoding', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getTransactionMock = jest.mocked(getTransaction); - const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const updateTransactionMock = jest.mocked(updateTransaction); const getFiatFeeReserveMultiplierMock = jest.mocked( getFiatFeeReserveMultiplier, @@ -149,7 +146,6 @@ describe('submitWithCalldataReEncoding', () => { getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); getTransactionMock.mockReturnValue(TRANSACTION_MOCK); - getNetworkClientIdMock.mockReturnValue('polygon-mainnet'); getFiatFeeReserveMultiplierMock.mockReturnValue(1); getFiatMaxRateDriftPercentMock.mockReturnValue(10); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts index 6286a4bd1d..3e57317e6e 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts @@ -8,7 +8,6 @@ import { getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, } from '../../utils/feature-flags'; -import { getNetworkClientId } from '../../utils/provider'; import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; @@ -138,22 +137,11 @@ export async function submitWithCalldataReEncoding({ targetAmountMinimum: adjustedTargetRaw, }; - // The transaction's chainId is the target chain (e.g. Monad 0x8f), but the - // relay deposit executes on the source chain (e.g. Ethereum 0x1). - // Override chainId and networkClientId so that processTransactions / - // getDelegationTransaction operate on the correct (source) chain. - const { sourceChainId } = baseRequest; - const sourceNetworkClientId = getNetworkClientId(messenger, sourceChainId); - const relayQuotes = await getRelayQuotes({ accountSupports7702: request.accountSupports7702, messenger, requests: [relayRequest], - transaction: { - ...updatedTransaction, - chainId: sourceChainId, - networkClientId: sourceNetworkClientId, - }, + transaction: updatedTransaction, }); if (!relayQuotes.length) { From 89060943309d0901b312e4fd71161b922d52acf3 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 09:27:38 +0200 Subject: [PATCH 27/28] fix: detect Polygon native token for post-quote gas subtraction --- .../src/strategy/relay/relay-quotes.test.ts | 55 ++++++++++++++++++- .../src/strategy/relay/relay-quotes.ts | 4 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index c45d4f375a..c79aef0980 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -227,6 +227,7 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); + getNativeTokenMock.mockReturnValue(NATIVE_TOKEN_ADDRESS); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); @@ -1305,7 +1306,7 @@ describe('Relay Quotes Utils', () => { const batchCall = estimateGasBatchMock.mock.calls[0][0]; const originalTxParams = batchCall.transactions[0]; expect(originalTxParams.data).toBe('0x'); - expect(originalTxParams.value).toBe('0'); + expect(originalTxParams.value).toBe('0x0'); }); it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { @@ -1952,6 +1953,58 @@ describe('Relay Quotes Utils', () => { ); }); + it('subtracts gas for Polygon native token address (0x1010) in post-quote flows', async () => { + const phase2Mock = { + ...QUOTE_MOCK, + details: { + ...QUOTE_MOCK.details, + currencyOut: { + ...QUOTE_MOCK.details.currencyOut, + amountFormatted: '0.8', + amountUsd: '0.80', + }, + }, + }; + + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => phase2Mock, + } as never); + + calculateGasCostMock.mockReturnValue({ + fiat: '0.50', + human: '0.5', + raw: '500000000000000', + usd: '0.50', + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + sourceChainId: '0x89' as Hex, + sourceTokenAddress: + '0x0000000000000000000000000000000000001010' as Hex, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].targetAmount).toStrictEqual( + expect.objectContaining({ usd: expect.any(String) }), + ); + }); + it('returns phase 1 quote when gas cost exceeds source amount for post-quote flows', async () => { successfulFetchMock.mockResolvedValue({ ok: true, 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 7ed79e891d..61c290dd51 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -150,7 +150,9 @@ async function getQuoteWithPostQuoteGasHandling( // the native gas token (gas comes from the same pool as the swap value). const isSourceNative = request.sourceTokenAddress.toLowerCase() === - NATIVE_TOKEN_ADDRESS.toLowerCase(); + NATIVE_TOKEN_ADDRESS.toLowerCase() || + request.sourceTokenAddress.toLowerCase() === + getNativeToken(request.sourceChainId).toLowerCase(); if (!phase1Quote.fees.isSourceGasFeeToken && !isSourceNative) { return phase1Quote; From b013078771e7eef20eba5109a7e0541f352e2584 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 5 Jun 2026 09:33:04 +0200 Subject: [PATCH 28/28] fix: skip batch gas estimation when accountOverride diverges from txParams.from --- .../src/strategy/relay/relay-quotes.test.ts | 33 +++++++++++++++++++ .../src/strategy/relay/relay-quotes.ts | 7 ++++ 2 files changed, 40 insertions(+) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index c79aef0980..9d9912d5ea 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -1309,6 +1309,39 @@ describe('Relay Quotes Utils', () => { expect(originalTxParams.value).toBe('0x0'); }); + it('skips original tx in batch estimation when accountOverride diverges from txParams.from', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + from: '0xOverrideEOA' as Hex, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: '0xMoneyAccount' as Hex, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + }); + it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { successfulFetchMock.mockResolvedValue({ ok: true, 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 61c290dd51..e49d2d2ea2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -953,6 +953,13 @@ function getOriginalTxGasParams( return undefined; } + const hasAccountOverride = + request.from.toLowerCase() !== (txParams.from as Hex).toLowerCase(); + + if (hasAccountOverride) { + return undefined; + } + const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; const gas = nestedGas ?? txParams.gas;