diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 42f8f96d98..c3367c5d0b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,15 +9,22 @@ 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 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)) + +### 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)) 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..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']; @@ -128,6 +133,7 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction | TransactionPayControllerGetDelegationTransactionAction + | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetPaymentOverrideDataAction | TransactionPayControllerGetStrategyAction | TransactionPayControllerPolymarketGetDepositWalletAddressAction 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/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index cc6e9f7a3f..3a8842f962 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,12 @@ 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/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index d86cc937ab..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 @@ -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,13 +266,14 @@ 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', }); }); - 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: { @@ -287,18 +303,12 @@ describe('submitFiatQuotes', () => { expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ - isMaxAmount: true, - isPostQuote: false, + isMaxAmount: false, + isPostQuote: true, sourceBalanceRaw: '1234500000000000000', sourceTokenAmount: '1234500000000000000', }), ]); - expect( - getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data, - ).toBeUndefined(); - expect( - getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, - ).toBeUndefined(); expect(submitRelayQuotesMock).toHaveBeenCalledWith( expect.objectContaining({ quotes: [RELAY_QUOTE_RESULT_MOCK], @@ -307,6 +317,77 @@ describe('submitFiatQuotes', () => { 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({ + 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(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), + ); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + it('persists fiat order metadata on the transaction before polling', async () => { const { request } = getRequest(); @@ -496,6 +577,10 @@ describe('submitFiatQuotes', () => { return getOrderCallCount === 1 ? pendingOrder : completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -549,6 +634,10 @@ describe('submitFiatQuotes', () => { return completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + throw new Error(`Unexpected action: ${action}`); }); @@ -668,10 +757,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 +771,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,11 +790,34 @@ 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('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(); @@ -706,6 +826,255 @@ describe('submitFiatQuotes', () => { ); }); + 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(); 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..05b0517f83 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, @@ -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) { @@ -169,51 +171,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. * @@ -317,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, @@ -326,22 +284,65 @@ async function submitRelayAfterFiatCompletion({ walletAddress, }); - const baseRequest = quotes[0].request; + 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: true, - isPostQuote: false, + isMaxAmount: false, + isPostQuote: true, sourceBalanceRaw: sourceAmountRaw, sourceTokenAmount: sourceAmountRaw, }; - log('Re-quoting relay from completed fiat order', { - completedOrderAmount: order.cryptoAmount, - relayRequest, - sourceAmountRaw, - transactionId, - }); - const relayQuotes = await getRelayQuotes({ accountSupports7702: request.accountSupports7702, messenger, @@ -353,32 +354,208 @@ async function submitRelayAfterFiatCompletion({ throw new Error('No relay quotes returned for completed fiat order'); } - const originalRelayQuote = quotes[0].original.relayQuote; - validateRelaySlippage({ - originalTargetRaw: originalRelayQuote.details.currencyOut.amount, - reQuotedTargetRaw: relayQuotes[0].original.details.currencyOut.amount, + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: relayQuotes[0].original, transactionId, }); - log('Received relay quotes for completed fiat order', { + log('Submitting simple relay after fiat settlement', { relayQuoteCount: relayQuotes.length, transactionId, }); - const relaySubmitRequest: PayStrategyExecuteRequest = { + 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 relayResult = await submitRelayQuotes(relaySubmitRequest); + 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, + }); - log('Relay submission completed after fiat order', { - relayResult, + 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, }); - return relayResult; + 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/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; diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index e332b63285..603892e619 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -88,6 +88,7 @@ export async function updateQuotes( isPolymarketDepositWallet, paymentOverride, paymentToken: originalPaymentToken, + fiatPayment, refundTo, sourceAmounts, tokens, @@ -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.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 7726b19c6b..9fa93632b2 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -179,21 +179,39 @@ describe('Totals Utils', () => { expect(result.total.usd).toBe('71.68'); }); - it('returns adjusted total using targetAmount when fiat strategy quote is present', () => { + 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('65.5'); - expect(result.total.usd).toBe('71.68'); + 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', () => { diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 7b8bccf020..6297819179 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -16,6 +16,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. @@ -24,12 +25,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,26 +79,36 @@ export function calculateTotals({ const amountUsd = sumProperty(quoteTokens, (token) => token.amountUsd); const hasQuotes = quotes.length > 0; + const sourceAmountFiat = getSourceAmount({ + hasFiatStrategy, + fiatPaymentAmount, + isMaxAmount, + hasQuotes, + targetAmount: targetAmount.fiat, + tokenAmount: amountFiat, + }); + + const sourceAmountUsd = getSourceAmount({ + 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( - (hasFiatStrategy || isMaxAmount) && hasQuotes - ? targetAmount.fiat - : amountFiat, - ) + .plus(sourceAmountFiat) .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(sourceAmountUsd) .toString(10); const estimatedDuration = Number( @@ -133,6 +146,44 @@ export function calculateTotals({ }; } +/** + * Get the source 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 getSourceAmount({ + 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. *