diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 65568276ad..3a55773b09 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added required `AllowedActions`: `GasFeeController:fetchGasFeeEstimates`, `KeyringController:signTransaction`, `NetworkController:getEIP1559Compatibility`, `NetworkController:getNetworkClientRegistry`, `NetworkController:getState` - Removed resubmit logic from `PendingTransactionTracker` +### Fixed + +- Fix EIP-7702 batch transactions not forwarding native value from nested calls ([#8987](https://github.com/MetaMask/core/pull/8987)) + - `generateEIP7702BatchTransaction` now sums the `value` fields of all nested calls and sets the total as the top-level transaction value. Previously the top-level value was always omitted, causing batches that include native token transfers (e.g. POL swaps) to revert. + ## [66.0.1] ### Changed diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 155eb007ee..91ec34cbc0 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -627,6 +627,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); @@ -665,6 +666,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); @@ -689,9 +691,47 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); + it('omits value when all nested calls have zero value', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [ + { + data: '0x1234', + to: ADDRESS_2_MOCK, + value: '0x0', + }, + { + data: '0x9abc', + to: ADDRESS_3_MOCK, + }, + ]); + + expect(result).toStrictEqual({ + data: expect.any(String), + to: ADDRESS_MOCK, + }); + expect(result.value).toBeUndefined(); + }); + + it('sums values from nested calls with mixed zero and non-zero values', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [ + { + data: '0x1234', + to: ADDRESS_2_MOCK, + value: '0x0', + }, + { + data: '0x9abc', + to: ADDRESS_3_MOCK, + value: '0x5678', + }, + ]); + + expect(result.value).toBe('0x5678'); + }); + it('uses non-atomic mode when atomic is false', () => { const result = generateEIP7702BatchTransaction( ADDRESS_MOCK, @@ -713,6 +753,7 @@ describe('EIP-7702 Utils', () => { expect(result).toStrictEqual({ data: DATA_NON_ATOMIC_MOCK, to: ADDRESS_MOCK, + value: '0x13568', }); }); }); diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 91b39c390d..672fc6825a 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -205,9 +205,15 @@ export function generateEIP7702BatchTransaction( log('Transaction data', data); + const totalValue = transactions.reduce( + (sum, tx) => sum + BigInt(tx.value ?? '0x0'), + BigInt(0), + ); + return { data, to: from, + ...(totalValue > BigInt(0) ? { value: toHex(totalValue) } : {}), }; } diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0b298b70d4..3d2e2263ba 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,15 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Adding processing for postQuote transactions with paymentOverride defined ([#8967](https://github.com/MetaMask/core/pull/8967)) +- 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 with fee-as-buffer strategy after on-ramp settlement, and simple deposits (Perps, Predict) skip to a single EXACT_INPUT relay quote for cheaper fees ([#8987](https://github.com/MetaMask/core/pull/8987)) - Fiat quote submission now treats the provider code (e.g. `transak-native`) as the canonical form when resolving the provider from a ramps quote, while continuing to accept the legacy path form (e.g. `/providers/transak-native`) for backwards compatibility ([#9004](https://github.com/MetaMask/core/pull/9004)) - Live token balance queries now respect the `confirmations_pay_extended.excludeChainIdsFromInfura` feature flag, skipping the Infura endpoint preference for excluded chains ([#8992](https://github.com/MetaMask/core/pull/8992)) - Bump `@metamask/assets-controllers` from `^108.3.0` to `^108.5.0` ([#8981](https://github.com/MetaMask/core/pull/8981), [#8999](https://github.com/MetaMask/core/pull/8999)) - 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 after on-ramp settlement by adding `getAmountData` callback for calldata re-encoding and correcting wallet address, quote amount, and slippage validation ([#8987](https://github.com/MetaMask/core/pull/8987)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) - Bump `@metamask/ramps-controller` from `^14.1.0` to `^14.1.1` ([#8989](https://github.com/MetaMask/core/pull/8989)) - Bump `@metamask/bridge-status-controller` from `^72.0.0` to `^72.0.2` ([#8990](https://github.com/MetaMask/core/pull/8990), [#8999](https://github.com/MetaMask/core/pull/8999)) diff --git a/packages/transaction-pay-controller/src/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-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 75a9163493..668dca896a 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-simple.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts new file mode 100644 index 0000000000..1622bc6c39 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts @@ -0,0 +1,256 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { getFiatMaxRateDriftPercent } from '../../utils/feature-flags'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import { submitSimpleRelay } from './fiat-submit-simple'; +import type { FiatQuote } from './types'; + +jest.mock('../../utils/feature-flags'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: WALLET_ADDRESS_MOCK }, + type: TransactionType.predictDeposit, +} as TransactionMeta; + +const BASE_QUOTE_REQUEST_MOCK: QuoteRequest = { + from: WALLET_ADDRESS_MOCK, + sourceBalanceRaw: '1000000000000000000', + sourceChainId: '0x89', + sourceTokenAddress: '0x0000000000000000000000000000000000001010', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '12000000', + targetChainId: '0x89', + targetTokenAddress: '0x2222222222222222222222222222222222222222', +}; + +const RELAY_QUOTE_MOCK = { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { fiat: '0', human: '0', raw: '0', usd: '0' }, + max: { fiat: '0', human: '0', raw: '0', usd: '0' }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + request: BASE_QUOTE_REQUEST_MOCK, + sourceAmount: { fiat: '0', human: '0', raw: '0', usd: '0' }, + strategy: TransactionPayStrategy.Relay, + targetAmount: { fiat: '0', usd: '0' }, +} as TransactionPayQuote; + +function buildFiatQuote(): TransactionPayQuote { + return { + ...RELAY_QUOTE_MOCK, + original: { + rampsQuote: {} as never, + relayQuote: RELAY_QUOTE_MOCK.original as unknown as RelayQuote, + }, + strategy: TransactionPayStrategy.Fiat, + } as unknown as TransactionPayQuote; +} + +function buildRequest( + overrides?: Partial>, +): PayStrategyExecuteRequest { + return { + accountSupports7702: false, + isSmartTransaction: () => false, + messenger: { call: jest.fn() } as never, + quotes: [buildFiatQuote()], + transaction: TRANSACTION_MOCK, + ...overrides, + }; +} + +describe('submitSimpleRelay', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + const getFiatMaxRateDriftPercentMock = jest.mocked( + getFiatMaxRateDriftPercent, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); + getFiatMaxRateDriftPercentMock.mockReturnValue(10); + }); + + it('builds an EXACT_INPUT relay request with the full settled amount', async () => { + const req = buildRequest(); + + await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '5000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: false, + isPostQuote: true, + skipProcessTransactions: false, + sourceBalanceRaw: '5000000000000000000', + sourceTokenAmount: '5000000000000000000', + }), + ]); + }); + + it('submits relay quotes after rate drift validation', async () => { + const req = buildRequest(); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [RELAY_QUOTE_MOCK], + transaction: TRANSACTION_MOCK, + }), + ); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('throws when relay returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const req = buildRequest(); + + await expect( + submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for completed fiat order'); + }); + + it('throws when rate drift exceeds configured threshold', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(5); + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '10000000', + amountUsd: '2.00', + minimumAmount: '9800000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const req = buildRequest(); + + await expect( + submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow(/Relay rate drift too high/u); + }); + + it('passes rate drift when discovery rate is better than original', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '14000000', + amountUsd: '6.00', + minimumAmount: '13800000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const req = buildRequest(); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('skips rate drift check when original relay amounts are zero', async () => { + const fiatQuote = buildFiatQuote(); + fiatQuote.original.relayQuote = { + details: { + currencyIn: { amount: '0', amountUsd: '0' }, + currencyOut: { amount: '0', amountUsd: '0', minimumAmount: '0' }, + }, + } as unknown as RelayQuote; + + const req = buildRequest({ quotes: [fiatQuote] }); + + const result = await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('reads maxRateDriftPercent from feature flags', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(25); + const req = buildRequest(); + + await submitSimpleRelay({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getFiatMaxRateDriftPercentMock).toHaveBeenCalledWith(req.messenger); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts new file mode 100644 index 0000000000..c763085b5a --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts @@ -0,0 +1,80 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { PayStrategyExecuteRequest, QuoteRequest } from '../../types'; +import { getFiatMaxRateDriftPercent } from '../../utils/feature-flags'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { FiatQuote } from './types'; +import { validateRelayRateDrift } from './utils'; + +const log = createModuleLogger(projectLogger, 'fiat-submit-simple'); + +/** + * Submits a single EXACT_INPUT relay quote for simple deposits + * that don't require nested calldata re-encoding or delegation. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +export async function submitSimpleRelay({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + + const originalRelayQuote = request.quotes[0].original.relayQuote; + + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: true, + skipProcessTransactions: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + }; + + const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [relayRequest], + transaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: relayQuotes[0].original, + maxRateDriftPercent: getFiatMaxRateDriftPercent(messenger), + transactionId, + }); + + log('Submitting simple relay after fiat settlement', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + return await submitRelayQuotes({ + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }); +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts new file mode 100644 index 0000000000..20e7038e3e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.test.ts @@ -0,0 +1,499 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, +} from '../../utils/feature-flags'; +import { getNetworkClientId } from '../../utils/provider'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; +import type { FiatQuote } from './types'; + +jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/provider'); +jest.mock('../../utils/transaction'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: WALLET_ADDRESS_MOCK }, + type: TransactionType.batch, + nestedTransactions: [ + { to: '0xaaa' as Hex, data: '0x1111' as Hex }, + { to: '0xbbb' as Hex, data: '0x2222' as Hex }, + ], +} as unknown as TransactionMeta; + +const BASE_QUOTE_REQUEST_MOCK: QuoteRequest = { + from: WALLET_ADDRESS_MOCK, + sourceBalanceRaw: '1000000000000000000', + sourceChainId: '0x89', + sourceTokenAddress: '0x0000000000000000000000000000000000001010', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '12000000', + targetChainId: '0x89', + targetTokenAddress: '0x2222222222222222222222222222222222222222', +}; + +const RELAY_QUOTE_MOCK = { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { fiat: '0', human: '0', raw: '0', usd: '0' }, + max: { fiat: '0', human: '0', raw: '0', usd: '0' }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '5.00' }, + currencyOut: { + amount: '12000000', + amountUsd: '4.85', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + request: BASE_QUOTE_REQUEST_MOCK, + sourceAmount: { fiat: '0', human: '0', raw: '0', usd: '0' }, + strategy: TransactionPayStrategy.Relay, + targetAmount: { fiat: '0', usd: '0' }, +} as TransactionPayQuote; + +function buildFiatQuote( + relayQuoteOverride?: Partial, +): TransactionPayQuote { + const relayQuote = { + ...(RELAY_QUOTE_MOCK.original as unknown as RelayQuote), + ...relayQuoteOverride, + } as RelayQuote; + return { + ...RELAY_QUOTE_MOCK, + original: { rampsQuote: {} as never, relayQuote }, + strategy: TransactionPayStrategy.Fiat, + } as unknown as TransactionPayQuote; +} + +function buildCallMock({ + getAmountDataUpdates = [ + { nestedTransactionIndex: 0, data: '0xNewApprove' }, + { nestedTransactionIndex: 1, data: '0xNewDeposit' }, + ], + featureFlags = {}, +}: { + getAmountDataUpdates?: { nestedTransactionIndex: number; data: string }[]; + featureFlags?: Record; +} = {}): jest.Mock { + return jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: getAmountDataUpdates }); + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: featureFlags }; + } + throw new Error(`Unexpected action: ${action}`); + }); +} + +function buildRequest({ + callMock = buildCallMock(), + quotes = [buildFiatQuote()], + transaction = TRANSACTION_MOCK, +}: { + callMock?: jest.Mock; + quotes?: TransactionPayQuote[]; + transaction?: TransactionMeta; +} = {}): PayStrategyExecuteRequest { + return { + accountSupports7702: false, + isSmartTransaction: () => false, + messenger: { call: callMock } as never, + quotes, + transaction, + }; +} + +describe('submitWithCalldataReEncoding', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + const getTransactionMock = jest.mocked(getTransaction); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const updateTransactionMock = jest.mocked(updateTransaction); + const getFiatFeeReserveMultiplierMock = jest.mocked( + getFiatFeeReserveMultiplier, + ); + const getFiatMaxRateDriftPercentMock = jest.mocked( + getFiatMaxRateDriftPercent, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); + getTransactionMock.mockReturnValue(TRANSACTION_MOCK); + getNetworkClientIdMock.mockReturnValue('polygon-mainnet'); + getFiatFeeReserveMultiplierMock.mockReturnValue(1); + getFiatMaxRateDriftPercentMock.mockReturnValue(10); + }); + + it('performs three-phase flow: discovery, calldata re-encoding, delegation quote', async () => { + const request = buildRequest(); + + const result = await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Phase 1: discovery (EXACT_INPUT) + expect(getRelayQuotesMock).toHaveBeenCalledTimes(2); + expect(getRelayQuotesMock.mock.calls[0][0].requests[0]).toStrictEqual( + expect.objectContaining({ + isPostQuote: true, + isMaxAmount: false, + }), + ); + + // Phase 3: delegation (EXACT_OUTPUT) + expect(getRelayQuotesMock.mock.calls[1][0].requests[0]).toStrictEqual( + expect.objectContaining({ + isPostQuote: false, + isMaxAmount: false, + }), + ); + + expect(submitRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('reserves original relay fee from discovery source amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Fee quote: sourceUsd=5.00, targetUsd=4.85, sourceRaw=1e18 + // feeUsd = 0.15, feeReserveRaw = 0.15 / (5.00/1e18) = 3e16 + // discoverySource = 1e18 - 3e16 = 970000000000000000 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('970000000000000000'); + expect(discoveryRequest.sourceBalanceRaw).toBe('1000000000000000000'); + }); + + it('applies fee reserve multiplier from feature flag', async () => { + getFiatFeeReserveMultiplierMock.mockReturnValue(1.5); + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // feeReserveRaw = 3e16 * 1.5 = 4.5e16 + // discoverySource = 1e18 - 4.5e16 = 955000000000000000 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('955000000000000000'); + }); + + it('clamps discovery source to minimum 1 when fee reserve exceeds settled amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '10000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // feeReserve (3e16) > settled (1e16) → clamped to 1 + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1'); + }); + + it('skips fee reserve when original relay quote has zero source USD', async () => { + const fiatQuote = buildFiatQuote({ + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '0' }, + currencyOut: { + amount: '12000000', + amountUsd: '0', + minimumAmount: '11900000', + }, + }, + } as unknown as Partial); + + const request = buildRequest({ quotes: [fiatQuote] }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Zero USD → fee reserve = 0 → full amount used + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1000000000000000000'); + }); + + it('skips fee reserve when original relay fee is not positive', async () => { + const fiatQuote = buildFiatQuote({ + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '4.85' }, + currencyOut: { + amount: '12000000', + amountUsd: '5.00', + minimumAmount: '11900000', + }, + }, + } as unknown as Partial); + + const request = buildRequest({ quotes: [fiatQuote] }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + const discoveryRequest = getRelayQuotesMock.mock.calls[0][0].requests[0]; + expect(discoveryRequest.sourceTokenAmount).toBe('1000000000000000000'); + }); + + it('adds discovery fee back to adjusted target amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + // Discovery returns minimumAmount=11900000 with sourceUsd=5.00, targetUsd=4.85 + // discoveryFeeUsd=0.15, usdPerTargetRaw=4.85/11900000 + // discoveryFeeInTargetRaw=0.15/(4.85/11900000)=368041 + // adjustedTarget=11900000+368041=12268041 + const finalRequest = getRelayQuotesMock.mock.calls[1][0].requests[0]; + expect(finalRequest.targetAmountMinimum).toBe('12268041'); + }); + + it('passes adjusted target to getAmountData', async () => { + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '12268041' }), + ); + }); + + it('updates nested calldata and requiredAssets with adjusted amount', async () => { + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + const settledAmountCall = updateTransactionMock.mock.calls.find( + ([opts]) => opts.note === 'Fiat deposit: update settled amount', + ); + expect(settledAmountCall).toBeDefined(); + + const txDraft = { + nestedTransactions: [ + { to: '0xaaa', data: '0x1111' }, + { to: '0xbbb', data: '0x2222' }, + ], + requiredAssets: [{ address: '0xaaa', amount: '0x0' }], + } as unknown as TransactionMeta; + + const updateFn = settledAmountCall?.[1]; + (updateFn as (tx: TransactionMeta) => void)(txDraft); + + expect(txDraft.nestedTransactions?.[0].data).toBe('0xNewApprove'); + expect(txDraft.nestedTransactions?.[1].data).toBe('0xNewDeposit'); + expect(txDraft.requiredAssets?.[0].amount).toBe('0xbb3209'); + }); + + it('throws when discovery relay returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const request = buildRequest(); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for fiat discovery'); + }); + + it('throws when final relay re-quote returns no quotes', async () => { + getRelayQuotesMock + .mockResolvedValueOnce([RELAY_QUOTE_MOCK]) + .mockResolvedValueOnce([]); + const request = buildRequest(); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No relay quotes returned for completed fiat order'); + }); + + it('throws when getAmountData returns no updates', async () => { + const callMock = buildCallMock({ getAmountDataUpdates: [] }); + const request = buildRequest({ callMock }); + + await expect( + submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow( + 'getAmountData returned no updates for transaction with nested calldata', + ); + }); + + it('falls back to original transaction when getTransaction returns undefined', async () => { + getTransactionMock.mockReturnValue(undefined); + const request = buildRequest(); + + const result = await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ transaction: TRANSACTION_MOCK }), + ); + expect(result).toStrictEqual({ transactionHash: '0xabc' }); + }); + + it('falls back to discovery minimum when discovery quote has zero target USD', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '0' }, + currencyOut: { + amount: '12000000', + amountUsd: '0', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), + ); + }); + + it('falls back to discovery minimum when discovery fee is not positive', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + details: { + currencyIn: { amount: '1000000000000000000', amountUsd: '4.85' }, + currencyOut: { + amount: '12000000', + amountUsd: '5.00', + minimumAmount: '11900000', + }, + }, + } as unknown as RelayQuote, + }, + ]); + + const callMock = buildCallMock(); + const request = buildRequest({ callMock }); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '11900000' }), + ); + }); + + it('reads maxRateDriftPercent from feature flags', async () => { + getFiatMaxRateDriftPercentMock.mockReturnValue(15); + const request = buildRequest(); + + await submitWithCalldataReEncoding({ + baseRequest: BASE_QUOTE_REQUEST_MOCK, + request, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getFiatMaxRateDriftPercentMock).toHaveBeenCalledWith( + request.messenger, + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts new file mode 100644 index 0000000000..b07dd625b6 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-calldata.ts @@ -0,0 +1,253 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { projectLogger } from '../../logger'; +import type { PayStrategyExecuteRequest, QuoteRequest } from '../../types'; +import { + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, +} from '../../utils/feature-flags'; +import { getTransaction, updateTransaction } from '../../utils/transaction'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; +import type { FiatQuote } from './types'; +import { validateRelayRateDrift } from './utils'; + +const log = createModuleLogger(projectLogger, 'fiat-submit-calldata'); + +/** + * Submits relay quotes using the three-phase flow for transactions with nested + * calldata that needs re-encoding (e.g. moneyAccountDeposit with approve + deposit). + * + * Phase 1: Discovery quote (EXACT_INPUT) to learn the target token output. + * Phase 2: Delegate calldata re-encoding to the client via getAmountData. + * Phase 3: Delegation quote (EXACT_OUTPUT) with updated nested transaction data. + * + * @param options - The submission options. + * @param options.baseRequest - The base quote request from the original fiat quote. + * @param options.request - The original fiat strategy execute request. + * @param options.sourceAmountRaw - The settled source amount in atomic units. + * @param options.transaction - The transaction metadata. + * @returns An object containing the relay transaction hash if available. + */ +export async function submitWithCalldataReEncoding({ + baseRequest, + request, + sourceAmountRaw, + transaction, +}: { + baseRequest: QuoteRequest; + request: PayStrategyExecuteRequest; + sourceAmountRaw: string; + transaction: PayStrategyExecuteRequest['transaction']; +}): Promise<{ transactionHash?: Hex }> { + const { messenger } = request; + const transactionId = transaction.id; + + const feeReserveRaw = calculateFeeReserve({ + feeQuote: request.quotes[0].original.relayQuote, + multiplier: getFiatFeeReserveMultiplier(messenger), + }); + + const discoverySourceAmount = BigNumber.max( + new BigNumber(sourceAmountRaw).minus(feeReserveRaw), + 1, + ).toFixed(0); + + log('Fee reserve for discovery', { + feeReserveRaw: feeReserveRaw.toFixed(0), + discoverySourceAmount, + sourceAmountRaw, + transactionId, + }); + + const discoveryRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: discoverySourceAmount, + }; + + const discoveryQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [discoveryRequest], + transaction, + }); + + if (!discoveryQuotes.length) { + throw new Error('No relay quotes returned for fiat discovery'); + } + + const discoveryRelay = discoveryQuotes[0].original; + + const adjustedTargetRaw = calculateAdjustedTarget(discoveryRelay); + + log('Adjusted target for final quote', { + discoveryMinimum: discoveryRelay.details.currencyOut.minimumAmount, + adjustedTargetRaw, + transactionId, + }); + + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { amount: adjustedTargetRaw, transaction }, + ); + + if (!updates.length) { + throw new Error( + 'getAmountData returned no updates for transaction with nested calldata', + ); + } + + updateTransaction( + { transactionId, messenger, note: 'Fiat deposit: update settled amount' }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${new BigNumber(adjustedTargetRaw).toString(16)}`; + } + }, + ); + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: false, + isPostQuote: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + targetAmountMinimum: adjustedTargetRaw, + }; + + const relayQuotes = await getRelayQuotes({ + accountSupports7702: request.accountSupports7702, + messenger, + requests: [relayRequest], + transaction: updatedTransaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + const originalRelayQuote = request.quotes[0].original.relayQuote; + validateRelayRateDrift({ + originalQuote: originalRelayQuote, + discoveryQuote: relayQuotes[0].original, + maxRateDriftPercent: getFiatMaxRateDriftPercent(messenger), + transactionId, + }); + + log('Received relay quotes for completed fiat order', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + return await submitRelayQuotes({ + accountSupports7702: request.accountSupports7702, + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction: updatedTransaction, + }); +} + +/** + * Calculates the fee reserve in raw source token units from the original + * relay fee quote. This reserve is subtracted from the discovery quote's + * source amount so the final EXACT_OUTPUT quote stays within the settled + * balance. + * + * @param options - Calculation options. + * @param options.feeQuote - The original relay quote from the fee/quoting phase. + * @param options.multiplier - Multiplier applied to the fee reserve (default 1). + * @returns The fee reserve in raw source token units. + */ +function calculateFeeReserve({ + feeQuote, + multiplier, +}: { + feeQuote: RelayQuote; + multiplier: number; +}): BigNumber { + const sourceRaw = new BigNumber(feeQuote.details.currencyIn.amount); + const sourceUsd = new BigNumber(feeQuote.details.currencyIn.amountUsd); + const targetUsd = new BigNumber(feeQuote.details.currencyOut.amountUsd); + + if (!sourceUsd.gt(0) || !sourceRaw.gt(0)) { + return new BigNumber(0); + } + + // Fee in USD = what the relay consumed beyond the target value + const feeUsd = sourceUsd.minus(targetUsd); + + if (!feeUsd.gt(0)) { + return new BigNumber(0); + } + + // Convert USD fee to raw source token units using the quote's own rate + const usdPerSourceRaw = sourceUsd.dividedBy(sourceRaw); + + return feeUsd + .dividedBy(usdPerSourceRaw) + .multipliedBy(multiplier) + .decimalPlaces(0, BigNumber.ROUND_UP); +} + +/** + * Calculates the adjusted target amount for the final EXACT_OUTPUT quote + * by adding the discovery quote's fee back to its minimum output. + * + * The discovery quote (EXACT_INPUT) reports a smaller fee than the final + * EXACT_OUTPUT quote will charge. Adding the discovery fee back to the + * minimum output compensates for this differential, ensuring the final + * quote targets a realistic amount. + * + * @param discoveryQuote - The relay quote from the discovery phase. + * @returns The adjusted target amount in raw target token units. + */ +function calculateAdjustedTarget(discoveryQuote: RelayQuote): string { + const targetMinRaw = new BigNumber( + discoveryQuote.details.currencyOut.minimumAmount, + ); + const targetUsd = new BigNumber(discoveryQuote.details.currencyOut.amountUsd); + const sourceUsd = new BigNumber(discoveryQuote.details.currencyIn.amountUsd); + + if (!targetUsd.gt(0) || !targetMinRaw.gt(0)) { + return targetMinRaw.toFixed(0); + } + + // Discovery fee in USD + const discoveryFeeUsd = sourceUsd.minus(targetUsd); + + if (!discoveryFeeUsd.gt(0)) { + return targetMinRaw.toFixed(0); + } + + // Convert fee from USD to raw target token units + const usdPerTargetRaw = targetUsd.dividedBy(targetMinRaw); + const discoveryFeeInTargetRaw = discoveryFeeUsd + .dividedBy(usdPerTargetRaw) + .decimalPlaces(0, BigNumber.ROUND_DOWN); + + const adjusted = targetMinRaw.plus(discoveryFeeInTargetRaw); + + log('calculateAdjustedTarget', { + targetMinRaw: targetMinRaw.toFixed(0), + discoveryFeeUsd: discoveryFeeUsd.toFixed(6), + discoveryFeeInTargetRaw: discoveryFeeInTargetRaw.toFixed(0), + adjustedTargetRaw: adjusted.toFixed(0), + }); + + return adjusted.toFixed(0); +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index d86cc937ab..a75026496f 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 @@ -24,7 +24,11 @@ import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; -jest.mock('./utils'); +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + deriveFiatAssetForFiatPayment: jest.fn(), + resolveSourceAmountRaw: jest.fn(), +})); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); @@ -96,7 +100,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 +172,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 +232,14 @@ function getRequest({ return order; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + throw new Error(`Unexpected action: ${action}`); }); @@ -257,7 +279,7 @@ describe('submitFiatQuotes', () => { }); }); - it('polls completed fiat order then requotes and submits relay', async () => { + it('polls completed fiat order then submits single EXACT_INPUT relay for simple deposits', async () => { const order = getFiatOrderMock({ cryptoAmount: '1.2345', cryptoCurrency: { @@ -287,18 +309,13 @@ describe('submitFiatQuotes', () => { expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ - isMaxAmount: true, - isPostQuote: false, + isMaxAmount: false, + isPostQuote: true, + skipProcessTransactions: false, sourceBalanceRaw: '1234500000000000000', sourceTokenAmount: '1234500000000000000', }), ]); - expect( - getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data, - ).toBeUndefined(); - expect( - getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, - ).toBeUndefined(); expect(submitRelayQuotesMock).toHaveBeenCalledWith( expect.objectContaining({ quotes: [RELAY_QUOTE_RESULT_MOCK], @@ -307,6 +324,80 @@ 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' }, + ], + }); + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + 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: '1198500000000000000', + }), + ]); + expect(getRelayQuotesMock.mock.calls[1][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: false, + isPostQuote: false, + sourceBalanceRaw: '1234500000000000000', + sourceTokenAmount: '1234500000000000000', + targetAmountMinimum: '12268041', + }), + ]); + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.objectContaining({ amount: '12268041' }), + ); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + it('persists fiat order metadata on the transaction before polling', async () => { const { request } = getRequest(); @@ -383,6 +474,9 @@ describe('submitFiatQuotes', () => { }, }; } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -496,6 +590,13 @@ describe('submitFiatQuotes', () => { return getOrderCallCount === 1 ? pendingOrder : completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -549,6 +650,13 @@ describe('submitFiatQuotes', () => { return completedOrder; } + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ updates: [] }); + } + + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } throw new Error(`Unexpected action: ${action}`); }); @@ -667,51 +775,4 @@ describe('submitFiatQuotes', () => { 'Computed fiat order source amount is not positive', ); }); - - it('skips slippage check when original relay target amount is zero', async () => { - const { request } = getRequest(); - request.quotes[0].original.relayQuote = { - details: { currencyOut: { amount: '0' } }, - } as unknown as RelayQuote; - - const result = await submitFiatQuotes(request); - - expect(result).toStrictEqual({ transactionHash: '0x1234' }); - }); - - it('throws if relay re-quote slippage exceeds threshold', async () => { - getRelayQuotesMock.mockResolvedValue([ - { - ...RELAY_QUOTE_RESULT_MOCK, - original: { - details: { - currencyOut: { amount: '10000000' }, - }, - } as unknown as RelayQuote, - }, - ]); - const { request } = getRequest(); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - /Relay re-quote slippage too high/u, - ); - }); - - it('throws if relay re-quote returns no quotes', async () => { - getRelayQuotesMock.mockResolvedValue([]); - const { request } = getRequest(); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - 'No relay quotes returned for completed fiat order', - ); - }); - - it('throws if relay submit fails', async () => { - submitRelayQuotesMock.mockRejectedValue(new Error('Relay submit failed')); - const { request } = getRequest(); - - await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Relay submit failed', - ); - }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 0c82c8c3b6..f340811d82 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -5,29 +5,29 @@ import type { import { RampsOrderStatus } from '@metamask/ramps-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { projectLogger } from '../../logger'; import type { PayStrategy, PayStrategyExecuteRequest, - QuoteRequest, TransactionPayControllerMessenger, } from '../../types'; import { buildCaipAssetType } from '../../utils/token'; import { updateTransaction } from '../../utils/transaction'; -import { getRelayQuotes } from '../relay/relay-quotes'; -import { submitRelayQuotes } from '../relay/relay-submit'; -import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; +import { submitSimpleRelay } from './fiat-submit-simple'; +import { submitWithCalldataReEncoding } from './fiat-submit-with-calldata'; import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; +import { + deriveFiatAssetForFiatPayment, + extractProviderCode, + resolveSourceAmountRaw, +} from './utils'; const log = createModuleLogger(projectLogger, 'fiat-submit'); const ORDER_POLL_INTERVAL_MS = 1000; const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; -const MAX_SLIPPAGE_PERCENT = 5; 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) { @@ -106,29 +108,6 @@ export async function submitFiatQuotes( return await submitRelayAfterFiatCompletion({ order, request }); } -/** - * Extracts the provider code from a ramps provider string. - * - * Accepts the canonical provider code (e.g. `transak-native`) and, for - * backwards compatibility, the legacy path form (e.g. `/providers/transak-native`). - * - * @param provider - Canonical provider code, or legacy provider path. - * @returns The provider code, or `null` if the format is invalid. - */ -function extractProviderCode(provider: string | undefined): string | null { - if (!provider) { - return null; - } - - const parts = provider.split('/').filter(Boolean); - - if (parts[0] === 'providers') { - return parts[1] ?? null; - } - - return parts.length === 1 ? parts[0] : null; -} - /** * Validates that the completed order's crypto asset matches the expected fiat asset. * @@ -169,51 +148,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 +251,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,59 +261,26 @@ async function submitRelayAfterFiatCompletion({ walletAddress, }); - const baseRequest = quotes[0].request; - const relayRequest: QuoteRequest = { - ...baseRequest, - isMaxAmount: true, - isPostQuote: false, - sourceBalanceRaw: sourceAmountRaw, - sourceTokenAmount: sourceAmountRaw, - }; - - log('Re-quoting relay from completed fiat order', { - completedOrderAmount: order.cryptoAmount, - relayRequest, - sourceAmountRaw, - transactionId, - }); - - const relayQuotes = await getRelayQuotes({ - accountSupports7702: request.accountSupports7702, - messenger, - requests: [relayRequest], - transaction, - }); - - if (!relayQuotes.length) { - throw new Error('No relay quotes returned for completed fiat order'); + 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, + }); } - const originalRelayQuote = quotes[0].original.relayQuote; - validateRelaySlippage({ - originalTargetRaw: originalRelayQuote.details.currencyOut.amount, - reQuotedTargetRaw: relayQuotes[0].original.details.currencyOut.amount, - transactionId, - }); - - log('Received relay quotes for completed fiat order', { - relayQuoteCount: relayQuotes.length, - transactionId, - }); - - const relaySubmitRequest: PayStrategyExecuteRequest = { - accountSupports7702: request.accountSupports7702, - isSmartTransaction: request.isSmartTransaction, - messenger, - quotes: relayQuotes, + return await submitSimpleRelay({ + baseRequest, + request, + sourceAmountRaw, transaction, - }; - - const relayResult = await submitRelayQuotes(relaySubmitRequest); - - log('Relay submission completed after fiat order', { - relayResult, - transactionId, }); - - return relayResult; } diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index c3615e1dab..253da904b7 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -10,6 +10,7 @@ import type { TransactionPayControllerMessenger } from '../../types'; import { getFiatAssetPerTransactionType } from '../../utils/feature-flags'; import { getTokenInfo } from '../../utils/token'; import { getTransferredAmountFromTxHash } from '../../utils/transaction'; +import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; @@ -138,3 +139,98 @@ export function getRawSourceAmountFromOrderCryptoAmount({ return rawAmount; } + +/** + * Validates that the relay exchange rate hasn't drifted significantly between + * the original quoting phase and the post-settlement discovery quote. + * + * Compares the USD output/input ratio from both quotes. This normalises for + * different source amounts (quoting phase uses a theoretical amount, discovery + * uses the actual settled amount) so the comparison reflects genuine rate + * movement rather than amount differences. + * + * @param options - The validation options. + * @param options.originalQuote - Relay quote from the original quoting phase. + * @param options.discoveryQuote - Relay quote from the post-settlement discovery. + * @param options.maxRateDriftPercent - Maximum allowed rate drift percentage. + * @param options.transactionId - Transaction ID for error reporting. + */ +export function validateRelayRateDrift({ + originalQuote, + discoveryQuote, + maxRateDriftPercent, + transactionId, +}: { + originalQuote: RelayQuote; + discoveryQuote: RelayQuote; + maxRateDriftPercent: number; + transactionId: string; +}): void { + const originalIn = new BigNumber(originalQuote.details.currencyIn.amountUsd); + const originalOut = new BigNumber( + originalQuote.details.currencyOut.amountUsd, + ); + const discoveryIn = new BigNumber( + discoveryQuote.details.currencyIn.amountUsd, + ); + const discoveryOut = new BigNumber( + discoveryQuote.details.currencyOut.amountUsd, + ); + + if ( + !originalIn.gt(0) || + !originalOut.gt(0) || + !discoveryIn.gt(0) || + !discoveryOut.gt(0) + ) { + return; + } + + const originalRate = originalOut.dividedBy(originalIn); + const discoveryRate = discoveryOut.dividedBy(discoveryIn); + + const driftPercent = originalRate + .minus(discoveryRate) + .dividedBy(originalRate) + .multipliedBy(100); + + log('Relay rate drift check', { + originalRate: originalRate.toFixed(6), + discoveryRate: discoveryRate.toFixed(6), + driftPercent: driftPercent.toFixed(2), + maxRateDriftPercent, + transactionId, + }); + + if (driftPercent.gt(maxRateDriftPercent)) { + throw new Error( + `Relay rate drift too high for transaction ` + + `${driftPercent.toFixed(2)}% exceeds ${maxRateDriftPercent}% max`, + ); + } +} + +/** + * Extracts the provider code from a ramps provider string. + * + * Accepts the canonical provider code (e.g. `transak-native`) and, for + * backwards compatibility, the legacy path form (e.g. `/providers/transak-native`). + * + * @param provider - Canonical provider code, or legacy provider path. + * @returns The provider code, or `null` if the format is invalid. + */ +export function extractProviderCode( + provider: string | undefined, +): string | null { + if (!provider) { + return null; + } + + const parts = provider.split('/').filter(Boolean); + + if (parts[0] === 'providers') { + return parts[1] ?? null; + } + + return parts.length === 1 ? parts[0] : null; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 9b1639f7d3..070d6fef31 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -229,6 +229,7 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); + getNativeTokenMock.mockReturnValue(NATIVE_TOKEN_ADDRESS); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); @@ -931,12 +932,18 @@ describe('Relay Quotes Utils', () => { expect(body.refundTo).toBeUndefined(); }); - it('estimates only relay transactions for post-quote', async () => { + it('includes original transaction in batch gas estimation for post-quote', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [100000], + totalGasEstimate: 100000, + totalGasLimit: 100000, + }); + const postQuoteTransaction = { ...TRANSACTION_META_MOCK, chainId: '0x1' as Hex, @@ -962,12 +969,16 @@ describe('Relay Quotes Utils', () => { transaction: postQuoteTransaction, }); - // Original transaction should NOT be included in gas estimation. - // Only relay step params are estimated. - expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ data: '0xaaa' }), + ]), + }), + ); }); - it('adds original transaction gas to single relay gas limit for post-quote', async () => { + it('returns combined 7702 gas limit for post-quote with original tx', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, @@ -975,6 +986,12 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [100000], + totalGasEstimate: 100000, + totalGasLimit: 100000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -998,13 +1015,11 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - expect(result[0].original.metamask.gasLimits).toStrictEqual([ - 79000, 21000, - ]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toStrictEqual([100000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => { + it('prefers nestedTransactions gas over txParams.gas for post-quote batch estimation', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, @@ -1012,6 +1027,12 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [71000], + totalGasEstimate: 71000, + totalGasLimit: 71000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -1036,13 +1057,19 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - expect(result[0].original.metamask.gasLimits).toStrictEqual([ - 50000, 21000, - ]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ gas: '0xC350' }), + ]), + }), + ); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([71000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('adds original transaction gas to EIP-7702 combined gas limit for post-quote', async () => { + it('returns combined 7702 gas limit for post-quote with multi-step relay', async () => { const multiStepQuote = { ...QUOTE_MOCK, steps: [ @@ -1069,10 +1096,9 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); - // EIP-7702: batch returns single combined gas for multiple relay txs estimateGasBatchMock.mockResolvedValue({ - totalGasLimit: 51000, - gasLimits: [51000], + totalGasLimit: 130000, + gasLimits: [130000], }); const result = await getRelayQuotes({ @@ -1098,12 +1124,11 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // EIP-7702: original tx gas (79000) added to combined relay gas (51000) expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); expect(result[0].original.metamask.is7702).toBe(true); }); - it('prepends original transaction gas to multiple relay gas limits for post-quote', async () => { + it('returns per-tx gas limits for post-quote with multi-step non-7702 relay', async () => { const multiStepQuote = { ...QUOTE_MOCK, steps: [ @@ -1130,10 +1155,9 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); - // Multiple gas limits (not 7702 combined): batch returns per-tx limits estimateGasBatchMock.mockResolvedValue({ - totalGasLimit: 51000, - gasLimits: [21000, 30000], + totalGasLimit: 130000, + gasLimits: [79000, 21000, 30000], }); const result = await getRelayQuotes({ @@ -1159,19 +1183,24 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // Original tx gas (79000) prepended to relay gas limits [21000, 30000] expect(result[0].original.metamask.gasLimits).toStrictEqual([ 79000, 21000, 30000, ]); expect(result[0].original.metamask.is7702).toBe(false); }); - it('skips original transaction gas when txParams.gas is missing for post-quote', async () => { + it('includes original tx without gas in batch estimation for post-quote', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [80000], + totalGasEstimate: 80000, + totalGasLimit: 80000, + }); + const result = await getRelayQuotes({ accountSupports7702: true, messenger, @@ -1194,15 +1223,12 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta, }); - // No gas on txParams or nestedTransactions — only relay gas limits - expect(result[0].original.metamask.gasLimits).toStrictEqual([21000]); - expect(result[0].original.metamask.is7702).toBe(false); + expect(estimateGasBatchMock).toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([80000]); + expect(result[0].original.metamask.is7702).toBe(true); }); - it('preserves estimate vs limit distinction when using fallback gas for post-quote', async () => { - // Use a quote whose relay step has NO gas param so the single-path - // estimation is attempted; make it fail to trigger the fallback path - // where estimate (900 000) != max (1 500 000). + it('uses batch estimation with original tx for post-quote even when relay step has no gas', async () => { const noGasQuote = cloneDeep(QUOTE_MOCK); delete noGasQuote.steps[0].items[0].data.gas; @@ -1211,9 +1237,15 @@ describe('Relay Quotes Utils', () => { json: async () => noGasQuote, } as never); - estimateGasMock.mockRejectedValue(new Error('Estimation failed')); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [1000000], + totalGasEstimate: 1000000, + totalGasLimit: 1000000, + }); - await getRelayQuotes({ + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ accountSupports7702: true, messenger, requests: [ @@ -1230,25 +1262,86 @@ describe('Relay Quotes Utils', () => { from: FROM_MOCK, to: '0x9' as Hex, data: '0xaaa' as Hex, - gas: '0x13498', // 79 000 + gas: '0x13498', value: '0', }, } as TransactionMeta, }); - // Fallback: estimate=900000, max=1500000. - // With originalTxGas=79000 added independently: - // estimate call should receive 900000+79000 = 979000 - // max call should receive 1500000+79000 = 1579000 - const estimateCall = calculateGasCostMock.mock.calls.find( - ([args]) => !args.isMax, - ); - const maxCall = calculateGasCostMock.mock.calls.find( - ([args]) => args.isMax, - ); + expect(estimateGasBatchMock).toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([1000000]); + expect(result[0].original.metamask.is7702).toBe(true); + }); + + it('defaults data and value for original tx when not present on txParams', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [80000], + totalGasEstimate: 80000, + totalGasLimit: 80000, + }); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + }, + } as TransactionMeta, + }); + + const batchCall = estimateGasBatchMock.mock.calls[0][0]; + const originalTxParams = batchCall.transactions[0]; + expect(originalTxParams.data).toBe('0x'); + expect(originalTxParams.value).toBe('0x0'); + }); + + it('skips original tx in batch estimation when accountOverride diverges from txParams.from', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + from: '0xOverrideEOA' as Hex, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: '0xMoneyAccount' as Hex, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); - expect(estimateCall?.[0].gas).toBe(979000); - expect(maxCall?.[0].gas).toBe(1579000); + expect(estimateGasBatchMock).not.toHaveBeenCalled(); }); it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { @@ -1270,11 +1363,98 @@ describe('Relay Quotes Utils', () => { transaction: TRANSACTION_META_MOCK, }); - // With no txParams.to the original tx should be skipped, so only - // the relay step params are sent to gas estimation (single path). expect(estimateGasBatchMock).not.toHaveBeenCalled(); }); + it('falls back to legacy gas combining when txParams.to is missing but gas is present for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + gas: '0x13498', + }, + } as TransactionMeta, + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 79000, 21000, + ]); + expect(result[0].original.metamask.is7702).toBe(false); + }); + + it('falls back to legacy 7702 gas combining when txParams.to is missing for post-quote with multi-step relay', async () => { + const multiStepQuote = { + ...QUOTE_MOCK, + steps: [ + { + ...STEP_MOCK, + items: [ + STEP_MOCK.items[0], + { + ...STEP_MOCK.items[0], + data: { + ...STEP_MOCK.items[0].data, + gas: '30000', + }, + }, + ], + }, + ], + } as RelayQuote; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => multiStepQuote, + } as never); + + getGasBufferMock.mockReturnValue(1); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + gas: '0x13498', + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); + expect(result[0].original.metamask.is7702).toBe(true); + }); + it('uses refundTo as from for single gas estimation in predictWithdraw post-quote', async () => { const quoteMock = cloneDeep(QUOTE_MOCK); delete quoteMock.steps[0].items[0].data.gas; @@ -1795,9 +1975,11 @@ describe('Relay Quotes Utils', () => { const secondBody = JSON.parse( (secondCall[1] as RequestInit).body as string, ); + // Gas cost is buffered by the default postQuoteGasBuffer (1.1) + // ceil(2725000000000000 * 1.1) = 2997500000000000 + const bufferedGas = BigInt('2997500000000000'); const adjustedAmount = ( - BigInt(QUOTE_REQUEST_MOCK.sourceTokenAmount) - - BigInt('2725000000000000') + BigInt(QUOTE_REQUEST_MOCK.sourceTokenAmount) - bufferedGas ).toString(); expect(secondBody.amount).toBe(adjustedAmount); @@ -1806,6 +1988,58 @@ describe('Relay Quotes Utils', () => { ); }); + it('subtracts gas for Polygon native token address (0x1010) in post-quote flows', async () => { + const phase2Mock = { + ...QUOTE_MOCK, + details: { + ...QUOTE_MOCK.details, + currencyOut: { + ...QUOTE_MOCK.details.currencyOut, + amountFormatted: '0.8', + amountUsd: '0.80', + }, + }, + }; + + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => phase2Mock, + } as never); + + calculateGasCostMock.mockReturnValue({ + fiat: '0.50', + human: '0.5', + raw: '500000000000000', + usd: '0.50', + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + sourceChainId: '0x89' as Hex, + sourceTokenAddress: + '0x0000000000000000000000000000000000001010' as Hex, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(result[0].targetAmount).toStrictEqual( + expect.objectContaining({ usd: expect.any(String) }), + ); + }); + it('returns phase 1 quote when gas cost exceeds source amount for post-quote flows', async () => { successfulFetchMock.mockResolvedValue({ ok: true, @@ -2322,6 +2556,12 @@ describe('Relay Quotes Utils', () => { json: async () => QUOTE_MOCK, } as never); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [42000], + totalGasEstimate: 42000, + totalGasLimit: 42000, + }); + getTokenBalanceMock.mockReturnValue('1724999999999999'); getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 3ee17c4af7..8485588edf 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -36,6 +36,7 @@ import type { import { getFiatValueFromUsd } from '../../utils/amounts'; import { getFeatureFlags, + getPostQuoteGasBuffer, getRelayOriginGasOverhead, getSlippage, isEIP7702Chain, @@ -143,11 +144,28 @@ async function getQuoteWithPostQuoteGasHandling( ): Promise> { const phase1Quote = await getSingleQuote(request, fullRequest); - if (!request.isPostQuote || !phase1Quote.fees.isSourceGasFeeToken) { + if (!request.isPostQuote) { return phase1Quote; } - const gasCostRaw = phase1Quote.fees.sourceNetwork.max.raw; + // Gas must be subtracted from the source amount when the user's balance + // is fully committed to the swap. This applies when gas is paid via an + // ERC-20 fee token (isSourceGasFeeToken) OR when the source itself is + // the native gas token (gas comes from the same pool as the swap value). + const isSourceNative = + request.sourceTokenAddress.toLowerCase() === + NATIVE_TOKEN_ADDRESS.toLowerCase() || + request.sourceTokenAddress.toLowerCase() === + getNativeToken(request.sourceChainId).toLowerCase(); + + if (!phase1Quote.fees.isSourceGasFeeToken && !isSourceNative) { + return phase1Quote; + } + + const gasBuffer = getPostQuoteGasBuffer(fullRequest.messenger); + const gasCostRaw = new BigNumber(phase1Quote.fees.sourceNetwork.max.raw) + .multipliedBy(gasBuffer) + .integerValue(BigNumber.ROUND_UP); const adjustedSourceAmount = new BigNumber(request.sourceTokenAmount) .minus(gasCostRaw) @@ -263,11 +281,16 @@ async function getSingleQuote( await applyPolymarketDepositWalletOverrides(body, request, messenger); } - // Skip transaction processing for post-quote flows - the original transaction - // will be included in the batch separately, not as part of the quote. - // Skip for Polymarket deposit wallet flows - the source is already a + // Skip transaction processing when skipProcessTransactions (defaulting to + // isPostQuote) is true — the original transaction will be included in the + // batch separately, not as part of the quote. + // Skip for Polymarket deposit wallet flows — the source is already a // bridged token transfer, not a contract call to embed. - if (!request.isPostQuote && !request.isPolymarketDepositWallet) { + const shouldProcessTransactions = + !(request.skipProcessTransactions ?? request.isPostQuote) && + !request.isPolymarketDepositWallet; + + if (shouldProcessTransactions) { await processTransactions(transaction, request, body, messenger); } else if ( request.isPostQuote && @@ -757,14 +780,29 @@ async function calculateSourceNetworkCost( const useFromOverride = isPredictWithdraw && hasDepositStep; const fromOverride = useFromOverride ? request.refundTo : undefined; - const relayOnlyGas = await calculateSourceNetworkGasLimit( - relayParams, + // For post-quote flows the original transaction will be prepended to the + // batch at submission time. Include it in the gas estimation so + // estimateGasBatch sees the full batch and can detect EIP-7702 support. + // Without this, a single relay step is estimated alone, gets is7702=false, + // and the batch falls back to separate type-0x2 transactions that each + // need native gas — breaking zero-balance fiat-funded accounts. + const originalTxGasParams = getOriginalTxGasParams(request, transaction); + const allGasParams = originalTxGasParams + ? [originalTxGasParams, ...relayParams] + : relayParams; + + const gasResult = await calculateSourceNetworkGasLimit( + allGasParams, messenger, fromOverride, ); + // When the original tx was NOT included in gas estimation (no gas params + // available), fall back to the legacy prepend-after-the-fact approach. const { gasLimits, is7702, totalGasEstimate, totalGasLimit } = - combinePrependedGas(relayOnlyGas, request, transaction); + originalTxGasParams + ? gasResult + : combinePrependedGas(gasResult, request, transaction); log('Gas limit', { is7702, @@ -962,6 +1000,45 @@ function toRelayQuoteGasTransaction( }; } +type RelayStepData = RelayTransactionStep['items'][0]['data']; + +function getOriginalTxGasParams( + request: QuoteRequest, + transaction: TransactionMeta, +): RelayStepData | undefined { + if (!request.isPostQuote) { + return undefined; + } + + const { txParams } = transaction; + const to = txParams.to as Hex | undefined; + + if (!to) { + return undefined; + } + + const hasAccountOverride = + request.from.toLowerCase() !== (txParams.from as Hex).toLowerCase(); + + if (hasAccountOverride) { + return undefined; + } + + const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; + const gas = nestedGas ?? txParams.gas; + + return { + chainId: Number(transaction.chainId), + data: (txParams.data as Hex) ?? ('0x' as Hex), + from: txParams.from as Hex, + gas: gas ? String(gas) : undefined, + maxFeePerGas: '0', + maxPriorityFeePerGas: '0', + to, + value: (txParams.value as string) ?? '0', + }; +} + type RelayGasResult = { totalGasEstimate: number; totalGasLimit: number; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index c43dfb22e6..da96145190 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 @@ -1843,14 +1843,12 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).not.toHaveBeenCalled(); }); - it('still validates source balance', async () => { + it('skips source balance validation for execute flow', async () => { getLiveTokenBalanceMock.mockResolvedValue('500000'); - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Insufficient source token balance for relay deposit', - ); + await submitRelayQuotes(request); - expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + expect(getDelegationTransactionMock).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..993f3d8c4d 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 + // - isExecute: Relay's relayer handles the source-side transaction + const skipBalanceCheck = + Boolean(quote.request.isPostQuote) || + Boolean(quote.request.paymentOverride) || + Boolean(quote.original.metamask.isExecute); + + if (!skipBalanceCheck) { await validateSourceBalance(quote, messenger); } @@ -429,6 +436,8 @@ async function submitTransactions( : ({ data: transaction.txParams.data as Hex | undefined, from: transaction.txParams.from, + maxFeePerGas: normalizedParams[0]?.maxFeePerGas, + maxPriorityFeePerGas: normalizedParams[0]?.maxPriorityFeePerGas, to: transaction.txParams.to, value: transaction.txParams.value as Hex | undefined, } as TransactionParams); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 574cc18b3f..eb170afa46 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -183,6 +183,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, @@ -219,6 +242,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; @@ -471,6 +497,9 @@ export type QuoteRequest = { */ refundTo?: Hex; + /** Whether to skip processTransactions in relay-quotes. Defaults to `isPostQuote`. */ + skipProcessTransactions?: boolean; + /** Balance of the source token in atomic format without factoring token decimals. */ sourceBalanceRaw: string; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 076fd56ed5..4ffc314351 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -17,6 +17,9 @@ import { getAssetsUnifyStateFeature, getFallbackGas, getFiatAssetPerTransactionType, + getFiatFeeReserveMultiplier, + getFiatMaxRateDriftPercent, + getPostQuoteGasBuffer, DEFAULT_RELAY_EXECUTE_URL, getServerPollingInterval, getServerPollingTimeout, @@ -1515,4 +1518,136 @@ describe('Feature Flags Utils', () => { expect(result).toStrictEqual(FIAT_ASSET_MOCK); }); }); + + describe('getFiatFeeReserveMultiplier', () => { + it('returns 1.2 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1.2); + }); + + it('returns the configured multiplier from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: 1.5 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1.5); + }); + + it('returns 1.2 when multiplier is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: 0 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1.2); + }); + + it('returns 1.2 when multiplier is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { feeReserveMultiplier: -2 }, + }, + }); + + expect(getFiatFeeReserveMultiplier(messenger)).toBe(1.2); + }); + }); + + describe('getFiatMaxRateDriftPercent', () => { + it('returns 10 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + + it('returns the configured value from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: 25 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(25); + }); + + it('returns 10 when value is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: 0 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + + it('returns 10 when value is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { maxRateDriftPercent: -5 }, + }, + }); + + expect(getFiatMaxRateDriftPercent(messenger)).toBe(10); + }); + }); + + describe('getPostQuoteGasBuffer', () => { + it('returns 1.1 when feature flag is not set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: {}, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + + it('returns the configured value from feature flag', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: 1.5 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.5); + }); + + it('returns 1.1 when value is zero', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: 0 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + + it('returns 1.1 when value is negative', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_post_quote: { gasBuffer: -1 }, + }, + }); + + expect(getPostQuoteGasBuffer(messenger)).toBe(1.1); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 8ef214c4a6..08a5375156 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -91,6 +91,12 @@ type FiatFlags = { assetPerTransactionType?: Partial< Record >; + feeReserveMultiplier?: number; + maxRateDriftPercent?: number; +}; + +type PostQuoteFlags = { + gasBuffer?: number; }; type StrategyRoutingConfig = { @@ -821,6 +827,80 @@ export function getFiatAssetPerTransactionType( ); } +const DEFAULT_FEE_RESERVE_MULTIPLIER = 1.2; +const DEFAULT_MAX_RATE_DRIFT_PERCENT = 10; +const DEFAULT_POST_QUOTE_GAS_BUFFER = 1.1; + +/** + * Returns the fee reserve multiplier for fiat three-phase submit. + * + * Controls how much of the original relay fee is reserved from the discovery + * quote source amount to prevent EXACT_OUTPUT cost overruns. + * Defaults to 1 (100% of the original fee). + * + * @param messenger - Controller messenger. + * @returns The fee reserve multiplier. + */ +export function getFiatFeeReserveMultiplier( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const fiatFlags = state.remoteFeatureFlags?.confirmations_pay_fiat as + | FiatFlags + | undefined; + + const multiplier = fiatFlags?.feeReserveMultiplier; + + return typeof multiplier === 'number' && multiplier > 0 + ? multiplier + : DEFAULT_FEE_RESERVE_MULTIPLIER; +} + +/** + * Returns the maximum allowed relay rate drift percentage for fiat submit. + * + * Controls how much the relay exchange rate can drift between the original + * quoting phase and the post-settlement discovery quote before failing. + * Defaults to 10%. + * + * @param messenger - Controller messenger. + * @returns The maximum rate drift percentage. + */ +export function getFiatMaxRateDriftPercent( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const fiatFlags = state.remoteFeatureFlags?.confirmations_pay_fiat as + | FiatFlags + | undefined; + + const maxDrift = fiatFlags?.maxRateDriftPercent; + + return typeof maxDrift === 'number' && maxDrift > 0 + ? maxDrift + : DEFAULT_MAX_RATE_DRIFT_PERCENT; +} + +/** + * Returns the gas buffer multiplier for post-quote gas reservations. + * + * @param messenger - Controller messenger. + * @returns The gas buffer multiplier. + */ +export function getPostQuoteGasBuffer( + messenger: TransactionPayControllerMessenger, +): number { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const postQuoteFlags = state.remoteFeatureFlags + ?.confirmations_pay_post_quote as PostQuoteFlags | undefined; + + const buffer = postQuoteFlags?.gasBuffer; + + return typeof buffer === 'number' && buffer > 0 + ? buffer + : DEFAULT_POST_QUOTE_GAS_BUFFER; +} + /** * Checks if a chain supports EIP-7702. * 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. *