From 9d9b6c2ef80edc4c3ff5593d7d6a1039bd43daef Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:23:23 +0200 Subject: [PATCH 01/18] 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 0560a354d208265f23b7f94e96b89f3e709712c6 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:23:31 +0200 Subject: [PATCH 02/18] 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 753ef243a8..5625c88fec 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, @@ -161,51 +161,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. * @@ -319,39 +274,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, @@ -362,7 +369,7 @@ async function submitRelayAfterFiatCompletion({ isSmartTransaction: request.isSmartTransaction, messenger, quotes: relayQuotes, - transaction, + transaction: updatedTransaction, }; const relayResult = await submitRelayQuotes(relaySubmitRequest); @@ -374,3 +381,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 00d4db95df17120c4f53042ab1fb3908cbde2621 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:53:00 +0200 Subject: [PATCH 03/18] 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 f3ea32cb8a..67821961dc 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], @@ -471,6 +490,10 @@ describe('submitFiatQuotes', () => { return getOrderCallCount === 1 ? pendingOrder : completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -524,6 +547,10 @@ describe('submitFiatQuotes', () => { return completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -643,10 +670,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); @@ -654,13 +684,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, }, @@ -668,19 +703,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 5625c88fec..2671853a74 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -329,7 +329,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)}`; } }, ); @@ -390,6 +390,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 89efa73389eb027d321cb7ef5cbbebac944b2f83 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:55:40 +0200 Subject: [PATCH 04/18] 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 2671853a74..c5defa70c1 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -406,8 +406,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 761cebf413da5325043709fcba6ac31da2411bfc Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 14:58:05 +0200 Subject: [PATCH 05/18] docs: update transaction-pay-controller changelog --- packages/transaction-pay-controller/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index b19daaa961..0bea4f7cbb 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,11 +9,26 @@ 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)) + +### 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/assets-controllers` from `^108.3.0` to `^108.4.0` ([#8981](https://github.com/MetaMask/core/pull/8981)) - Bump `@metamask/assets-controller` from `^8.0.2` to `^8.3.1` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) From babbeb7e69386c0b29ee87101968b9e1832d295c Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:10:20 +0200 Subject: [PATCH 06/18] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0bea4f7cbb..6d28d12aed 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -20,15 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) -### 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)) - +- Fix fiat `moneyAccountDeposit` failing by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.4.0` ([#8981](https://github.com/MetaMask/core/pull/8981)) - Bump `@metamask/assets-controller` from `^8.0.2` to `^8.3.1` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) From 882fc80500857aac9b4bb81f34fbda7c00e043c8 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:15:07 +0200 Subject: [PATCH 07/18] 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 67821961dc..e59ed9197e 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 @@ -707,6 +707,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 c5defa70c1..3db2c11850 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -440,7 +440,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 8ca96e82ddc81705eb611663f41cf8f88a3ac489 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:19:02 +0200 Subject: [PATCH 08/18] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 2 -- .../TransactionPayController-method-action-types.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6d28d12aed..f2f033e997 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,14 +11,12 @@ 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. ### 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)) ### Fixed 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 3cf707fc45ec6e9b3069c6b32b677d12087d8896 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 15:51:20 +0200 Subject: [PATCH 09/18] Changelog update --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 31dfeadde1..03d69566a5 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -17,10 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Fix fiat `moneyAccountDeposit` failing by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) ### Fixed -- Fix fiat `moneyAccountDeposit` failing by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.4.0` ([#8981](https://github.com/MetaMask/core/pull/8981)) - Bump `@metamask/assets-controller` from `^8.0.2` to `^8.3.1` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) From 55fb0718a72e7ec62780cd05e1c9143111778a3a Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 16:00:48 +0200 Subject: [PATCH 10/18] 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 e59ed9197e..ceddefe504 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 @@ -804,7 +804,7 @@ describe('submitFiatQuotes', () => { transaction: nestedTransaction, }); - callMock.mockImplementation((action: string, ...args: unknown[]) => { + callMock.mockImplementation((action: string) => { if (action === 'TransactionPayController:getState') { return { transactionData: { @@ -848,11 +848,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 f242520fcd5900e502e274616aa95f396c0e1794 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 19:06:31 +0200 Subject: [PATCH 11/18] 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 573d18116f57be09309530232c25557cd0eaeb86 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 20:47:03 +0200 Subject: [PATCH 12/18] 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 3db2c11850..52148d2c53 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) { @@ -264,7 +266,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, @@ -273,8 +276,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 2333bcd22c0117f3d6562893d33b6134c2d18bc4 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 22:53:11 +0200 Subject: [PATCH 13/18] 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 87158d1b53d9a1526457cb4df7be49677a2afb25 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 23:02:04 +0200 Subject: [PATCH 14/18] 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 52cc7cbec13b21aa2390e3f294f67354a9fd6e0f Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 3 Jun 2026 23:24:46 +0200 Subject: [PATCH 15/18] 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 830404e40bf78829ad12b71937154da5a46f5240 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 00:02:29 +0200 Subject: [PATCH 16/18] 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 7e8e999cd4cab2bfa8237f1901f628080c0bf881 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 00:06:47 +0200 Subject: [PATCH 17/18] 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 1f321acd814eb425f293bcfa3c89b6cd26806103 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 4 Jun 2026 13:26:00 +0200 Subject: [PATCH 18/18] 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 | 6 ++---- .../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, 10 insertions(+), 13 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 03d69566a5..9d7219e411 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,15 +9,13 @@ 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. ### 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)) -- Fix fiat `moneyAccountDeposit` failing by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) +- Fiat submit now uses a three-phase relay flow after on-ramp settlement: discovery quote, calldata update via `getAmountData`, then delegation quote; also fixes fiat `moneyAccountDeposit` failing by using `isMaxAmount: false` in the re-quote ([#8987](https://github.com/MetaMask/core/pull/8987)) ### Fixed 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,