From 106e9f774c077ebc321ca3857c972ce6582996c0 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sat, 13 Jun 2026 21:54:34 +0100 Subject: [PATCH 1/6] feat(transaction-pay-controller): validate relay quotes --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../transaction-pay-controller/src/index.ts | 2 + .../src/strategy/relay/RelayStrategy.test.ts | 87 ++ .../src/strategy/relay/RelayStrategy.ts | 47 + .../src/strategy/relay/relay-submit.ts | 151 ++- .../src/strategy/relay/simulation.test.ts | 512 +++++++++++ .../src/strategy/relay/simulation.ts | 857 ++++++++++++++++++ .../transaction-pay-controller/src/types.ts | 53 +- .../src/utils/quotes.test.ts | 53 +- .../src/utils/quotes.ts | 13 +- .../src/utils/sentinel.ts | 121 +++ .../src/utils/strategy.ts | 15 +- 12 files changed, 1861 insertions(+), 54 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/relay/simulation.ts create mode 100644 packages/transaction-pay-controller/src/utils/sentinel.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 17116e6f03..cc72624c92 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Relay quote validation and transaction simulation before Transaction Pay quotes are surfaced ([#9143](https://github.com/MetaMask/core/pull/9143)) + ## [23.8.0] ### Changed diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index fe2679a011..4de7283857 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -19,6 +19,8 @@ export type { TransactionPayControllerStateChangeEvent, TransactionPaymentToken, TransactionPayQuote, + TransactionPayQuoteValidationError, + TransactionPayQuoteValidationErrorCode, TransactionPayRequiredToken, TransactionPaySourceAmount, TransactionPayTotals, diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts index 0d44f891d8..d517f31633 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +import { TransactionPayStrategy } from '../../constants'; import type { PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, @@ -9,16 +10,22 @@ import { getPayStrategiesConfig } from '../../utils/feature-flags'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; import { RelayStrategy } from './RelayStrategy'; +import { isRelayQuoteValidationError, validateRelayQuote } from './simulation'; import type { RelayQuote } from './types'; jest.mock('./relay-quotes'); +jest.mock('./simulation'); jest.mock('./relay-submit'); jest.mock('../../utils/feature-flags'); describe('RelayStrategy', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const isRelayQuoteValidationErrorMock = jest.mocked( + isRelayQuoteValidationError, + ); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); + const validateRelayQuoteMock = jest.mocked(validateRelayQuote); const messenger = {} as never; @@ -57,6 +64,8 @@ describe('RelayStrategy', () => { enabled: true, }, }); + isRelayQuoteValidationErrorMock.mockReturnValue(false); + validateRelayQuoteMock.mockResolvedValue(undefined); }); it('returns true from supports when relay is enabled', () => { @@ -92,6 +101,84 @@ describe('RelayStrategy', () => { expect(getRelayQuotesMock).toHaveBeenCalledWith(request); }); + it('validates quotes in checkQuoteSupport', async () => { + const quote = { + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + + const strategy = new RelayStrategy(); + const result = await strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: request.transaction, + }); + + expect(result).toStrictEqual({ isSupported: true }); + expect(validateRelayQuoteMock).toHaveBeenCalledWith({ + messenger, + quote, + signal: undefined, + transaction: request.transaction, + }); + }); + + it('returns quote validation errors from checkQuoteSupport', async () => { + const validationError = { + code: 'insufficient_source_balance', + message: 'Insufficient source token balance for quote', + strategy: TransactionPayStrategy.Relay, + } as const; + const quote = { + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + const error = { validationError } as unknown as Error; + + isRelayQuoteValidationErrorMock.mockReturnValue(true); + validateRelayQuoteMock.mockRejectedValue(error); + + const strategy = new RelayStrategy(); + const result = await strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: request.transaction, + }); + + expect(result).toStrictEqual({ + isSupported: false, + validationError, + }); + }); + + it('wraps unknown quote validation errors', async () => { + const quote = { + request: { + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + }, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + + validateRelayQuoteMock.mockRejectedValue(new Error('RPC down')); + + const strategy = new RelayStrategy(); + const result = await strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: request.transaction, + }); + + expect(result).toStrictEqual({ + isSupported: false, + validationError: { + chainId: '0x1', + code: 'quote_validation_unavailable', + message: 'RPC down', + strategy: TransactionPayStrategy.Relay, + tokenAddress: '0xabc', + }, + }); + }); + it('delegates execute', async () => { const executeRequest = { messenger, diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts index 8ed8d23c54..219e6ab682 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -1,12 +1,16 @@ import type { PayStrategy, + PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, + PayStrategyQuoteSupportResult, TransactionPayQuote, + TransactionPayQuoteValidationError, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; +import { isRelayQuoteValidationError, validateRelayQuote } from './simulation'; import type { RelayQuote } from './types'; export class RelayStrategy implements PayStrategy { @@ -21,6 +25,32 @@ export class RelayStrategy implements PayStrategy { return getRelayQuotes(request); } + async checkQuoteSupport( + request: PayStrategyCheckQuoteSupportRequest, + ): Promise { + for (const quote of request.quotes) { + try { + await validateRelayQuote({ + messenger: request.messenger, + quote, + signal: request.signal, + transaction: request.transaction, + }); + } catch (error) { + if (request.signal?.aborted) { + throw error; + } + + return { + isSupported: false, + validationError: getValidationError(quote, error), + }; + } + } + + return { isSupported: true }; + } + async execute( request: PayStrategyExecuteRequest, ): ReturnType['execute']> { @@ -32,3 +62,20 @@ export class RelayStrategy implements PayStrategy { } } } + +function getValidationError( + quote: TransactionPayQuote, + error: unknown, +): TransactionPayQuoteValidationError { + if (isRelayQuoteValidationError(error)) { + return error.validationError; + } + + return { + chainId: quote.request.sourceChainId, + code: 'quote_validation_unavailable', + message: (error as Error).message, + strategy: quote.strategy, + tokenAddress: quote.request.sourceTokenAddress, + }; +} 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 29b1c26271..522cbaadb7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -55,6 +55,11 @@ const FALLBACK_HASH = '0x0' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); +export type RelaySubmitParams = { + allParams: TransactionParams[]; + normalizedParams: TransactionParams[]; +}; + /** * Submits Relay quotes. * @@ -270,7 +275,7 @@ async function waitForRelayCompletion( * @param messenger - Controller messenger. * @returns Normalized transaction parameters. */ -function normalizeParams( +export function normalizeRelayParams( params: RelayTransactionStep['items'][0]['data'], messenger: TransactionPayControllerMessenger, ): TransactionParams { @@ -364,29 +369,74 @@ async function submitTransactions( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, ): Promise { - const { steps } = quote.original; - const txSteps = steps.filter( - (step): step is RelayTransactionStep => step.kind === 'transaction', + // In post-quote flows (e.g. Predict withdraw), the source tokens are held in + // the Safe — not the EOA — and only become available after the original tx + // executes as part of the batch. Skip the EOA balance check here. + if (!quote.request.isPostQuote && !quote.request.paymentOverride) { + await validateSourceBalance(quote, messenger); + } + + const { allParams, normalizedParams } = await buildRelaySubmitParams({ + messenger, + quote, + transaction, + }); + + if (quote.original.metamask.isExecute) { + return await submitViaRelayExecute( + quote, + transaction, + messenger, + allParams, + ); + } + + return await submitViaTransactionController( + quote, + transaction, + messenger, + normalizedParams, + allParams, ); - const params = txSteps.flatMap((step) => step.items).map((item) => item.data); - const SUPPORTED_STEP_KINDS = ['transaction', 'signature']; +} + +export function getRelayTransactionStepData( + quote: TransactionPayQuote, +): RelayTransactionStep['items'][0]['data'][] { + const { steps } = quote.original; + const supportedStepKinds = ['transaction', 'signature']; const invalidKind = steps.find( - (step) => !SUPPORTED_STEP_KINDS.includes(step.kind), + (step) => !supportedStepKinds.includes(step.kind), )?.kind; if (invalidKind) { 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) { - await validateSourceBalance(quote, messenger); - } + const txSteps = steps.filter( + (step): step is RelayTransactionStep => step.kind === 'transaction', + ); + return txSteps + .flatMap((step) => step.items) + .map((item) => item.data) + .filter((data): data is RelayTransactionStep['items'][0]['data'] => + isRelayTransactionStepData(data), + ); +} + +export async function buildRelaySubmitParams({ + messenger, + quote, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const params = getRelayTransactionStepData(quote); const normalizedParams = params.map((singleParams) => - normalizeParams(singleParams, messenger), + normalizeRelayParams(singleParams, messenger), ); // For post-quote flows, prepend the original transaction so it gets @@ -442,22 +492,14 @@ async function submitTransactions( allParams = [prependedParams, ...normalizedParams]; } - if (quote.original.metamask.isExecute) { - return await submitViaRelayExecute( - quote, - transaction, - messenger, - allParams, - ); - } + return { allParams, normalizedParams }; +} - return await submitViaTransactionController( - quote, - transaction, - messenger, - normalizedParams, - allParams, - ); +function isRelayTransactionStepData( + data: RelayTransactionStep['items'][0]['data'], +): data is RelayTransactionStep['items'][0]['data'] { + const maybeStepData = data as { to?: unknown }; + return maybeStepData.to !== undefined; } /** @@ -516,11 +558,44 @@ async function submitViaRelayExecute( messenger: TransactionPayControllerMessenger, allParams: TransactionParams[], ): Promise { + const executeBody = await buildRelayExecuteRequest({ + allParams, + messenger, + quote, + transaction, + }); + const { from } = quote.request; + + log('Submitting via Relay execute', { executeBody, from }); + + let result; + try { + result = await submitRelayExecute(messenger, executeBody); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Relay execute: ${message}`); + } + + log('Relay execute response', result); + + return FALLBACK_HASH; +} + +export async function buildRelayExecuteRequest({ + allParams, + messenger, + quote, + transaction, +}: { + allParams: TransactionParams[]; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { const { from, sourceChainId } = quote.request; const { requestId } = quote.original.steps[0]; const networkClientId = getNetworkClientId(messenger, sourceChainId); - const sourceCallTransaction = { ...transaction, chainId: sourceChainId, @@ -543,7 +618,7 @@ async function submitViaRelayExecute( log('Delegation result for source calls', delegation); - const executeBody: RelayExecuteRequest = { + return { executionKind: 'rawCalls', data: { chainId: Number(sourceChainId), @@ -568,20 +643,6 @@ async function submitViaRelayExecute( }, requestId, }; - - log('Submitting via Relay execute', { executeBody, from }); - - let result; - try { - result = await submitRelayExecute(messenger, executeBody); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Relay execute: ${message}`); - } - - log('Relay execute response', result); - - return FALLBACK_HASH; } /** diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts new file mode 100644 index 0000000000..6bd5268be7 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts @@ -0,0 +1,512 @@ +import { Interface } from '@ethersproject/abi'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../../constants'; +import { getMessengerMock } from '../../tests/messenger-mock'; +import type { TransactionPayQuote } from '../../types'; +import { getFeatureFlags } from '../../utils/feature-flags'; +import { rpcRequest } from '../../utils/provider'; +import { + SentinelSimulationError, + simulateTransactions, +} from '../../utils/sentinel'; +import { getLiveTokenBalance } from '../../utils/token'; +import { + isRelayQuoteValidationError, + RelayQuoteValidationError, + validateRelayQuote, +} from './simulation'; +import type { RelayQuote } from './types'; + +jest.mock('../../utils/feature-flags', () => ({ + ...jest.requireActual( + '../../utils/feature-flags', + ), + getFeatureFlags: jest.fn(), +})); +jest.mock('../../utils/provider', () => ({ + ...jest.requireActual( + '../../utils/provider', + ), + rpcRequest: jest.fn(), +})); +jest.mock('../../utils/sentinel', () => ({ + ...jest.requireActual( + '../../utils/sentinel', + ), + simulateTransactions: jest.fn(), +})); +jest.mock('../../utils/token', () => ({ + ...jest.requireActual( + '../../utils/token', + ), + getLiveTokenBalance: jest.fn(), +})); + +const erc20Interface = new Interface(abiERC20); +const FROM_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const TOKEN_ADDRESS_MOCK = '0x2222222222222222222222222222222222222222' as Hex; +const RECIPIENT_MOCK = '0x3333333333333333333333333333333333333333' as Hex; +const CHAIN_ID_MOCK = '0x38' as Hex; +const TRANSACTION_MOCK = { + id: 'tx-id', + chainId: CHAIN_ID_MOCK, + txParams: { from: FROM_MOCK }, +} as TransactionMeta; + +describe('Relay quote simulation validation', () => { + const getFeatureFlagsMock = jest.mocked(getFeatureFlags); + const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); + const rpcRequestMock = jest.mocked(rpcRequest); + const simulateTransactionsMock = jest.mocked(simulateTransactions); + const { + findNetworkClientIdByChainIdMock, + getDelegationTransactionMock, + getRemoteFeatureFlagControllerStateMock, + messenger, + } = getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + findNetworkClientIdByChainIdMock.mockReturnValue('network-client-id'); + getFeatureFlagsMock.mockReturnValue({ + relayFallbackGas: { max: 123 }, + } as never); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + remoteFeatureFlags: {}, + } as never); + getLiveTokenBalanceMock.mockResolvedValue('1000'); + rpcRequestMock.mockResolvedValue({} as never); + simulateTransactionsMock.mockResolvedValue({ transactions: [{}] }); + }); + + it('simulates the normalized relay transaction when live balance covers the quote', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith(CHAIN_ID_MOCK, { + transactions: [ + expect.objectContaining({ + data: expect.stringMatching(/^0xa9059cbb/u), + from: FROM_MOCK, + gas: '0x7b', + to: TOKEN_ADDRESS_MOCK, + }), + ], + withCallTrace: true, + withGas: true, + withLogs: true, + }); + }); + + it('throws an insufficient source balance error when source amount exceeds live balance', async () => { + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '1500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + availableAmountRaw: '1000', + code: 'insufficient_source_balance', + message: 'Insufficient quote source amount', + requiredAmountRaw: '1500', + }, + }); + expect(simulateTransactionsMock).not.toHaveBeenCalled(); + }); + + it('throws an insufficient source balance error when decoded transfer exceeds live balance', async () => { + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '500', + transferAmountRaw: '1500', + }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + availableAmountRaw: '1000', + code: 'insufficient_source_balance', + message: 'Insufficient balance for decoded quote amount', + requiredAmountRaw: '1500', + }, + }); + expect(simulateTransactionsMock).not.toHaveBeenCalled(); + }); + + it('throws a balance unavailable error when live balance retrieval fails', async () => { + getLiveTokenBalanceMock.mockRejectedValue(new Error('RPC timeout')); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'source_balance_unavailable', + message: 'Cannot validate payment token balance - RPC timeout', + }, + }); + }); + + it('throws a simulation error when Sentinel returns a transaction error', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ error: 'ERC20: transfer amount exceeds balance' }], + }); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_simulation_failed', + message: 'ERC20: transfer amount exceeds balance', + }, + }); + }); + + it('uses debug_traceCall fallback details when Sentinel returns a generic simulation error', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ error: 'Quote simulation failed - execution reverted' }], + }); + rpcRequestMock.mockResolvedValueOnce({ + error: 'execution reverted', + calls: [{ error: 'ERC20: transfer amount exceeds balance' }], + } as never); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_simulation_failed', + message: 'ERC20: transfer amount exceeds balance', + }, + }); + + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: CHAIN_ID_MOCK, + method: 'debug_traceCall', + options: { preferInfura: true }, + }), + ); + }); + + it('does not prefer Infura for fallback details when the chain is feature-flag excluded', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + remoteFeatureFlags: { + confirmations_pay_extended: { + excludeChainIdsFromInfura: [CHAIN_ID_MOCK], + }, + }, + } as never); + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ error: 'Quote simulation failed - execution reverted' }], + }); + rpcRequestMock.mockResolvedValueOnce({ + calls: [{ error: 'route reverted' }], + } as never); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + message: 'route reverted', + }, + }); + + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'debug_traceCall', + options: { preferInfura: false }, + }), + ); + }); + + it('validates the quote with debug_traceCall when Sentinel does not support the chain', async () => { + simulateTransactionsMock.mockRejectedValue( + new SentinelSimulationError( + `Simulation is not supported for chain ${CHAIN_ID_MOCK}`, + ), + ); + rpcRequestMock.mockResolvedValueOnce({} as never); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }); + + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ method: 'debug_traceCall' }), + ); + }); + + it('validates the quote with eth_estimateGas when Sentinel and debug_traceCall are unavailable', async () => { + simulateTransactionsMock.mockRejectedValue( + new SentinelSimulationError( + `Simulation is not supported for chain ${CHAIN_ID_MOCK}`, + ), + ); + rpcRequestMock + .mockRejectedValueOnce(new Error('method debug_traceCall not supported')) + .mockResolvedValueOnce({} as never); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }); + + expect(rpcRequestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: 'eth_estimateGas' }), + ); + }); + + it('does not reject multi-transaction quotes when Sentinel does not support the chain', async () => { + simulateTransactionsMock.mockRejectedValue( + new SentinelSimulationError( + `Simulation is not supported for chain ${CHAIN_ID_MOCK}`, + ), + ); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '500', + transactionCount: 2, + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(rpcRequestMock).not.toHaveBeenCalled(); + }); + + it('uses eth_estimateGas fallback details when debug_traceCall is unavailable', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ error: 'Quote simulation failed - execution reverted' }], + }); + rpcRequestMock + .mockRejectedValueOnce(new Error('method debug_traceCall not supported')) + .mockRejectedValueOnce(new Error('execution reverted: route reverted')); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_simulation_failed', + message: 'execution reverted: route reverted', + }, + }); + + expect(rpcRequestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: 'eth_estimateGas' }), + ); + }); + + it('returns raw nested route balance reverts as simulation failures', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [ + { error: 'execution reverted: ERC20: transfer amount exceeds balance' }, + ], + }); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '500', + stepData: '0xf9e4bab4', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_simulation_failed', + message: 'execution reverted: ERC20: transfer amount exceeds balance', + }, + }); + }); + + it('returns raw generic insufficient funds simulation errors as simulation failures', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ error: 'insufficient funds for gas * price + value' }], + }); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_simulation_failed', + message: 'insufficient funds for gas * price + value', + }, + }); + }); + + it('simulates Relay execute quotes with an EIP-7702 account override', async () => { + const authorizationAddress = + '0x4444444444444444444444444444444444444444' as Hex; + getDelegationTransactionMock.mockResolvedValue({ + authorizationList: [ + { + address: authorizationAddress, + chainId: CHAIN_ID_MOCK, + nonce: '0x1', + r: '0x1' as Hex, + s: '0x2' as Hex, + yParity: '0x0', + }, + ], + data: '0x1234' as Hex, + to: '0x5555555555555555555555555555555555555555' as Hex, + value: '0x0' as Hex, + }); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isExecute: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + overrides: { + [FROM_MOCK.toLowerCase()]: { + code: `0xef0100${authorizationAddress.slice(2)}`, + }, + }, + transactions: [ + expect.objectContaining({ + data: '0x1234', + from: FROM_MOCK, + to: '0x5555555555555555555555555555555555555555', + value: '0x0', + }), + ], + }), + ); + }); + + it('identifies relay quote validation errors', async () => { + const error = new RelayQuoteValidationError({ + code: 'quote_simulation_failed', + message: 'boom', + strategy: TransactionPayStrategy.Relay, + }); + + expect(isRelayQuoteValidationError(error)).toBe(true); + }); +}); + +function buildQuote({ + isExecute = false, + sourceAmountRaw, + stepData, + transactionCount = 1, + transferAmountRaw, +}: { + isExecute?: boolean; + sourceAmountRaw: string; + stepData?: Hex; + transactionCount?: number; + transferAmountRaw: string; +}): TransactionPayQuote { + return { + original: { + details: { + currencyIn: { currency: { chainId: Number(CHAIN_ID_MOCK) } }, + currencyOut: { currency: { chainId: 1 } }, + }, + metamask: { gasLimits: [], isExecute, is7702: false }, + request: {}, + steps: [ + { + id: 'deposit', + kind: 'transaction', + requestId: 'request-id', + items: Array.from({ length: transactionCount }, () => ({ + check: { + endpoint: 'https://relay.test/status', + method: 'GET', + }, + data: { + chainId: Number(CHAIN_ID_MOCK), + data: + stepData ?? + (erc20Interface.encodeFunctionData('transfer', [ + RECIPIENT_MOCK, + transferAmountRaw, + ]) as Hex), + from: FROM_MOCK, + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + to: TOKEN_ADDRESS_MOCK, + value: '0', + }, + status: 'complete', + })), + }, + ], + } as RelayQuote, + request: { + from: FROM_MOCK, + sourceBalanceRaw: '1000', + sourceChainId: CHAIN_ID_MOCK, + sourceTokenAddress: TOKEN_ADDRESS_MOCK, + sourceTokenAmount: sourceAmountRaw, + targetAmountMinimum: '0', + targetChainId: '0x1' as Hex, + targetTokenAddress: '0x6666666666666666666666666666666666666666' as Hex, + }, + sourceAmount: { + fiat: '0', + human: '0', + raw: sourceAmountRaw, + usd: '0', + }, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.ts b/packages/transaction-pay-controller/src/strategy/relay/simulation.ts new file mode 100644 index 0000000000..4e8a522388 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/simulation.ts @@ -0,0 +1,857 @@ +import { defaultAbiCoder, Interface } from '@ethersproject/abi'; +import { toHex } from '@metamask/controller-utils'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; +import type { Hex, Json } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { TransactionPayStrategy } from '../../constants'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, + TransactionPayQuoteValidationError, +} from '../../types'; +import { isChainExcludedFromInfura } from '../../utils/feature-flags'; +import { rpcRequest } from '../../utils/provider'; +import { + SentinelSimulationError, + SentinelSimulationResponse, + SentinelSimulationResponseTransaction, + SentinelSimulationTransaction, + simulateTransactions, +} from '../../utils/sentinel'; +import { + getLiveTokenBalance, + getNativeToken, + normalizeTokenAddress, + TokenAddressTarget, +} from '../../utils/token'; +import { + buildRelayExecuteRequest, + buildRelaySubmitParams, +} from './relay-submit'; +import type { RelayExecuteRequest, RelayQuote } from './types'; + +const DELEGATION_PREFIX = '0xef0100'; +const ERC7579_CALL_TYPE_BATCH = '01'; +const ERC7579_EXEC_TYPE_DEFAULT = '00'; +const ERROR_STRING_SELECTOR = '0x08c379a0'; +const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; +const LATEST_BLOCK = 'latest'; +const PANIC_SELECTOR = '0x4e487b71'; +const QUOTE_SIMULATION_FAILED_PREFIX = /^Quote simulation failed\s*[-:]\s*/iu; +const RPC_FALLBACK_UNSUPPORTED_REGEX = + /(does not exist|invalid argument|invalid params|method .*not|not supported|too many arguments|unsupported)/iu; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; + +const erc20Interface = new Interface(abiERC20); +const erc7821Interface = new Interface([ + 'function execute(bytes32 mode, bytes executionData)', +]); + +type ValidateRelayQuoteRequest = { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + signal?: AbortSignal; + transaction: TransactionMeta; +}; + +type RelaySimulationRequest = { + overrides?: Record; + transactions: SentinelSimulationTransaction[]; +}; + +type RpcCallTransaction = { + data?: Hex; + from: Hex; + gas?: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + to?: Hex; + value?: Hex; +}; + +type RpcCallTrace = { + calls?: RpcCallTrace[] | null; + error?: string; + output?: Hex; + revertReason?: string; +}; + +type RpcFallbackSimulationResult = { + error?: string; + isSupported: boolean; +}; + +export class RelayQuoteValidationError extends Error { + readonly validationError: TransactionPayQuoteValidationError; + + constructor(validationError: TransactionPayQuoteValidationError) { + super(validationError.message); + this.name = 'RelayQuoteValidationError'; + this.validationError = validationError; + } +} + +export async function validateRelayQuote({ + messenger, + quote, + signal, + transaction, +}: ValidateRelayQuoteRequest): Promise { + if (shouldSkipValidation(quote)) { + return; + } + + throwIfAborted(signal); + + const liveBalance = await getLiveSourceBalance(quote, messenger); + + throwIfAborted(signal); + + validateRequiredSourceAmount(quote, liveBalance); + + const simulationRequest = await buildRelaySimulationRequest({ + messenger, + quote, + transaction, + }); + + validateDecodedSourceTransfers( + quote, + liveBalance, + simulationRequest.transactions, + ); + + throwIfAborted(signal); + + const response = await simulateRelayQuote({ + messenger, + quote, + request: simulationRequest, + }); + + throwIfAborted(signal); + + await validateSimulationResponse({ + messenger, + quote, + request: simulationRequest, + responseTransactions: response.transactions, + }); +} + +export function isRelayQuoteValidationError( + error: unknown, +): error is RelayQuoteValidationError { + return error instanceof RelayQuoteValidationError; +} + +function shouldSkipValidation(quote: TransactionPayQuote): boolean { + const { request } = quote; + + return Boolean( + request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, + ); +} + +async function getLiveSourceBalance( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, +): Promise { + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + + try { + return await getLiveTokenBalance( + messenger, + from, + sourceChainId, + normalizedSourceTokenAddress, + ); + } catch (error) { + throw new RelayQuoteValidationError({ + chainId: sourceChainId, + code: 'source_balance_unavailable', + message: `Cannot validate payment token balance - ${ + (error as Error).message + }`, + strategy: TransactionPayStrategy.Relay, + tokenAddress: normalizedSourceTokenAddress, + }); + } +} + +function validateRequiredSourceAmount( + quote: TransactionPayQuote, + liveBalance: string, +): void { + if (quote.request.isPostQuote || quote.request.paymentOverride) { + return; + } + + const requiredAmount = new BigNumber(quote.sourceAmount.raw); + const balance = new BigNumber(liveBalance); + + if (balance.isGreaterThanOrEqualTo(requiredAmount)) { + return; + } + + throwInsufficientBalanceError( + quote, + liveBalance, + requiredAmount.toString(10), + 'Insufficient quote source amount', + ); +} + +function validateDecodedSourceTransfers( + quote: TransactionPayQuote, + liveBalance: string, + transactions: SentinelSimulationTransaction[], +): void { + const transferAmounts = getDecodedSourceTransferAmounts(quote, transactions); + const balance = new BigNumber(liveBalance); + + for (const transferAmount of transferAmounts) { + if (balance.isLessThan(transferAmount)) { + throwInsufficientBalanceError( + quote, + liveBalance, + transferAmount, + 'Insufficient balance for decoded quote amount', + ); + } + } +} + +function getDecodedSourceTransferAmounts( + quote: TransactionPayQuote, + transactions: SentinelSimulationTransaction[], +): string[] { + const { sourceChainId, sourceTokenAddress } = quote.request; + const isNativeSource = + sourceTokenAddress.toLowerCase() === + getNativeToken(sourceChainId).toLowerCase(); + + if (isNativeSource) { + return []; + } + + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ).toLowerCase(); + + return transactions + .filter( + (transaction) => + transaction.to && + normalizeTokenAddress( + transaction.to, + sourceChainId, + TokenAddressTarget.MetaMask, + ).toLowerCase() === normalizedSourceTokenAddress, + ) + .map((transaction) => + transaction.data ? decodeTransferAmount(transaction.data) : undefined, + ) + .filter((amount): amount is string => amount !== undefined); +} + +function decodeTransferAmount(data: Hex): string | undefined { + try { + const result = erc20Interface.decodeFunctionData('transfer', data); + return new BigNumber(result._value.toString()).toString(10); + } catch { + return undefined; + } +} + +async function buildRelaySimulationRequest({ + messenger, + quote, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const { allParams } = await buildRelaySubmitParams({ + messenger, + quote, + transaction, + }); + + if (quote.original.metamask.isExecute) { + return await buildRelayExecuteSimulationRequest({ + allParams, + messenger, + quote, + transaction, + }); + } + + if (quote.original.metamask.is7702) { + return buildRelay7702BatchSimulationRequest(quote, allParams); + } + + return { + transactions: allParams.map(toSentinelTransaction), + }; +} + +async function buildRelayExecuteSimulationRequest({ + allParams, + messenger, + quote, + transaction, +}: { + allParams: TransactionParams[]; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const executeRequest = await buildRelayExecuteRequest({ + allParams, + messenger, + quote, + transaction, + }); + validateAuthorizationList(executeRequest, quote); + + const authorizationAddress = + executeRequest.data.authorizationList?.[0]?.address; + const transactionToSimulate = { + data: executeRequest.data.data, + from: quote.request.from, + to: executeRequest.data.to, + value: decimalToHex(executeRequest.data.value), + }; + + return { + ...(authorizationAddress + ? { + overrides: getAccountUpgradeOverride( + quote.request.from, + authorizationAddress, + ), + } + : {}), + transactions: [transactionToSimulate], + }; +} + +function buildRelay7702BatchSimulationRequest( + quote: TransactionPayQuote, + allParams: TransactionParams[], +): RelaySimulationRequest { + const { from } = quote.request; + const authorizationAddress = + quote.original.request.authorizationList?.[0]?.address; + const batchTransaction = buildEip7702BatchTransaction(from, allParams, quote); + + return { + ...(authorizationAddress + ? { overrides: getAccountUpgradeOverride(from, authorizationAddress) } + : {}), + transactions: [batchTransaction], + }; +} + +function buildEip7702BatchTransaction( + from: Hex, + allParams: TransactionParams[], + quote: TransactionPayQuote, +): SentinelSimulationTransaction { + const calls = allParams.map((params) => [ + (params.to as Hex | undefined) ?? ZERO_ADDRESS, + params.value ?? '0x0', + params.data ?? '0x', + ]); + const mode = + `0x${ERC7579_CALL_TYPE_BATCH}${ERC7579_EXEC_TYPE_DEFAULT}`.padEnd( + 66, + '0', + ) as Hex; + const executionData = defaultAbiCoder.encode( + [CALLS_SIGNATURE], + [calls], + ) as Hex; + const gas = quote.original.metamask.gasLimits[0]; + + return { + data: erc7821Interface.encodeFunctionData('execute', [ + mode, + executionData, + ]) as Hex, + from, + ...(gas === undefined ? {} : { gas: toHex(gas) }), + to: from, + value: '0x0', + }; +} + +function validateAuthorizationList( + executeRequest: RelayExecuteRequest, + quote: TransactionPayQuote, +): void { + for (const authorization of executeRequest.data.authorizationList ?? []) { + if ( + authorization.address === undefined || + authorization.chainId === undefined || + authorization.nonce === undefined || + authorization.r === undefined || + authorization.s === undefined || + authorization.yParity === undefined + ) { + throw new RelayQuoteValidationError({ + chainId: quote.request.sourceChainId, + code: 'quote_authorization_invalid', + message: 'Relay execute authorization list is incomplete', + strategy: TransactionPayStrategy.Relay, + tokenAddress: quote.request.sourceTokenAddress, + }); + } + } +} + +async function simulateRelayQuote({ + messenger, + quote, + request, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + request: RelaySimulationRequest; +}): Promise { + try { + return await simulateTransactions(quote.request.sourceChainId, { + ...request, + withCallTrace: true, + withGas: true, + withLogs: true, + }); + } catch (error) { + const fallbackResult = await getFallbackSimulationResult({ + messenger, + quote, + request, + }); + const fallbackError = fallbackResult?.error; + + if (fallbackResult?.isSupported && !fallbackError) { + return { transactions: [{}] }; + } + + if (!fallbackError && isSentinelChainUnsupportedError(error)) { + return { transactions: [{}] }; + } + + const { message } = error as Error; + const code = + fallbackError || + isQuoteSimulationFailure(error) || + !(error instanceof SentinelSimulationError) + ? 'quote_simulation_failed' + : 'quote_validation_unavailable'; + + throw new RelayQuoteValidationError({ + chainId: quote.request.sourceChainId, + code, + message: fallbackError ?? normalizeSimulationErrorMessage(message), + strategy: TransactionPayStrategy.Relay, + tokenAddress: quote.request.sourceTokenAddress, + }); + } +} + +async function validateSimulationResponse({ + messenger, + quote, + request, + responseTransactions, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + request: RelaySimulationRequest; + responseTransactions: SentinelSimulationResponseTransaction[]; +}): Promise { + for (const [index, responseTransaction] of responseTransactions.entries()) { + const error = + responseTransaction.error ?? getCallTraceError(responseTransaction); + + if (!error) { + continue; + } + + const fallbackError = ( + await getFallbackSimulationResult({ + messenger, + quote, + request, + transactionIndex: index, + }) + )?.error; + + throw new RelayQuoteValidationError({ + chainId: quote.request.sourceChainId, + code: 'quote_simulation_failed', + message: fallbackError ?? normalizeSimulationErrorMessage(error), + strategy: TransactionPayStrategy.Relay, + tokenAddress: quote.request.sourceTokenAddress, + }); + } +} + +async function getFallbackSimulationResult({ + messenger, + quote, + request, + transactionIndex = 0, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + request: RelaySimulationRequest; + transactionIndex?: number; +}): Promise { + if (request.transactions.length !== 1) { + return undefined; + } + + const transaction = request.transactions[transactionIndex]; + + if (!transaction) { + return undefined; + } + + const debugTraceCallResult = await getDebugTraceCallResult( + messenger, + quote, + request, + transaction, + ); + + if (debugTraceCallResult?.isSupported) { + return debugTraceCallResult; + } + + return await getEstimateGasResult(messenger, quote, request, transaction); +} + +async function getDebugTraceCallResult( + messenger: TransactionPayControllerMessenger, + quote: TransactionPayQuote, + request: RelaySimulationRequest, + transaction: SentinelSimulationTransaction, +): Promise { + try { + const trace = await rpcRequest({ + messenger, + chainId: quote.request.sourceChainId, + method: 'debug_traceCall', + params: [ + toRpcCallTransaction(transaction), + LATEST_BLOCK, + { + tracer: 'callTracer', + ...(request.overrides ? { stateOverrides: request.overrides } : {}), + }, + ], + options: getRpcFallbackRequestOptions(messenger, quote), + }); + + return { + error: getRpcCallTraceError(trace), + isSupported: true, + }; + } catch (error) { + return getRpcFallbackErrorResult(error); + } +} + +async function getEstimateGasResult( + messenger: TransactionPayControllerMessenger, + quote: TransactionPayQuote, + request: RelaySimulationRequest, + transaction: SentinelSimulationTransaction, +): Promise { + try { + await rpcRequest({ + messenger, + chainId: quote.request.sourceChainId, + method: 'eth_estimateGas', + params: getRpcCallParams(transaction, request), + options: getRpcFallbackRequestOptions(messenger, quote), + }); + } catch (error) { + return getRpcFallbackErrorResult(error); + } + + return { isSupported: true }; +} + +function getRpcFallbackErrorResult( + error: unknown, +): RpcFallbackSimulationResult | undefined { + const message = getRpcErrorMessage(error); + + if (!message || RPC_FALLBACK_UNSUPPORTED_REGEX.test(message)) { + return undefined; + } + + return { + error: normalizeSimulationErrorMessage(message), + isSupported: true, + }; +} + +function getRpcFallbackRequestOptions( + messenger: TransactionPayControllerMessenger, + quote: TransactionPayQuote, +): { preferInfura: boolean } { + return { + preferInfura: !isChainExcludedFromInfura( + messenger, + quote.request.sourceChainId, + ), + }; +} + +function getRpcCallTraceError(trace: RpcCallTrace): string | undefined { + const ownErrors = [ + trace.revertReason, + decodeRevertData(trace.output), + trace.error, + ]; + const nestedErrors = (trace.calls ?? []) + .map((call) => getRpcCallTraceError(call)) + .filter((error): error is string => error !== undefined); + const errors = [...ownErrors, ...nestedErrors] + .filter((error): error is string => error !== undefined) + .map(normalizeSimulationErrorMessage); + + return errors.find((error) => !isGenericSimulationError(error)) ?? errors[0]; +} + +function getRpcErrorMessage(error: unknown): string | undefined { + return ( + decodeRevertData(findRevertData(error)) ?? + findErrorMessage(error)?.replace(/^Error: /u, '') + ); +} + +function findRevertData(value: unknown): Hex | undefined { + if (typeof value === 'string') { + return isRevertData(value) ? (value as Hex) : undefined; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const valueRecord = value as Record; + + return ( + findRevertData(valueRecord.data) ?? + findRevertData(valueRecord.error) ?? + findRevertData(valueRecord.originalError) + ); +} + +function findErrorMessage(value: unknown): string | undefined { + if (value instanceof Error) { + return value.message; + } + + if (typeof value === 'string') { + return value; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const valueRecord = value as Record; + + return ( + findErrorMessage(valueRecord.message) ?? + findErrorMessage(valueRecord.data) ?? + findErrorMessage(valueRecord.error) ?? + findErrorMessage(valueRecord.originalError) + ); +} + +function decodeRevertData(data?: Hex): string | undefined { + if (!data) { + return undefined; + } + + try { + if (data.startsWith(ERROR_STRING_SELECTOR)) { + return defaultAbiCoder + .decode(['string'], `0x${data.slice(ERROR_STRING_SELECTOR.length)}`)[0] + .toString(); + } + + if (data.startsWith(PANIC_SELECTOR)) { + const [code] = defaultAbiCoder.decode( + ['uint256'], + `0x${data.slice(PANIC_SELECTOR.length)}`, + ); + + return `Panic(${code.toString()})`; + } + } catch { + return undefined; + } + + return undefined; +} + +function getRpcCallParams( + transaction: SentinelSimulationTransaction, + request: RelaySimulationRequest, +): Json[] { + const rpcTransaction = toRpcCallTransaction(transaction) as Json; + + return request.overrides + ? [rpcTransaction, LATEST_BLOCK, request.overrides as Json] + : [rpcTransaction, LATEST_BLOCK]; +} + +function toRpcCallTransaction( + transaction: SentinelSimulationTransaction, +): RpcCallTransaction { + return { + data: transaction.data, + from: transaction.from, + gas: transaction.gas, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + to: transaction.to, + value: transaction.value ?? '0x0', + }; +} + +function isQuoteSimulationFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + + return QUOTE_SIMULATION_FAILED_PREFIX.test(message); +} + +function isSentinelChainUnsupportedError(error: unknown): boolean { + return ( + error instanceof SentinelSimulationError && + /^Simulation is not supported for chain /iu.test(error.message) + ); +} + +function normalizeSimulationErrorMessage(message: string): string { + return message.replace(QUOTE_SIMULATION_FAILED_PREFIX, ''); +} + +function isGenericSimulationError(message: string): boolean { + return /^(execution reverted|reverted)$/iu.test(message.trim()); +} + +function isRevertData(value: string): boolean { + return ( + value.startsWith(ERROR_STRING_SELECTOR) || value.startsWith(PANIC_SELECTOR) + ); +} + +function getCallTraceError( + transaction: SentinelSimulationResponseTransaction, +): string | undefined { + return findCallTraceError(transaction.callTrace); +} + +function findCallTraceError( + callTrace: SentinelSimulationResponseTransaction['callTrace'], +): string | undefined { + if (!callTrace) { + return undefined; + } + + if (callTrace.error) { + return callTrace.error; + } + + for (const nestedCall of callTrace.calls ?? []) { + const error = findCallTraceError(nestedCall); + + if (error) { + return error; + } + } + + return undefined; +} + +function toSentinelTransaction( + params: TransactionParams, +): SentinelSimulationTransaction { + return { + data: params.data as Hex | undefined, + from: params.from as Hex, + gas: params.gas as Hex | undefined, + maxFeePerGas: params.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: params.maxPriorityFeePerGas as Hex | undefined, + to: params.to as Hex | undefined, + value: (params.value as Hex | undefined) ?? '0x0', + }; +} + +function getAccountUpgradeOverride( + account: Hex, + delegationAddress: Hex, +): Record { + return { + [account.toLowerCase() as Hex]: { + code: `${DELEGATION_PREFIX}${delegationAddress.slice(2)}` as Hex, + }, + }; +} + +function decimalToHex(value: string): Hex { + return new BigNumber(value).toString(16).replace(/^/u, '0x') as Hex; +} + +function throwInsufficientBalanceError( + quote: TransactionPayQuote, + liveBalance: string, + requiredAmountRaw: string, + message: string, +): never { + const { sourceChainId, sourceTokenAddress } = quote.request; + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + + throw new RelayQuoteValidationError({ + availableAmountRaw: liveBalance, + chainId: sourceChainId, + code: 'insufficient_source_balance', + message, + requiredAmountRaw, + strategy: TransactionPayStrategy.Relay, + tokenAddress: normalizedSourceTokenAddress, + }); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Quote validation aborted'); + } +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 15897ece30..a9f4eb34b2 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -335,6 +335,9 @@ export type TransactionData = { /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; + /** Most relevant validation error from the latest quote attempt. */ + quoteValidationError?: TransactionPayQuoteValidationError; + /** Timestamp of when quotes were last updated. */ quotesLastUpdated?: number; @@ -432,6 +435,51 @@ export type TransactionPaySourceAmount = { targetTokenAddress: Hex; }; +/** Machine-readable reason a quote was rejected during validation. */ +export type TransactionPayQuoteValidationErrorCode = + | 'insufficient_source_balance' + | 'quote_authorization_invalid' + | 'quote_simulation_failed' + | 'quote_validation_unavailable' + | 'relay_execute_validation_failed' + | 'source_approval_reverted' + | 'source_balance_unavailable' + | 'source_token_contract_missing' + | 'source_transfer_reverted'; + +/** Validation error suitable for clients to surface instead of a generic no-quotes message. */ +export type TransactionPayQuoteValidationError = { + /** Machine-readable validation failure code. */ + code: TransactionPayQuoteValidationErrorCode; + + /** Human-readable fallback message. Clients may map `code` to localized copy. */ + message: string; + + /** Strategy whose quote failed validation. */ + strategy: TransactionPayStrategy; + + /** Available source token balance, if known. */ + availableAmountRaw?: string; + + /** Chain ID associated with the failed source token or transaction. */ + chainId?: Hex; + + /** Source token amount required by the quote, if known. */ + requiredAmountRaw?: string; + + /** Source token address associated with the failed quote, if known. */ + tokenAddress?: Hex; +}; + +/** Result returned by a strategy's post-quote support check. */ +export type PayStrategyQuoteSupportResult = { + /** Whether the quote set is supported and can be presented to the user. */ + isSupported: boolean; + + /** Validation error explaining why the quote set was rejected. */ + validationError?: TransactionPayQuoteValidationError; +}; + /** Source token used to pay for required tokens. */ export type TransactionPaymentToken = { /** Address of the payment token. */ @@ -696,7 +744,10 @@ export type PayStrategy = { */ checkQuoteSupport?: ( request: PayStrategyCheckQuoteSupportRequest, - ) => boolean | Promise; + ) => + | boolean + | PayStrategyQuoteSupportResult + | Promise; /** Retrieve batch transactions for quotes, if supported by the strategy. */ getBatchTransactions?: ( diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 69fe267549..b4499d5747 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -178,10 +178,12 @@ describe('Quotes Utils', () => { checkStrategyQuoteSupportMock.mockImplementation( async (strategy, request) => { if (strategy.checkQuoteSupport) { - return await strategy.checkQuoteSupport(request); + const result = await strategy.checkQuoteSupport(request); + + return typeof result === 'boolean' ? { isSupported: result } : result; } - return true; + return { isSupported: true }; }, ); @@ -212,6 +214,53 @@ describe('Quotes Utils', () => { }); }); + it('stores quote validation errors when quotes are rejected', async () => { + const validationError = { + code: 'insufficient_source_balance' as const, + message: 'Insufficient source token balance for quote', + strategy: TransactionPayStrategy.Test, + }; + + checkStrategyQuoteSupportMock.mockResolvedValue({ + isSupported: false, + validationError, + }); + + await run(); + + const transactionDataMock = {}; + + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quoteValidationError: validationError, + }); + }); + + it('clears quote validation error when quote loading starts', async () => { + const validationError = { + code: 'insufficient_source_balance' as const, + message: 'Insufficient source token balance for quote', + strategy: TransactionPayStrategy.Test, + }; + + await run(); + + const transactionDataMock = { + quoteValidationError: validationError, + }; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toStrictEqual({ + isLoading: true, + quoteValidationError: undefined, + }); + }); + it('clears quotes in state if no source amounts', async () => { await run({ transactionData: { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 098911a2de..2a6443df7b 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -11,6 +11,7 @@ import type { TransactionData, TransactionPayControllerMessenger, TransactionPayQuote, + TransactionPayQuoteValidationError, TransactionPayRequiredToken, TransactionPaySourceAmount, TransactionPayTotals, @@ -101,6 +102,7 @@ export async function updateQuotes( updateTransactionData(transactionId, (data) => { data.isLoading = true; + data.quoteValidationError = undefined; }); try { @@ -134,7 +136,7 @@ export async function updateQuotes( const supports7702 = accountSupports7702(messenger, from); - const { batchTransactions, quotes } = await getQuotes( + const { batchTransactions, quotes, validationError } = await getQuotes( transaction, from, requests, @@ -173,6 +175,7 @@ export async function updateQuotes( updateTransactionData(transactionId, (data) => { data.quotes = quotes as never; + data.quoteValidationError = quotes.length ? undefined : validationError; data.quotesLastUpdated = Date.now(); data.totals = totals; }); @@ -597,6 +600,7 @@ async function getQuotes( ): Promise<{ batchTransactions: BatchTransaction[]; quotes: TransactionPayQuote[]; + validationError?: TransactionPayQuoteValidationError; }> { const { id: transactionId } = transaction; const strategies = getStrategiesByName( @@ -626,6 +630,8 @@ async function getQuotes( transaction, }; + let validationError: TransactionPayQuoteValidationError | undefined; + for (const { name, strategy } of strategies) { try { const support = await checkStrategySupport(strategy, request); @@ -654,7 +660,9 @@ async function getQuotes( transaction, }); - if (!quoteSupport) { + if (!quoteSupport.isSupported) { + validationError ??= quoteSupport.validationError; + log('Strategy does not support quotes', { strategy: name, transactionId, @@ -697,5 +705,6 @@ async function getQuotes( return { batchTransactions: [], quotes: [], + validationError, }; } diff --git a/packages/transaction-pay-controller/src/utils/sentinel.ts b/packages/transaction-pay-controller/src/utils/sentinel.ts new file mode 100644 index 0000000000..710163d44a --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/sentinel.ts @@ -0,0 +1,121 @@ +import { hexToBigInt, createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { projectLogger } from '../logger'; + +const RPC_METHOD = 'infura_simulateTransactions'; +const BASE_URL = 'https://tx-sentinel-{0}.api.cx.metamask.io/'; +const ENDPOINT_NETWORKS = 'networks'; + +const log = createModuleLogger(projectLogger, 'sentinel'); + +type SentinelNetwork = { + confirmations?: boolean; + network: string; +}; + +type SentinelNetworkResponse = Record; + +export type SentinelSimulationTransaction = { + data?: Hex; + from: Hex; + gas?: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + to?: Hex; + value?: Hex; +}; + +export type SentinelSimulationRequest = { + overrides?: Record< + Hex, + { + code?: Hex; + stateDiff?: Record; + } + >; + transactions: SentinelSimulationTransaction[]; + withCallTrace?: boolean; + withGas?: boolean; + withLogs?: boolean; +}; + +export type SentinelSimulationResponseTransaction = { + callTrace?: SentinelSimulationCallTrace; + error?: string; + gasLimit?: Hex; + gasUsed?: Hex; +}; + +export type SentinelSimulationCallTrace = { + calls?: SentinelSimulationCallTrace[] | null; + error?: string; + output?: Hex; +}; + +export type SentinelSimulationResponse = { + transactions: SentinelSimulationResponseTransaction[]; +}; + +export class SentinelSimulationError extends Error { + readonly code?: number; + + constructor(message: string, code?: number) { + super(message); + this.name = 'SentinelSimulationError'; + this.code = code; + } +} + +export async function simulateTransactions( + chainId: Hex, + request: SentinelSimulationRequest, +): Promise { + const url = await getSimulationUrl(chainId); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: '0', + jsonrpc: '2.0', + method: RPC_METHOD, + params: [request], + }), + }); + const responseJson = await response.json(); + + log('Simulation response', { chainId, responseJson }); + + if (responseJson.error) { + const { code, message } = responseJson.error; + throw new SentinelSimulationError(message, code); + } + + return responseJson.result; +} + +async function getSimulationUrl(chainId: Hex): Promise { + const networkData = await getNetworkData(); + const network = networkData[Number(hexToBigInt(chainId)).toString(10)]; + + if (!network?.confirmations) { + throw new SentinelSimulationError( + `Simulation is not supported for chain ${chainId}`, + ); + } + + return getUrl(network.network); +} + +async function getNetworkData(): Promise { + const response = await fetch( + `${getUrl('ethereum-mainnet')}${ENDPOINT_NETWORKS}`, + ); + return await response.json(); +} + +function getUrl(subdomain: string): string { + return BASE_URL.replace('{0}', subdomain); +} diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index b968c0712b..82438b4f1e 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -9,6 +9,7 @@ import type { PayStrategy, PayStrategyCheckQuoteSupportRequest, PayStrategyGetQuotesRequest, + PayStrategyQuoteSupportResult, } from '../types'; export type NamedStrategy = { @@ -106,10 +107,16 @@ export async function checkStrategySupport( export async function checkStrategyQuoteSupport( strategy: PayStrategy, request: PayStrategyCheckQuoteSupportRequest, -): Promise { - if (strategy.checkQuoteSupport) { - return await strategy.checkQuoteSupport(request); +): Promise { + if (!strategy.checkQuoteSupport) { + return { isSupported: true }; + } + + const result = await strategy.checkQuoteSupport(request); + + if (typeof result === 'boolean') { + return { isSupported: result }; } - return true; + return result; } From d4c09152da48b77db393700d9d9f0995460b3e8d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sat, 13 Jun 2026 22:27:20 +0100 Subject: [PATCH 2/6] refactor(transaction-pay-controller): generalize quote validation --- .../src/strategy/relay/RelayStrategy.test.ts | 53 +- .../src/strategy/relay/RelayStrategy.ts | 30 +- .../src/strategy/relay/simulation.test.ts | 41 +- .../src/strategy/relay/simulation.ts | 711 +----------------- .../src/utils/simulation.ts | 440 +++++++++++ .../src/utils/validation.ts | 242 ++++++ 6 files changed, 799 insertions(+), 718 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/simulation.ts create mode 100644 packages/transaction-pay-controller/src/utils/validation.ts diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts index d517f31633..2fe9c1a970 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -7,25 +7,29 @@ import type { TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { + isQuoteValidationError, + validateQuoteExecution, +} from '../../utils/validation'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; import { RelayStrategy } from './RelayStrategy'; -import { isRelayQuoteValidationError, validateRelayQuote } from './simulation'; +import { buildRelaySimulation } from './simulation'; import type { RelayQuote } from './types'; jest.mock('./relay-quotes'); jest.mock('./simulation'); jest.mock('./relay-submit'); jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/validation'); describe('RelayStrategy', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); - const isRelayQuoteValidationErrorMock = jest.mocked( - isRelayQuoteValidationError, - ); + const buildRelaySimulationMock = jest.mocked(buildRelaySimulation); + const isQuoteValidationErrorMock = jest.mocked(isQuoteValidationError); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); - const validateRelayQuoteMock = jest.mocked(validateRelayQuote); + const validateQuoteExecutionMock = jest.mocked(validateQuoteExecution); const messenger = {} as never; @@ -64,8 +68,9 @@ describe('RelayStrategy', () => { enabled: true, }, }); - isRelayQuoteValidationErrorMock.mockReturnValue(false); - validateRelayQuoteMock.mockResolvedValue(undefined); + buildRelaySimulationMock.mockResolvedValue({ transactions: [] }); + isQuoteValidationErrorMock.mockReturnValue(false); + validateQuoteExecutionMock.mockResolvedValue(undefined); }); it('returns true from supports when relay is enabled', () => { @@ -102,9 +107,7 @@ describe('RelayStrategy', () => { }); it('validates quotes in checkQuoteSupport', async () => { - const quote = { - strategy: TransactionPayStrategy.Relay, - } as TransactionPayQuote; + const quote = buildQuote(); const strategy = new RelayStrategy(); const result = await strategy.checkQuoteSupport({ @@ -114,12 +117,17 @@ describe('RelayStrategy', () => { }); expect(result).toStrictEqual({ isSupported: true }); - expect(validateRelayQuoteMock).toHaveBeenCalledWith({ + expect(buildRelaySimulationMock).toHaveBeenCalledWith({ messenger, quote, - signal: undefined, transaction: request.transaction, }); + expect(validateQuoteExecutionMock).toHaveBeenCalledWith({ + messenger, + quote, + signal: undefined, + simulation: { transactions: [] }, + }); }); it('returns quote validation errors from checkQuoteSupport', async () => { @@ -128,13 +136,11 @@ describe('RelayStrategy', () => { message: 'Insufficient source token balance for quote', strategy: TransactionPayStrategy.Relay, } as const; - const quote = { - strategy: TransactionPayStrategy.Relay, - } as TransactionPayQuote; + const quote = buildQuote(); const error = { validationError } as unknown as Error; - isRelayQuoteValidationErrorMock.mockReturnValue(true); - validateRelayQuoteMock.mockRejectedValue(error); + isQuoteValidationErrorMock.mockReturnValue(true); + validateQuoteExecutionMock.mockRejectedValue(error); const strategy = new RelayStrategy(); const result = await strategy.checkQuoteSupport({ @@ -158,7 +164,8 @@ describe('RelayStrategy', () => { strategy: TransactionPayStrategy.Relay, } as TransactionPayQuote; - validateRelayQuoteMock.mockRejectedValue(new Error('RPC down')); + buildRelaySimulationMock.mockResolvedValue({ transactions: [] }); + validateQuoteExecutionMock.mockRejectedValue(new Error('RPC down')); const strategy = new RelayStrategy(); const result = await strategy.checkQuoteSupport({ @@ -179,6 +186,16 @@ describe('RelayStrategy', () => { }); }); + function buildQuote(): TransactionPayQuote { + return { + request: { + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + }, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + } + it('delegates execute', async () => { const executeRequest = { messenger, diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts index 219e6ab682..e0c007d76d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -8,9 +8,13 @@ import type { TransactionPayQuoteValidationError, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { + isQuoteValidationError, + validateQuoteExecution, +} from '../../utils/validation'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; -import { isRelayQuoteValidationError, validateRelayQuote } from './simulation'; +import { buildRelaySimulation } from './simulation'; import type { RelayQuote } from './types'; export class RelayStrategy implements PayStrategy { @@ -29,13 +33,23 @@ export class RelayStrategy implements PayStrategy { request: PayStrategyCheckQuoteSupportRequest, ): Promise { for (const quote of request.quotes) { + if (shouldSkipValidation(quote)) { + continue; + } + try { - await validateRelayQuote({ + const simulation = await buildRelaySimulation({ messenger: request.messenger, quote, - signal: request.signal, transaction: request.transaction, }); + + await validateQuoteExecution({ + messenger: request.messenger, + quote, + signal: request.signal, + simulation, + }); } catch (error) { if (request.signal?.aborted) { throw error; @@ -63,11 +77,19 @@ export class RelayStrategy implements PayStrategy { } } +function shouldSkipValidation(quote: TransactionPayQuote): boolean { + const { request } = quote; + + return Boolean( + request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, + ); +} + function getValidationError( quote: TransactionPayQuote, error: unknown, ): TransactionPayQuoteValidationError { - if (isRelayQuoteValidationError(error)) { + if (isQuoteValidationError(error)) { return error.validationError; } diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts index 6bd5268be7..8e3d721ffc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts @@ -5,7 +5,10 @@ import type { Hex } from '@metamask/utils'; import { TransactionPayStrategy } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; -import type { TransactionPayQuote } from '../../types'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; import { getFeatureFlags } from '../../utils/feature-flags'; import { rpcRequest } from '../../utils/provider'; import { @@ -14,10 +17,10 @@ import { } from '../../utils/sentinel'; import { getLiveTokenBalance } from '../../utils/token'; import { - isRelayQuoteValidationError, - RelayQuoteValidationError, - validateRelayQuote, -} from './simulation'; + isQuoteValidationError, + validateQuoteExecution, +} from '../../utils/validation'; +import { buildRelaySimulation, RelayQuoteValidationError } from './simulation'; import type { RelayQuote } from './types'; jest.mock('../../utils/feature-flags', () => ({ @@ -50,6 +53,8 @@ const FROM_MOCK = '0x1111111111111111111111111111111111111111' as Hex; const TOKEN_ADDRESS_MOCK = '0x2222222222222222222222222222222222222222' as Hex; const RECIPIENT_MOCK = '0x3333333333333333333333333333333333333333' as Hex; const CHAIN_ID_MOCK = '0x38' as Hex; +const EIP7702_DELEGATOR_ADDRESS = + '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b' as Hex; const TRANSACTION_MOCK = { id: 'tx-id', chainId: CHAIN_ID_MOCK, @@ -415,7 +420,7 @@ describe('Relay quote simulation validation', () => { expect.objectContaining({ overrides: { [FROM_MOCK.toLowerCase()]: { - code: `0xef0100${authorizationAddress.slice(2)}`, + code: `0xef0100${EIP7702_DELEGATOR_ADDRESS.slice(2)}`, }, }, transactions: [ @@ -437,7 +442,7 @@ describe('Relay quote simulation validation', () => { strategy: TransactionPayStrategy.Relay, }); - expect(isRelayQuoteValidationError(error)).toBe(true); + expect(isQuoteValidationError(error)).toBe(true); }); }); @@ -510,3 +515,25 @@ function buildQuote({ strategy: TransactionPayStrategy.Relay, } as TransactionPayQuote; } + +async function validateRelayQuote({ + messenger, + quote, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const simulation = await buildRelaySimulation({ + messenger, + quote, + transaction, + }); + + await validateQuoteExecution({ + messenger, + quote, + simulation, + }); +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.ts b/packages/transaction-pay-controller/src/strategy/relay/simulation.ts index 4e8a522388..19a5950269 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/simulation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/simulation.ts @@ -1,282 +1,38 @@ import { defaultAbiCoder, Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; -import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { TransactionMeta, TransactionParams, } from '@metamask/transaction-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { TransactionPayStrategy } from '../../constants'; import type { TransactionPayControllerMessenger, TransactionPayQuote, - TransactionPayQuoteValidationError, } from '../../types'; -import { isChainExcludedFromInfura } from '../../utils/feature-flags'; -import { rpcRequest } from '../../utils/provider'; -import { - SentinelSimulationError, - SentinelSimulationResponse, - SentinelSimulationResponseTransaction, - SentinelSimulationTransaction, - simulateTransactions, -} from '../../utils/sentinel'; -import { - getLiveTokenBalance, - getNativeToken, - normalizeTokenAddress, - TokenAddressTarget, -} from '../../utils/token'; +import type { SimulationTransaction } from '../../utils/simulation'; +import { QuoteValidationError } from '../../utils/validation'; +import type { QuoteValidationSimulation } from '../../utils/validation'; import { buildRelayExecuteRequest, buildRelaySubmitParams, } from './relay-submit'; import type { RelayExecuteRequest, RelayQuote } from './types'; -const DELEGATION_PREFIX = '0xef0100'; const ERC7579_CALL_TYPE_BATCH = '01'; const ERC7579_EXEC_TYPE_DEFAULT = '00'; -const ERROR_STRING_SELECTOR = '0x08c379a0'; const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; -const LATEST_BLOCK = 'latest'; -const PANIC_SELECTOR = '0x4e487b71'; -const QUOTE_SIMULATION_FAILED_PREFIX = /^Quote simulation failed\s*[-:]\s*/iu; -const RPC_FALLBACK_UNSUPPORTED_REGEX = - /(does not exist|invalid argument|invalid params|method .*not|not supported|too many arguments|unsupported)/iu; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; -const erc20Interface = new Interface(abiERC20); const erc7821Interface = new Interface([ 'function execute(bytes32 mode, bytes executionData)', ]); -type ValidateRelayQuoteRequest = { - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - signal?: AbortSignal; - transaction: TransactionMeta; -}; - -type RelaySimulationRequest = { - overrides?: Record; - transactions: SentinelSimulationTransaction[]; -}; - -type RpcCallTransaction = { - data?: Hex; - from: Hex; - gas?: Hex; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - to?: Hex; - value?: Hex; -}; - -type RpcCallTrace = { - calls?: RpcCallTrace[] | null; - error?: string; - output?: Hex; - revertReason?: string; -}; +export { QuoteValidationError as RelayQuoteValidationError }; -type RpcFallbackSimulationResult = { - error?: string; - isSupported: boolean; -}; - -export class RelayQuoteValidationError extends Error { - readonly validationError: TransactionPayQuoteValidationError; - - constructor(validationError: TransactionPayQuoteValidationError) { - super(validationError.message); - this.name = 'RelayQuoteValidationError'; - this.validationError = validationError; - } -} - -export async function validateRelayQuote({ - messenger, - quote, - signal, - transaction, -}: ValidateRelayQuoteRequest): Promise { - if (shouldSkipValidation(quote)) { - return; - } - - throwIfAborted(signal); - - const liveBalance = await getLiveSourceBalance(quote, messenger); - - throwIfAborted(signal); - - validateRequiredSourceAmount(quote, liveBalance); - - const simulationRequest = await buildRelaySimulationRequest({ - messenger, - quote, - transaction, - }); - - validateDecodedSourceTransfers( - quote, - liveBalance, - simulationRequest.transactions, - ); - - throwIfAborted(signal); - - const response = await simulateRelayQuote({ - messenger, - quote, - request: simulationRequest, - }); - - throwIfAborted(signal); - - await validateSimulationResponse({ - messenger, - quote, - request: simulationRequest, - responseTransactions: response.transactions, - }); -} - -export function isRelayQuoteValidationError( - error: unknown, -): error is RelayQuoteValidationError { - return error instanceof RelayQuoteValidationError; -} - -function shouldSkipValidation(quote: TransactionPayQuote): boolean { - const { request } = quote; - - return Boolean( - request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, - ); -} - -async function getLiveSourceBalance( - quote: TransactionPayQuote, - messenger: TransactionPayControllerMessenger, -): Promise { - const { from, sourceChainId, sourceTokenAddress } = quote.request; - const normalizedSourceTokenAddress = normalizeTokenAddress( - sourceTokenAddress, - sourceChainId, - TokenAddressTarget.MetaMask, - ); - - try { - return await getLiveTokenBalance( - messenger, - from, - sourceChainId, - normalizedSourceTokenAddress, - ); - } catch (error) { - throw new RelayQuoteValidationError({ - chainId: sourceChainId, - code: 'source_balance_unavailable', - message: `Cannot validate payment token balance - ${ - (error as Error).message - }`, - strategy: TransactionPayStrategy.Relay, - tokenAddress: normalizedSourceTokenAddress, - }); - } -} - -function validateRequiredSourceAmount( - quote: TransactionPayQuote, - liveBalance: string, -): void { - if (quote.request.isPostQuote || quote.request.paymentOverride) { - return; - } - - const requiredAmount = new BigNumber(quote.sourceAmount.raw); - const balance = new BigNumber(liveBalance); - - if (balance.isGreaterThanOrEqualTo(requiredAmount)) { - return; - } - - throwInsufficientBalanceError( - quote, - liveBalance, - requiredAmount.toString(10), - 'Insufficient quote source amount', - ); -} - -function validateDecodedSourceTransfers( - quote: TransactionPayQuote, - liveBalance: string, - transactions: SentinelSimulationTransaction[], -): void { - const transferAmounts = getDecodedSourceTransferAmounts(quote, transactions); - const balance = new BigNumber(liveBalance); - - for (const transferAmount of transferAmounts) { - if (balance.isLessThan(transferAmount)) { - throwInsufficientBalanceError( - quote, - liveBalance, - transferAmount, - 'Insufficient balance for decoded quote amount', - ); - } - } -} - -function getDecodedSourceTransferAmounts( - quote: TransactionPayQuote, - transactions: SentinelSimulationTransaction[], -): string[] { - const { sourceChainId, sourceTokenAddress } = quote.request; - const isNativeSource = - sourceTokenAddress.toLowerCase() === - getNativeToken(sourceChainId).toLowerCase(); - - if (isNativeSource) { - return []; - } - - const normalizedSourceTokenAddress = normalizeTokenAddress( - sourceTokenAddress, - sourceChainId, - TokenAddressTarget.MetaMask, - ).toLowerCase(); - - return transactions - .filter( - (transaction) => - transaction.to && - normalizeTokenAddress( - transaction.to, - sourceChainId, - TokenAddressTarget.MetaMask, - ).toLowerCase() === normalizedSourceTokenAddress, - ) - .map((transaction) => - transaction.data ? decodeTransferAmount(transaction.data) : undefined, - ) - .filter((amount): amount is string => amount !== undefined); -} - -function decodeTransferAmount(data: Hex): string | undefined { - try { - const result = erc20Interface.decodeFunctionData('transfer', data); - return new BigNumber(result._value.toString()).toString(10); - } catch { - return undefined; - } -} - -async function buildRelaySimulationRequest({ +export async function buildRelaySimulation({ messenger, quote, transaction, @@ -284,7 +40,7 @@ async function buildRelaySimulationRequest({ messenger: TransactionPayControllerMessenger; quote: TransactionPayQuote; transaction: TransactionMeta; -}): Promise { +}): Promise { const { allParams } = await buildRelaySubmitParams({ messenger, quote, @@ -292,7 +48,7 @@ async function buildRelaySimulationRequest({ }); if (quote.original.metamask.isExecute) { - return await buildRelayExecuteSimulationRequest({ + return await buildRelayExecuteSimulation({ allParams, messenger, quote, @@ -301,15 +57,15 @@ async function buildRelaySimulationRequest({ } if (quote.original.metamask.is7702) { - return buildRelay7702BatchSimulationRequest(quote, allParams); + return buildRelay7702BatchSimulation(quote, allParams); } return { - transactions: allParams.map(toSentinelTransaction), + transactions: allParams.map(toSimulationTransaction), }; } -async function buildRelayExecuteSimulationRequest({ +async function buildRelayExecuteSimulation({ allParams, messenger, quote, @@ -319,7 +75,7 @@ async function buildRelayExecuteSimulationRequest({ messenger: TransactionPayControllerMessenger; quote: TransactionPayQuote; transaction: TransactionMeta; -}): Promise { +}): Promise { const executeRequest = await buildRelayExecuteRequest({ allParams, messenger, @@ -328,8 +84,6 @@ async function buildRelayExecuteSimulationRequest({ }); validateAuthorizationList(executeRequest, quote); - const authorizationAddress = - executeRequest.data.authorizationList?.[0]?.address; const transactionToSimulate = { data: executeRequest.data.data, from: quote.request.from, @@ -338,30 +92,23 @@ async function buildRelayExecuteSimulationRequest({ }; return { - ...(authorizationAddress - ? { - overrides: getAccountUpgradeOverride( - quote.request.from, - authorizationAddress, - ), - } + ...(executeRequest.data.authorizationList?.length + ? { mock7702From: quote.request.from } : {}), transactions: [transactionToSimulate], }; } -function buildRelay7702BatchSimulationRequest( +function buildRelay7702BatchSimulation( quote: TransactionPayQuote, allParams: TransactionParams[], -): RelaySimulationRequest { +): QuoteValidationSimulation { const { from } = quote.request; - const authorizationAddress = - quote.original.request.authorizationList?.[0]?.address; const batchTransaction = buildEip7702BatchTransaction(from, allParams, quote); return { - ...(authorizationAddress - ? { overrides: getAccountUpgradeOverride(from, authorizationAddress) } + ...(quote.original.request.authorizationList?.length + ? { mock7702From: from } : {}), transactions: [batchTransaction], }; @@ -371,7 +118,7 @@ function buildEip7702BatchTransaction( from: Hex, allParams: TransactionParams[], quote: TransactionPayQuote, -): SentinelSimulationTransaction { +): SimulationTransaction { const calls = allParams.map((params) => [ (params.to as Hex | undefined) ?? ZERO_ADDRESS, params.value ?? '0x0', @@ -413,7 +160,7 @@ function validateAuthorizationList( authorization.s === undefined || authorization.yParity === undefined ) { - throw new RelayQuoteValidationError({ + throw new QuoteValidationError({ chainId: quote.request.sourceChainId, code: 'quote_authorization_invalid', message: 'Relay execute authorization list is incomplete', @@ -424,382 +171,9 @@ function validateAuthorizationList( } } -async function simulateRelayQuote({ - messenger, - quote, - request, -}: { - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - request: RelaySimulationRequest; -}): Promise { - try { - return await simulateTransactions(quote.request.sourceChainId, { - ...request, - withCallTrace: true, - withGas: true, - withLogs: true, - }); - } catch (error) { - const fallbackResult = await getFallbackSimulationResult({ - messenger, - quote, - request, - }); - const fallbackError = fallbackResult?.error; - - if (fallbackResult?.isSupported && !fallbackError) { - return { transactions: [{}] }; - } - - if (!fallbackError && isSentinelChainUnsupportedError(error)) { - return { transactions: [{}] }; - } - - const { message } = error as Error; - const code = - fallbackError || - isQuoteSimulationFailure(error) || - !(error instanceof SentinelSimulationError) - ? 'quote_simulation_failed' - : 'quote_validation_unavailable'; - - throw new RelayQuoteValidationError({ - chainId: quote.request.sourceChainId, - code, - message: fallbackError ?? normalizeSimulationErrorMessage(message), - strategy: TransactionPayStrategy.Relay, - tokenAddress: quote.request.sourceTokenAddress, - }); - } -} - -async function validateSimulationResponse({ - messenger, - quote, - request, - responseTransactions, -}: { - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - request: RelaySimulationRequest; - responseTransactions: SentinelSimulationResponseTransaction[]; -}): Promise { - for (const [index, responseTransaction] of responseTransactions.entries()) { - const error = - responseTransaction.error ?? getCallTraceError(responseTransaction); - - if (!error) { - continue; - } - - const fallbackError = ( - await getFallbackSimulationResult({ - messenger, - quote, - request, - transactionIndex: index, - }) - )?.error; - - throw new RelayQuoteValidationError({ - chainId: quote.request.sourceChainId, - code: 'quote_simulation_failed', - message: fallbackError ?? normalizeSimulationErrorMessage(error), - strategy: TransactionPayStrategy.Relay, - tokenAddress: quote.request.sourceTokenAddress, - }); - } -} - -async function getFallbackSimulationResult({ - messenger, - quote, - request, - transactionIndex = 0, -}: { - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - request: RelaySimulationRequest; - transactionIndex?: number; -}): Promise { - if (request.transactions.length !== 1) { - return undefined; - } - - const transaction = request.transactions[transactionIndex]; - - if (!transaction) { - return undefined; - } - - const debugTraceCallResult = await getDebugTraceCallResult( - messenger, - quote, - request, - transaction, - ); - - if (debugTraceCallResult?.isSupported) { - return debugTraceCallResult; - } - - return await getEstimateGasResult(messenger, quote, request, transaction); -} - -async function getDebugTraceCallResult( - messenger: TransactionPayControllerMessenger, - quote: TransactionPayQuote, - request: RelaySimulationRequest, - transaction: SentinelSimulationTransaction, -): Promise { - try { - const trace = await rpcRequest({ - messenger, - chainId: quote.request.sourceChainId, - method: 'debug_traceCall', - params: [ - toRpcCallTransaction(transaction), - LATEST_BLOCK, - { - tracer: 'callTracer', - ...(request.overrides ? { stateOverrides: request.overrides } : {}), - }, - ], - options: getRpcFallbackRequestOptions(messenger, quote), - }); - - return { - error: getRpcCallTraceError(trace), - isSupported: true, - }; - } catch (error) { - return getRpcFallbackErrorResult(error); - } -} - -async function getEstimateGasResult( - messenger: TransactionPayControllerMessenger, - quote: TransactionPayQuote, - request: RelaySimulationRequest, - transaction: SentinelSimulationTransaction, -): Promise { - try { - await rpcRequest({ - messenger, - chainId: quote.request.sourceChainId, - method: 'eth_estimateGas', - params: getRpcCallParams(transaction, request), - options: getRpcFallbackRequestOptions(messenger, quote), - }); - } catch (error) { - return getRpcFallbackErrorResult(error); - } - - return { isSupported: true }; -} - -function getRpcFallbackErrorResult( - error: unknown, -): RpcFallbackSimulationResult | undefined { - const message = getRpcErrorMessage(error); - - if (!message || RPC_FALLBACK_UNSUPPORTED_REGEX.test(message)) { - return undefined; - } - - return { - error: normalizeSimulationErrorMessage(message), - isSupported: true, - }; -} - -function getRpcFallbackRequestOptions( - messenger: TransactionPayControllerMessenger, - quote: TransactionPayQuote, -): { preferInfura: boolean } { - return { - preferInfura: !isChainExcludedFromInfura( - messenger, - quote.request.sourceChainId, - ), - }; -} - -function getRpcCallTraceError(trace: RpcCallTrace): string | undefined { - const ownErrors = [ - trace.revertReason, - decodeRevertData(trace.output), - trace.error, - ]; - const nestedErrors = (trace.calls ?? []) - .map((call) => getRpcCallTraceError(call)) - .filter((error): error is string => error !== undefined); - const errors = [...ownErrors, ...nestedErrors] - .filter((error): error is string => error !== undefined) - .map(normalizeSimulationErrorMessage); - - return errors.find((error) => !isGenericSimulationError(error)) ?? errors[0]; -} - -function getRpcErrorMessage(error: unknown): string | undefined { - return ( - decodeRevertData(findRevertData(error)) ?? - findErrorMessage(error)?.replace(/^Error: /u, '') - ); -} - -function findRevertData(value: unknown): Hex | undefined { - if (typeof value === 'string') { - return isRevertData(value) ? (value as Hex) : undefined; - } - - if (!value || typeof value !== 'object') { - return undefined; - } - - const valueRecord = value as Record; - - return ( - findRevertData(valueRecord.data) ?? - findRevertData(valueRecord.error) ?? - findRevertData(valueRecord.originalError) - ); -} - -function findErrorMessage(value: unknown): string | undefined { - if (value instanceof Error) { - return value.message; - } - - if (typeof value === 'string') { - return value; - } - - if (!value || typeof value !== 'object') { - return undefined; - } - - const valueRecord = value as Record; - - return ( - findErrorMessage(valueRecord.message) ?? - findErrorMessage(valueRecord.data) ?? - findErrorMessage(valueRecord.error) ?? - findErrorMessage(valueRecord.originalError) - ); -} - -function decodeRevertData(data?: Hex): string | undefined { - if (!data) { - return undefined; - } - - try { - if (data.startsWith(ERROR_STRING_SELECTOR)) { - return defaultAbiCoder - .decode(['string'], `0x${data.slice(ERROR_STRING_SELECTOR.length)}`)[0] - .toString(); - } - - if (data.startsWith(PANIC_SELECTOR)) { - const [code] = defaultAbiCoder.decode( - ['uint256'], - `0x${data.slice(PANIC_SELECTOR.length)}`, - ); - - return `Panic(${code.toString()})`; - } - } catch { - return undefined; - } - - return undefined; -} - -function getRpcCallParams( - transaction: SentinelSimulationTransaction, - request: RelaySimulationRequest, -): Json[] { - const rpcTransaction = toRpcCallTransaction(transaction) as Json; - - return request.overrides - ? [rpcTransaction, LATEST_BLOCK, request.overrides as Json] - : [rpcTransaction, LATEST_BLOCK]; -} - -function toRpcCallTransaction( - transaction: SentinelSimulationTransaction, -): RpcCallTransaction { - return { - data: transaction.data, - from: transaction.from, - gas: transaction.gas, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, - to: transaction.to, - value: transaction.value ?? '0x0', - }; -} - -function isQuoteSimulationFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - - return QUOTE_SIMULATION_FAILED_PREFIX.test(message); -} - -function isSentinelChainUnsupportedError(error: unknown): boolean { - return ( - error instanceof SentinelSimulationError && - /^Simulation is not supported for chain /iu.test(error.message) - ); -} - -function normalizeSimulationErrorMessage(message: string): string { - return message.replace(QUOTE_SIMULATION_FAILED_PREFIX, ''); -} - -function isGenericSimulationError(message: string): boolean { - return /^(execution reverted|reverted)$/iu.test(message.trim()); -} - -function isRevertData(value: string): boolean { - return ( - value.startsWith(ERROR_STRING_SELECTOR) || value.startsWith(PANIC_SELECTOR) - ); -} - -function getCallTraceError( - transaction: SentinelSimulationResponseTransaction, -): string | undefined { - return findCallTraceError(transaction.callTrace); -} - -function findCallTraceError( - callTrace: SentinelSimulationResponseTransaction['callTrace'], -): string | undefined { - if (!callTrace) { - return undefined; - } - - if (callTrace.error) { - return callTrace.error; - } - - for (const nestedCall of callTrace.calls ?? []) { - const error = findCallTraceError(nestedCall); - - if (error) { - return error; - } - } - - return undefined; -} - -function toSentinelTransaction( +function toSimulationTransaction( params: TransactionParams, -): SentinelSimulationTransaction { +): SimulationTransaction { return { data: params.data as Hex | undefined, from: params.from as Hex, @@ -811,47 +185,6 @@ function toSentinelTransaction( }; } -function getAccountUpgradeOverride( - account: Hex, - delegationAddress: Hex, -): Record { - return { - [account.toLowerCase() as Hex]: { - code: `${DELEGATION_PREFIX}${delegationAddress.slice(2)}` as Hex, - }, - }; -} - function decimalToHex(value: string): Hex { return new BigNumber(value).toString(16).replace(/^/u, '0x') as Hex; } - -function throwInsufficientBalanceError( - quote: TransactionPayQuote, - liveBalance: string, - requiredAmountRaw: string, - message: string, -): never { - const { sourceChainId, sourceTokenAddress } = quote.request; - const normalizedSourceTokenAddress = normalizeTokenAddress( - sourceTokenAddress, - sourceChainId, - TokenAddressTarget.MetaMask, - ); - - throw new RelayQuoteValidationError({ - availableAmountRaw: liveBalance, - chainId: sourceChainId, - code: 'insufficient_source_balance', - message, - requiredAmountRaw, - strategy: TransactionPayStrategy.Relay, - tokenAddress: normalizedSourceTokenAddress, - }); -} - -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new Error('Quote validation aborted'); - } -} diff --git a/packages/transaction-pay-controller/src/utils/simulation.ts b/packages/transaction-pay-controller/src/utils/simulation.ts new file mode 100644 index 0000000000..fe02f2b897 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/simulation.ts @@ -0,0 +1,440 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import type { Hex, Json } from '@metamask/utils'; + +import type { + TransactionPayControllerMessenger, + TransactionPayQuoteValidationErrorCode, +} from '../types'; +import { isChainExcludedFromInfura } from './feature-flags'; +import { rpcRequest } from './provider'; +import { + SentinelSimulationError, + SentinelSimulationResponseTransaction, + SentinelSimulationTransaction, + simulateTransactions, +} from './sentinel'; + +const DELEGATION_PREFIX = '0xef0100'; +const ERROR_STRING_SELECTOR = '0x08c379a0'; +const EIP7702_DELEGATOR_ADDRESS = + '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b' as Hex; +const LATEST_BLOCK = 'latest'; +const PANIC_SELECTOR = '0x4e487b71'; +const QUOTE_SIMULATION_FAILED_PREFIX = /^Quote simulation failed\s*[-:]\s*/iu; +const RPC_FALLBACK_UNSUPPORTED_REGEX = + /(does not exist|invalid argument|invalid params|method .*not|not supported|too many arguments|unsupported)/iu; + +export type SimulationTransaction = SentinelSimulationTransaction; + +export type SimulationRequest = { + chainId: Hex; + messenger: TransactionPayControllerMessenger; + mock7702From?: Hex; + transactions: SimulationTransaction[]; +}; + +type RpcCallTransaction = { + data?: Hex; + from: Hex; + gas?: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + to?: Hex; + value?: Hex; +}; + +type RpcCallTrace = { + calls?: RpcCallTrace[] | null; + error?: string; + output?: Hex; + revertReason?: string; +}; + +type RpcFallbackSimulationResult = { + error?: string; + isSupported: boolean; +}; + +type SimulationRequestWithOverrides = SimulationRequest & { + overrides?: Record; +}; + +export class TransactionPaySimulationError extends Error { + readonly code: TransactionPayQuoteValidationErrorCode; + + constructor(code: TransactionPayQuoteValidationErrorCode, message: string) { + super(message); + this.name = 'TransactionPaySimulationError'; + this.code = code; + } +} + +export async function simulateQuoteTransactions( + request: SimulationRequest, +): Promise { + const requestWithOverrides = addSimulationOverrides(request); + let responseTransactions: SentinelSimulationResponseTransaction[]; + + try { + const response = await simulateTransactions(request.chainId, { + ...toSentinelRequest(requestWithOverrides), + withCallTrace: true, + withGas: true, + withLogs: true, + }); + responseTransactions = response.transactions; + } catch (error) { + const fallbackResult = + await getFallbackSimulationResult(requestWithOverrides); + const fallbackError = fallbackResult?.error; + + if (fallbackResult?.isSupported && !fallbackError) { + return; + } + + if (!fallbackError && isSentinelChainUnsupportedError(error)) { + return; + } + + const { message } = error as Error; + const code = + fallbackError || + isQuoteSimulationFailure(error) || + !(error instanceof SentinelSimulationError) + ? 'quote_simulation_failed' + : 'quote_validation_unavailable'; + + throw new TransactionPaySimulationError( + code, + fallbackError ?? normalizeSimulationErrorMessage(message), + ); + } + + await validateSimulationResponse(requestWithOverrides, responseTransactions); +} + +async function validateSimulationResponse( + request: SimulationRequestWithOverrides, + responseTransactions: SentinelSimulationResponseTransaction[], +): Promise { + for (const [index, responseTransaction] of responseTransactions.entries()) { + const error = + responseTransaction.error ?? getCallTraceError(responseTransaction); + + if (!error) { + continue; + } + + const fallbackError = (await getFallbackSimulationResult(request, index)) + ?.error; + + throw new TransactionPaySimulationError( + 'quote_simulation_failed', + fallbackError ?? normalizeSimulationErrorMessage(error), + ); + } +} + +function addSimulationOverrides( + request: SimulationRequest, +): SimulationRequestWithOverrides { + const { mock7702From } = request; + + if (!mock7702From) { + return request; + } + + return { + ...request, + overrides: getAccountUpgradeOverride(mock7702From), + }; +} + +function toSentinelRequest(request: SimulationRequestWithOverrides): { + overrides?: Record; + transactions: SimulationTransaction[]; +} { + return { + ...(request.overrides ? { overrides: request.overrides } : {}), + transactions: request.transactions, + }; +} + +async function getFallbackSimulationResult( + request: SimulationRequestWithOverrides, + transactionIndex = 0, +): Promise { + if (request.transactions.length !== 1) { + return undefined; + } + + const transaction = request.transactions[transactionIndex]; + + if (!transaction) { + return undefined; + } + + const debugTraceCallResult = await getDebugTraceCallResult( + request, + transaction, + ); + + if (debugTraceCallResult?.isSupported) { + return debugTraceCallResult; + } + + return await getEstimateGasResult(request, transaction); +} + +async function getDebugTraceCallResult( + request: SimulationRequestWithOverrides, + transaction: SimulationTransaction, +): Promise { + try { + const trace = await rpcRequest({ + messenger: request.messenger, + chainId: request.chainId, + method: 'debug_traceCall', + params: [ + toRpcCallTransaction(transaction), + LATEST_BLOCK, + { + tracer: 'callTracer', + ...(request.overrides ? { stateOverrides: request.overrides } : {}), + }, + ], + options: getRpcFallbackRequestOptions(request), + }); + + return { + error: getRpcCallTraceError(trace), + isSupported: true, + }; + } catch (error) { + return getRpcFallbackErrorResult(error); + } +} + +async function getEstimateGasResult( + request: SimulationRequestWithOverrides, + transaction: SimulationTransaction, +): Promise { + try { + await rpcRequest({ + messenger: request.messenger, + chainId: request.chainId, + method: 'eth_estimateGas', + params: getRpcCallParams(transaction, request), + options: getRpcFallbackRequestOptions(request), + }); + } catch (error) { + return getRpcFallbackErrorResult(error); + } + + return { isSupported: true }; +} + +function getRpcFallbackErrorResult( + error: unknown, +): RpcFallbackSimulationResult | undefined { + const message = getRpcErrorMessage(error); + + if (!message || RPC_FALLBACK_UNSUPPORTED_REGEX.test(message)) { + return undefined; + } + + return { + error: normalizeSimulationErrorMessage(message), + isSupported: true, + }; +} + +function getRpcFallbackRequestOptions(request: SimulationRequest): { + preferInfura: boolean; +} { + return { + preferInfura: !isChainExcludedFromInfura( + request.messenger, + request.chainId, + ), + }; +} + +function getRpcCallTraceError(trace: RpcCallTrace): string | undefined { + const ownErrors = [ + trace.revertReason, + decodeRevertData(trace.output), + trace.error, + ]; + const nestedErrors = (trace.calls ?? []) + .map((call) => getRpcCallTraceError(call)) + .filter((error): error is string => error !== undefined); + const errors = [...ownErrors, ...nestedErrors] + .filter((error): error is string => error !== undefined) + .map(normalizeSimulationErrorMessage); + + return errors.find((error) => !isGenericSimulationError(error)) ?? errors[0]; +} + +function getRpcErrorMessage(error: unknown): string | undefined { + return ( + decodeRevertData(findRevertData(error)) ?? + findErrorMessage(error)?.replace(/^Error: /u, '') + ); +} + +function findRevertData(value: unknown): Hex | undefined { + if (typeof value === 'string') { + return isRevertData(value) ? (value as Hex) : undefined; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const valueRecord = value as Record; + + return ( + findRevertData(valueRecord.data) ?? + findRevertData(valueRecord.error) ?? + findRevertData(valueRecord.originalError) + ); +} + +function findErrorMessage(value: unknown): string | undefined { + if (value instanceof Error) { + return value.message; + } + + if (typeof value === 'string') { + return value; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const valueRecord = value as Record; + + return ( + findErrorMessage(valueRecord.message) ?? + findErrorMessage(valueRecord.data) ?? + findErrorMessage(valueRecord.error) ?? + findErrorMessage(valueRecord.originalError) + ); +} + +function decodeRevertData(data?: Hex): string | undefined { + if (!data) { + return undefined; + } + + try { + if (data.startsWith(ERROR_STRING_SELECTOR)) { + return defaultAbiCoder + .decode(['string'], `0x${data.slice(ERROR_STRING_SELECTOR.length)}`)[0] + .toString(); + } + + if (data.startsWith(PANIC_SELECTOR)) { + const [code] = defaultAbiCoder.decode( + ['uint256'], + `0x${data.slice(PANIC_SELECTOR.length)}`, + ); + + return `Panic(${code.toString()})`; + } + } catch { + return undefined; + } + + return undefined; +} + +function getRpcCallParams( + transaction: SimulationTransaction, + request: SimulationRequestWithOverrides, +): Json[] { + const rpcTransaction = toRpcCallTransaction(transaction) as Json; + + return request.overrides + ? [rpcTransaction, LATEST_BLOCK, request.overrides as Json] + : [rpcTransaction, LATEST_BLOCK]; +} + +function toRpcCallTransaction( + transaction: SimulationTransaction, +): RpcCallTransaction { + return { + data: transaction.data, + from: transaction.from, + gas: transaction.gas, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + to: transaction.to, + value: transaction.value ?? '0x0', + }; +} + +function isQuoteSimulationFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + + return QUOTE_SIMULATION_FAILED_PREFIX.test(message); +} + +function isSentinelChainUnsupportedError(error: unknown): boolean { + return ( + error instanceof SentinelSimulationError && + /^Simulation is not supported for chain /iu.test(error.message) + ); +} + +function normalizeSimulationErrorMessage(message: string): string { + return message.replace(QUOTE_SIMULATION_FAILED_PREFIX, ''); +} + +function isGenericSimulationError(message: string): boolean { + return /^(execution reverted|reverted)$/iu.test(message.trim()); +} + +function isRevertData(value: string): boolean { + return ( + value.startsWith(ERROR_STRING_SELECTOR) || value.startsWith(PANIC_SELECTOR) + ); +} + +function getCallTraceError( + transaction: SentinelSimulationResponseTransaction, +): string | undefined { + return findCallTraceError(transaction.callTrace); +} + +function findCallTraceError( + callTrace: SentinelSimulationResponseTransaction['callTrace'], +): string | undefined { + if (!callTrace) { + return undefined; + } + + if (callTrace.error) { + return callTrace.error; + } + + for (const nestedCall of callTrace.calls ?? []) { + const error = findCallTraceError(nestedCall); + + if (error) { + return error; + } + } + + return undefined; +} + +function getAccountUpgradeOverride(account: Hex): Record { + return { + [account.toLowerCase() as Hex]: { + code: `${DELEGATION_PREFIX}${EIP7702_DELEGATOR_ADDRESS.slice(2)}` as Hex, + }, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/validation.ts b/packages/transaction-pay-controller/src/utils/validation.ts new file mode 100644 index 0000000000..cc5a6189c8 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/validation.ts @@ -0,0 +1,242 @@ +import { Interface } from '@ethersproject/abi'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, + TransactionPayQuoteValidationError, +} from '../types'; +import { + SimulationTransaction, + simulateQuoteTransactions, + TransactionPaySimulationError, +} from './simulation'; +import { + getLiveTokenBalance, + getNativeToken, + normalizeTokenAddress, + TokenAddressTarget, +} from './token'; + +const erc20Interface = new Interface(abiERC20); + +export type QuoteValidationSimulation = { + mock7702From?: Hex; + transactions: SimulationTransaction[]; +}; + +export type QuoteValidationRequest = { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + signal?: AbortSignal; + simulation: QuoteValidationSimulation; +}; + +export class QuoteValidationError extends Error { + readonly validationError: TransactionPayQuoteValidationError; + + constructor(validationError: TransactionPayQuoteValidationError) { + super(validationError.message); + this.name = 'QuoteValidationError'; + this.validationError = validationError; + } +} + +export async function validateQuoteExecution({ + messenger, + quote, + signal, + simulation, +}: QuoteValidationRequest): Promise { + throwIfAborted(signal); + + const liveBalance = await getLiveSourceBalance(quote, messenger); + + throwIfAborted(signal); + + validateRequiredSourceAmount(quote, liveBalance); + validateDecodedSourceTransfers(quote, liveBalance, simulation.transactions); + + throwIfAborted(signal); + + try { + await simulateQuoteTransactions({ + chainId: quote.request.sourceChainId, + messenger, + mock7702From: simulation.mock7702From, + transactions: simulation.transactions, + }); + } catch (error) { + throwIfAborted(signal); + + if (error instanceof TransactionPaySimulationError) { + throw new QuoteValidationError({ + chainId: quote.request.sourceChainId, + code: error.code, + message: error.message, + strategy: quote.strategy, + tokenAddress: quote.request.sourceTokenAddress, + }); + } + + throw error; + } +} + +export function isQuoteValidationError( + error: unknown, +): error is QuoteValidationError { + return error instanceof QuoteValidationError; +} + +async function getLiveSourceBalance( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, +): Promise { + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + + try { + return await getLiveTokenBalance( + messenger, + from, + sourceChainId, + normalizedSourceTokenAddress, + ); + } catch (error) { + throw new QuoteValidationError({ + chainId: sourceChainId, + code: 'source_balance_unavailable', + message: `Cannot validate payment token balance - ${ + (error as Error).message + }`, + strategy: quote.strategy, + tokenAddress: normalizedSourceTokenAddress, + }); + } +} + +function validateRequiredSourceAmount( + quote: TransactionPayQuote, + liveBalance: string, +): void { + if (quote.request.isPostQuote || quote.request.paymentOverride) { + return; + } + + const requiredAmount = new BigNumber(quote.sourceAmount.raw); + const balance = new BigNumber(liveBalance); + + if (balance.isGreaterThanOrEqualTo(requiredAmount)) { + return; + } + + throwInsufficientBalanceError( + quote, + liveBalance, + requiredAmount.toString(10), + 'Insufficient quote source amount', + ); +} + +function validateDecodedSourceTransfers( + quote: TransactionPayQuote, + liveBalance: string, + transactions: SimulationTransaction[], +): void { + const requiredAmount = getDecodedSourceTransferAmounts(quote, transactions) + .reduce((total, amount) => total.plus(amount), new BigNumber(0)) + .toString(10); + const balance = new BigNumber(liveBalance); + + if (balance.isGreaterThanOrEqualTo(requiredAmount)) { + return; + } + + throwInsufficientBalanceError( + quote, + liveBalance, + requiredAmount, + 'Insufficient balance for decoded quote amount', + ); +} + +function getDecodedSourceTransferAmounts( + quote: TransactionPayQuote, + transactions: SimulationTransaction[], +): string[] { + const { sourceChainId, sourceTokenAddress } = quote.request; + const isNativeSource = + sourceTokenAddress.toLowerCase() === + getNativeToken(sourceChainId).toLowerCase(); + + if (isNativeSource) { + return []; + } + + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ).toLowerCase(); + + return transactions + .filter( + (transaction) => + transaction.to && + normalizeTokenAddress( + transaction.to, + sourceChainId, + TokenAddressTarget.MetaMask, + ).toLowerCase() === normalizedSourceTokenAddress, + ) + .map((transaction) => + transaction.data ? decodeTransferAmount(transaction.data) : undefined, + ) + .filter((amount): amount is string => amount !== undefined); +} + +function decodeTransferAmount(data: Hex): string | undefined { + try { + const result = erc20Interface.decodeFunctionData('transfer', data); + return new BigNumber(result._value.toString()).toString(10); + } catch { + return undefined; + } +} + +function throwInsufficientBalanceError( + quote: TransactionPayQuote, + liveBalance: string, + requiredAmountRaw: string, + message: string, +): never { + const { sourceChainId, sourceTokenAddress } = quote.request; + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + + throw new QuoteValidationError({ + availableAmountRaw: liveBalance, + chainId: sourceChainId, + code: 'insufficient_source_balance', + message, + requiredAmountRaw, + strategy: quote.strategy, + tokenAddress: normalizedSourceTokenAddress, + }); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Quote validation aborted'); + } +} From 7478ea1e8c798e9e460ac59ab7db4d95ed34e442 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 15:00:32 +0100 Subject: [PATCH 3/6] test(transaction-pay-controller): cover quote validation simulation --- .../src/strategy/relay/RelayStrategy.test.ts | 40 +- .../src/strategy/relay/simulation.test.ts | 287 +++++++++++++- .../src/utils/sentinel.test.ts | 181 +++++++++ .../src/utils/simulation.test.ts | 375 ++++++++++++++++++ .../src/utils/strategy.test.ts | 30 +- 5 files changed, 905 insertions(+), 8 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/sentinel.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/simulation.test.ts diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts index 2fe9c1a970..85ea4630cc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -130,6 +130,41 @@ describe('RelayStrategy', () => { }); }); + it('skips validation for Hyperliquid source quotes', async () => { + const quote = buildQuote({ isHyperliquidSource: true }); + + const strategy = new RelayStrategy(); + const result = await strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + transaction: request.transaction, + }); + + expect(result).toStrictEqual({ isSupported: true }); + expect(buildRelaySimulationMock).not.toHaveBeenCalled(); + expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); + }); + + it('rethrows validation errors when the abort signal is aborted', async () => { + const quote = buildQuote(); + const controller = new AbortController(); + const error = new Error('Quote validation aborted'); + + controller.abort(); + validateQuoteExecutionMock.mockRejectedValue(error); + + const strategy = new RelayStrategy(); + + await expect( + strategy.checkQuoteSupport({ + messenger, + quotes: [quote], + signal: controller.signal, + transaction: request.transaction, + }), + ).rejects.toThrow(error); + }); + it('returns quote validation errors from checkQuoteSupport', async () => { const validationError = { code: 'insufficient_source_balance', @@ -186,11 +221,14 @@ describe('RelayStrategy', () => { }); }); - function buildQuote(): TransactionPayQuote { + function buildQuote( + requestOverrides: Partial['request']> = {}, + ): TransactionPayQuote { return { request: { sourceChainId: '0x1' as Hex, sourceTokenAddress: '0xabc' as Hex, + ...requestOverrides, }, strategy: TransactionPayStrategy.Relay, } as TransactionPayQuote; diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts index 8e3d721ffc..dd2666337d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts @@ -3,7 +3,7 @@ import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { TransactionPayStrategy } from '../../constants'; +import { NATIVE_TOKEN_ADDRESS, TransactionPayStrategy } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { TransactionPayControllerMessenger, @@ -60,6 +60,10 @@ const TRANSACTION_MOCK = { chainId: CHAIN_ID_MOCK, txParams: { from: FROM_MOCK }, } as TransactionMeta; +const TRANSACTION_WITH_TO_MOCK = { + ...TRANSACTION_MOCK, + txParams: { ...TRANSACTION_MOCK.txParams, to: TOKEN_ADDRESS_MOCK }, +} as TransactionMeta; describe('Relay quote simulation validation', () => { const getFeatureFlagsMock = jest.mocked(getFeatureFlags); @@ -68,7 +72,9 @@ describe('Relay quote simulation validation', () => { const simulateTransactionsMock = jest.mocked(simulateTransactions); const { findNetworkClientIdByChainIdMock, + getControllerStateMock, getDelegationTransactionMock, + getPaymentOverrideDataMock, getRemoteFeatureFlagControllerStateMock, messenger, } = getMessengerMock(); @@ -82,6 +88,7 @@ describe('Relay quote simulation validation', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ remoteFeatureFlags: {}, } as never); + getControllerStateMock.mockReturnValue({ transactionData: {} } as never); getLiveTokenBalanceMock.mockResolvedValue('1000'); rpcRequestMock.mockResolvedValue({} as never); simulateTransactionsMock.mockResolvedValue({ transactions: [{}] }); @@ -435,6 +442,29 @@ describe('Relay quote simulation validation', () => { ); }); + it('simulates Relay execute quotes without an account override when authorization list is absent', async () => { + getDelegationTransactionMock.mockResolvedValue({ + data: '0x1234' as Hex, + to: '0x5555555555555555555555555555555555555555' as Hex, + value: '0', + }); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isExecute: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.not.objectContaining({ overrides: expect.any(Object) }), + ); + }); + it('identifies relay quote validation errors', async () => { const error = new RelayQuoteValidationError({ code: 'quote_simulation_failed', @@ -444,17 +474,259 @@ describe('Relay quote simulation validation', () => { expect(isQuoteValidationError(error)).toBe(true); }); + + it('simulates is7702 quotes as a single batch execute transaction sent to the from address', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + is7702: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + transactions: [ + expect.objectContaining({ + from: FROM_MOCK, + to: FROM_MOCK, + value: '0x0', + }), + ], + }), + ); + }); + + it('adds the first Relay gas limit to is7702 batch simulations when provided', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + gasLimits: [123], + is7702: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + transactions: [expect.objectContaining({ gas: '0x7b' })], + }), + ); + }); + + it('uses default call values for incomplete payment override calls in is7702 batch simulations', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [{ from: FROM_MOCK }], + } as never); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + is7702: true, + paymentOverride: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + transactions: [expect.objectContaining({ to: FROM_MOCK })], + }), + ); + }); + + it('sets mock7702From on is7702 simulations when the quote request has an authorization list', async () => { + const authorizationEntry = { + address: '0x4444444444444444444444444444444444444444' as Hex, + chainId: CHAIN_ID_MOCK, + nonce: '0x1' as Hex, + r: '0x1' as Hex, + s: '0x2' as Hex, + yParity: '0x0' as Hex, + }; + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + is7702: true, + requestAuthorizationList: [authorizationEntry], + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + overrides: { + [FROM_MOCK.toLowerCase()]: { + code: `0xef0100${EIP7702_DELEGATOR_ADDRESS.slice(2)}`, + }, + }, + }), + ); + }); + + it('throws quote_authorization_invalid when Relay execute authorization list has an incomplete entry', async () => { + getDelegationTransactionMock.mockResolvedValue({ + authorizationList: [ + { + // address intentionally omitted to trigger validation failure + chainId: CHAIN_ID_MOCK, + nonce: '0x1', + r: '0x1' as Hex, + s: '0x2' as Hex, + yParity: '0x0', + }, + ], + data: '0x1234' as Hex, + to: '0x5555555555555555555555555555555555555555' as Hex, + value: '0x0' as Hex, + } as never); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ + isExecute: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: { + code: 'quote_authorization_invalid', + message: 'Relay execute authorization list is incomplete', + }, + }); + }); + + it('rethrows non-simulation errors from simulateQuoteTransactions', async () => { + simulateTransactionsMock.mockResolvedValue({ transactions: null as never }); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow(TypeError); + }); + + it('skips required source amount check for post-quote requests', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isPostQuote: true, + sourceAmountRaw: '9999', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledTimes(1); + }); + + it('skips decoded ERC-20 transfer check for native token source quotes', async () => { + getLiveTokenBalanceMock.mockResolvedValue('500'); + + await validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '300', + sourceTokenAddress: NATIVE_TOKEN_ADDRESS, + transferAmountRaw: '300', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledTimes(1); + }); + + it('defaults missing value and ignores missing data on prepended post-quote transactions', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isPostQuote: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_WITH_TO_MOCK, + }); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + data: undefined, + to: TOKEN_ADDRESS_MOCK, + value: '0x0', + }), + ]), + }), + ); + }); + + it('throws when the abort signal is already aborted before validation starts', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + signal: controller.signal, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Quote validation aborted'); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + }); }); function buildQuote({ isExecute = false, + is7702 = false, + isPostQuote = false, + gasLimits = [], + paymentOverride, + requestAuthorizationList, sourceAmountRaw, + sourceTokenAddress = TOKEN_ADDRESS_MOCK, stepData, transactionCount = 1, transferAmountRaw, }: { isExecute?: boolean; + is7702?: boolean; + isPostQuote?: boolean; + gasLimits?: number[]; + paymentOverride?: boolean; + requestAuthorizationList?: { + address: Hex; + chainId: Hex; + nonce: Hex; + r: Hex; + s: Hex; + yParity: Hex; + }[]; sourceAmountRaw: string; + sourceTokenAddress?: Hex; stepData?: Hex; transactionCount?: number; transferAmountRaw: string; @@ -465,8 +737,10 @@ function buildQuote({ currencyIn: { currency: { chainId: Number(CHAIN_ID_MOCK) } }, currencyOut: { currency: { chainId: 1 } }, }, - metamask: { gasLimits: [], isExecute, is7702: false }, - request: {}, + metamask: { gasLimits, isExecute, is7702 }, + request: requestAuthorizationList + ? { authorizationList: requestAuthorizationList } + : {}, steps: [ { id: 'deposit', @@ -498,9 +772,11 @@ function buildQuote({ } as RelayQuote, request: { from: FROM_MOCK, + isPostQuote, + paymentOverride, sourceBalanceRaw: '1000', sourceChainId: CHAIN_ID_MOCK, - sourceTokenAddress: TOKEN_ADDRESS_MOCK, + sourceTokenAddress, sourceTokenAmount: sourceAmountRaw, targetAmountMinimum: '0', targetChainId: '0x1' as Hex, @@ -519,10 +795,12 @@ function buildQuote({ async function validateRelayQuote({ messenger, quote, + signal, transaction, }: { messenger: TransactionPayControllerMessenger; quote: TransactionPayQuote; + signal?: AbortSignal; transaction: TransactionMeta; }): Promise { const simulation = await buildRelaySimulation({ @@ -534,6 +812,7 @@ async function validateRelayQuote({ await validateQuoteExecution({ messenger, quote, + signal, simulation, }); } diff --git a/packages/transaction-pay-controller/src/utils/sentinel.test.ts b/packages/transaction-pay-controller/src/utils/sentinel.test.ts new file mode 100644 index 0000000000..e4204ffaaf --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/sentinel.test.ts @@ -0,0 +1,181 @@ +import type { Hex } from '@metamask/utils'; + +import { SentinelSimulationError, simulateTransactions } from './sentinel'; + +const CHAIN_ID_MOCK = '0x1' as Hex; +const CHAIN_ID_UNSUPPORTED_MOCK = '0x999' as Hex; +const NETWORK_SUBDOMAIN_MOCK = 'ethereum-mainnet'; +const SIMULATION_URL_MOCK = `https://tx-sentinel-${NETWORK_SUBDOMAIN_MOCK}.api.cx.metamask.io/`; + +const NETWORKS_RESPONSE_MOCK = { + '1': { confirmations: true, network: NETWORK_SUBDOMAIN_MOCK }, +}; + +function buildFetchMock( + networksBody: unknown, + simulationBody: unknown, +): jest.Mock { + return jest + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve(networksBody), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve(simulationBody), + }); +} + +describe('Sentinel', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('simulateTransactions', () => { + it('fetches network list then posts simulation and returns result', async () => { + const transactionsMock = [{ error: undefined }]; + + fetchMock = buildFetchMock(NETWORKS_RESPONSE_MOCK, { + result: { transactions: transactionsMock }, + }); + global.fetch = fetchMock; + + const result = await simulateTransactions(CHAIN_ID_MOCK, { + transactions: [ + { from: '0x1111111111111111111111111111111111111111' as Hex }, + ], + }); + + expect(result).toStrictEqual({ transactions: transactionsMock }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/networks', + ); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + SIMULATION_URL_MOCK, + expect.objectContaining({ method: 'POST' }), + ); + + const body = JSON.parse( + (fetchMock.mock.calls[1][1] as { body: string }).body, + ); + + expect(body).toStrictEqual({ + id: '0', + jsonrpc: '2.0', + method: 'infura_simulateTransactions', + params: [ + { + transactions: [ + { from: '0x1111111111111111111111111111111111111111' }, + ], + }, + ], + }); + }); + + it('throws SentinelSimulationError when response contains error', async () => { + fetchMock = buildFetchMock(NETWORKS_RESPONSE_MOCK, { + error: { code: -32000, message: 'Internal server error' }, + }); + global.fetch = fetchMock; + + await expect( + simulateTransactions(CHAIN_ID_MOCK, { transactions: [] }), + ).rejects.toMatchObject({ + name: 'SentinelSimulationError', + message: 'Internal server error', + code: -32000, + }); + }); + + it('throws SentinelSimulationError when chain is not in network data', async () => { + fetchMock = jest.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({}), + }); + global.fetch = fetchMock; + + await expect( + simulateTransactions(CHAIN_ID_UNSUPPORTED_MOCK, { transactions: [] }), + ).rejects.toMatchObject({ + name: 'SentinelSimulationError', + message: `Simulation is not supported for chain ${CHAIN_ID_UNSUPPORTED_MOCK}`, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws SentinelSimulationError when network has no confirmations flag', async () => { + fetchMock = jest.fn().mockResolvedValueOnce({ + json: () => + Promise.resolve({ + '2471': { network: 'some-network' }, + }), + }); + global.fetch = fetchMock; + + await expect( + simulateTransactions('0x9a7' as Hex, { transactions: [] }), + ).rejects.toMatchObject({ + name: 'SentinelSimulationError', + message: 'Simulation is not supported for chain 0x9a7', + }); + }); + + it('passes optional simulation fields through to the request body', async () => { + fetchMock = buildFetchMock(NETWORKS_RESPONSE_MOCK, { + result: { transactions: [] }, + }); + global.fetch = fetchMock; + + const overrides = { + ['0xaaaa' as Hex]: { code: '0xbbbb' as Hex }, + }; + + await simulateTransactions(CHAIN_ID_MOCK, { + overrides, + transactions: [], + withCallTrace: true, + withGas: true, + withLogs: true, + }); + + const body = JSON.parse( + (fetchMock.mock.calls[1][1] as { body: string }).body, + ); + + expect(body.params[0]).toStrictEqual({ + overrides, + transactions: [], + withCallTrace: true, + withGas: true, + withLogs: true, + }); + }); + }); + + describe('SentinelSimulationError', () => { + it('preserves name, message, and optional code', () => { + const error = new SentinelSimulationError('boom', 42); + + expect(error.name).toBe('SentinelSimulationError'); + expect(error.message).toBe('boom'); + expect(error.code).toBe(42); + }); + + it('allows code to be omitted', () => { + const error = new SentinelSimulationError('no code'); + + expect(error.code).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/simulation.test.ts b/packages/transaction-pay-controller/src/utils/simulation.test.ts new file mode 100644 index 0000000000..f285c63fab --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/simulation.test.ts @@ -0,0 +1,375 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '../types'; +import { isChainExcludedFromInfura } from './feature-flags'; +import { rpcRequest } from './provider'; +import { SentinelSimulationError, simulateTransactions } from './sentinel'; +import { simulateQuoteTransactions } from './simulation'; +import type { SimulationRequest } from './simulation'; + +jest.mock('./feature-flags', () => ({ + ...jest.requireActual('./feature-flags'), + isChainExcludedFromInfura: jest.fn(), +})); +jest.mock('./provider', () => ({ + ...jest.requireActual('./provider'), + rpcRequest: jest.fn(), +})); +jest.mock('./sentinel', () => ({ + ...jest.requireActual('./sentinel'), + simulateTransactions: jest.fn(), +})); + +const CHAIN_ID_MOCK = '0x38' as Hex; +const FROM_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const TOKEN_ADDRESS_MOCK = '0x2222222222222222222222222222222222222222' as Hex; +const EIP7702_DELEGATOR_ADDRESS = + '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b' as Hex; + +const CHAIN_UNSUPPORTED_ERROR = new SentinelSimulationError( + `Simulation is not supported for chain ${CHAIN_ID_MOCK}`, +); + +const ERROR_STRING_SELECTOR = '0x08c379a0'; +const PANIC_SELECTOR = '0x4e487b71'; + +const ERROR_DATA_MOCK = + `${ERROR_STRING_SELECTOR}${defaultAbiCoder.encode(['string'], ['ERC20: transfer failed']).slice(2)}` as Hex; +const PANIC_DATA_MOCK = `${PANIC_SELECTOR}${'0'.repeat(64)}` as Hex; +const MALFORMED_ERROR_SELECTOR_MOCK = ERROR_STRING_SELECTOR as Hex; +const UNKNOWN_SELECTOR_DATA_MOCK = `0xdeadbeef${'0'.repeat(64)}` as Hex; + +function buildRequest( + overrides?: Partial, +): SimulationRequest { + return { + chainId: CHAIN_ID_MOCK, + messenger: null as unknown as TransactionPayControllerMessenger, + transactions: [{ from: FROM_MOCK, to: TOKEN_ADDRESS_MOCK }], + ...overrides, + }; +} + +describe('simulateQuoteTransactions', () => { + const isChainExcludedFromInfuraMock = jest.mocked(isChainExcludedFromInfura); + const rpcRequestMock = jest.mocked(rpcRequest); + const simulateTransactionsMock = jest.mocked(simulateTransactions); + + beforeEach(() => { + jest.resetAllMocks(); + isChainExcludedFromInfuraMock.mockReturnValue(false); + rpcRequestMock.mockResolvedValue({} as never); + simulateTransactionsMock.mockResolvedValue({ transactions: [{}] }); + }); + + describe('catch block — Sentinel throws', () => { + it('throws quote_simulation_failed when Sentinel throws a generic non-SentinelSimulationError', async () => { + simulateTransactionsMock.mockRejectedValue(new Error('network timeout')); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockRejectedValueOnce( + new Error('method eth_estimateGas not supported'), + ); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + name: 'TransactionPaySimulationError', + code: 'quote_simulation_failed', + }); + }); + + it('throws quote_validation_unavailable when Sentinel fails with a non-critical SentinelSimulationError and both fallbacks are unavailable', async () => { + simulateTransactionsMock.mockRejectedValue( + new SentinelSimulationError('Internal server error'), + ); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockRejectedValueOnce( + new Error('method eth_estimateGas not supported'), + ); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + name: 'TransactionPaySimulationError', + code: 'quote_validation_unavailable', + message: 'Internal server error', + }); + }); + + it('throws quote_simulation_failed with fallback error when debug_traceCall error has a non-revert string in .data', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ data: '0x1234abcd' }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: '0x1234abcd', + }); + }); + + it('throws quote_simulation_failed with Panic decode when debug_traceCall error .data is a PANIC selector', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ data: PANIC_DATA_MOCK }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'Panic(0)', + }); + }); + + it('throws quote_simulation_failed with decoded ABI error when debug_traceCall trace output is an ERROR_STRING_SELECTOR', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockResolvedValueOnce({ output: ERROR_DATA_MOCK }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'ERC20: transfer failed', + }); + }); + + it('uses fallback error message from trace .error when debug_traceCall trace has a direct error', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockResolvedValueOnce({ error: 'fallback route error' }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'fallback route error', + }); + }); + + it('is non-blocking when debug_traceCall trace output is a malformed error selector', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockResolvedValueOnce({ + output: MALFORMED_ERROR_SELECTOR_MOCK, + }); + + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); + }); + + it('is non-blocking when debug_traceCall trace output has an unrecognized selector', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockResolvedValueOnce({ + output: UNKNOWN_SELECTOR_DATA_MOCK, + }); + + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); + }); + + it('uses isQuoteSimulationFailure with String() when the thrown error is not an Error instance', async () => { + simulateTransactionsMock.mockRejectedValue({ + code: 999, + message: 'custom error', + }); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockRejectedValueOnce( + new Error('method eth_estimateGas not supported'), + ); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'custom error', + }); + }); + + it('passes stateOverrides to debug_traceCall when request has 7702 account overrides', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + + await simulateQuoteTransactions( + buildRequest({ mock7702From: FROM_MOCK }), + ); + + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'debug_traceCall', + params: expect.arrayContaining([ + expect.objectContaining({ stateOverrides: expect.any(Object) }), + ]), + }), + ); + }); + + it('passes overrides to eth_estimateGas params when request has 7702 account overrides', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockResolvedValueOnce({} as never); + + await simulateQuoteTransactions( + buildRequest({ mock7702From: FROM_MOCK }), + ); + + expect(rpcRequestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'eth_estimateGas', + params: [expect.any(Object), 'latest', expect.any(Object)], + }), + ); + }); + + it('uses error .message string directly when findErrorMessage traversal resolves via message property', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ + code: 3, + message: 'rpc error string', + }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'rpc error string', + }); + }); + + it('falls through to nested data messages when the top-level message is missing', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ + data: { message: 'nested rpc error string' }, + }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'nested rpc error string', + }); + }); + + it('falls through to nested error messages when message and data are missing', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ + error: { message: 'nested error rpc string' }, + }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'nested error rpc string', + }); + }); + + it('falls through to original error messages when earlier fields are missing', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ + originalError: { message: 'nested original rpc string' }, + }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'nested original rpc string', + }); + }); + + it('ignores unsupported chain errors when no RPC fallback message can be extracted', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ code: 3 }); + + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); + }); + }); + + describe('validateSimulationResponse — response transaction errors', () => { + it('throws when Sentinel returns an error in the second response transaction while request has one transaction', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{}, { error: 'tx2 route error' }], + }); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'tx2 route error', + }); + }); + + it('throws when Sentinel response transaction has a direct callTrace error', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [{ callTrace: { error: 'callTrace direct error' } }], + }); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockRejectedValueOnce( + new Error('method eth_estimateGas not supported'), + ); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'callTrace direct error', + }); + }); + + it('throws when Sentinel response transaction has a nested callTrace error', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [ + { + callTrace: { + calls: [{}, { error: 'nested callTrace error' }], + }, + }, + ], + }); + rpcRequestMock + .mockRejectedValueOnce( + new Error('method debug_traceCall not supported'), + ) + .mockRejectedValueOnce( + new Error('method eth_estimateGas not supported'), + ); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + code: 'quote_simulation_failed', + message: 'nested callTrace error', + }); + }); + }); + + describe('7702 account override', () => { + it('adds account code override when mock7702From is provided', async () => { + await simulateQuoteTransactions( + buildRequest({ mock7702From: FROM_MOCK }), + ); + + expect(simulateTransactionsMock).toHaveBeenCalledWith( + CHAIN_ID_MOCK, + expect.objectContaining({ + overrides: { + [FROM_MOCK.toLowerCase()]: { + code: `0xef0100${EIP7702_DELEGATOR_ADDRESS.slice(2)}`, + }, + }, + }), + ); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 29017e80d5..a3ce4b3372 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -135,24 +135,48 @@ describe('Strategy Utils', () => { quotes: [], } as never; - it('uses checkQuoteSupport when available', async () => { + it('wraps boolean false into a support result', async () => { const strategy = { checkQuoteSupport: jest.fn().mockReturnValue(false), getQuotes: jest.fn(), execute: jest.fn(), }; - expect(await checkStrategyQuoteSupport(strategy, request)).toBe(false); + expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ + isSupported: false, + }); expect(strategy.checkQuoteSupport).toHaveBeenCalledWith(request); }); + it('passes through a structured support result unchanged', async () => { + const validationError = { + code: 'quote_simulation_failed', + message: 'boom', + }; + const strategy = { + checkQuoteSupport: jest.fn().mockReturnValue({ + isSupported: false, + validationError, + }), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ + isSupported: false, + validationError, + }); + }); + it('defaults to supported when no post-quote support check is provided', async () => { const strategy = { getQuotes: jest.fn(), execute: jest.fn(), }; - expect(await checkStrategyQuoteSupport(strategy, request)).toBe(true); + expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ + isSupported: true, + }); }); }); }); From 303d96185995e837ff865280a5995749f088e2ba Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 21:37:21 +0100 Subject: [PATCH 4/6] refactor(transaction-pay-controller): simplify quote validation --- .../transaction-pay-controller/src/index.ts | 2 - .../strategy/across/AcrossStrategy.test.ts | 16 +- .../src/strategy/across/AcrossStrategy.ts | 19 +- .../src/strategy/relay/RelayStrategy.test.ts | 131 ++------------ .../src/strategy/relay/RelayStrategy.ts | 64 +------ .../src/strategy/relay/relay-submit.ts | 66 ++++--- ...ation.test.ts => relay-validation.test.ts} | 129 +++++++------- .../{simulation.ts => relay-validation.ts} | 123 ++++++++----- .../transaction-pay-controller/src/types.ts | 49 +----- .../src/utils/quotes.test.ts | 16 +- .../src/utils/quotes.ts | 5 +- .../src/utils/sentinel.ts | 17 +- .../src/utils/simulation.test.ts | 68 ++------ .../src/utils/simulation.ts | 162 ++++++++---------- .../src/utils/strategy.test.ts | 18 +- .../src/utils/strategy.ts | 8 +- .../src/utils/validation.ts | 61 ++----- 17 files changed, 351 insertions(+), 603 deletions(-) rename packages/transaction-pay-controller/src/strategy/relay/{simulation.test.ts => relay-validation.test.ts} (90%) rename packages/transaction-pay-controller/src/strategy/relay/{simulation.ts => relay-validation.ts} (64%) diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 4de7283857..fe2679a011 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -19,8 +19,6 @@ export type { TransactionPayControllerStateChangeEvent, TransactionPaymentToken, TransactionPayQuote, - TransactionPayQuoteValidationError, - TransactionPayQuoteValidationErrorCode, TransactionPayRequiredToken, TransactionPaySourceAmount, TransactionPayTotals, diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index c63d7ba7a8..fcbc483e8b 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -368,7 +368,7 @@ describe('AcrossStrategy', () => { transaction: TRANSACTION_META_MOCK, }); - expect(result).toBe(false); + expect(result).toStrictEqual({ isSupported: false }); }); it('supports source 7702 authorization lists for Predict withdraw post-quote quotes without Across actions', () => { @@ -390,7 +390,9 @@ describe('AcrossStrategy', () => { } as TransactionMeta, } as PayStrategyCheckQuoteSupportRequest; - expect(strategy.checkQuoteSupport(request)).toBe(true); + expect(strategy.checkQuoteSupport(request)).toStrictEqual({ + isSupported: true, + }); }); it('does not support first-time 7702 authorization lists for non-post-quote Across quotes', () => { @@ -404,7 +406,9 @@ describe('AcrossStrategy', () => { } as TransactionMeta, } as PayStrategyCheckQuoteSupportRequest; - expect(strategy.checkQuoteSupport(request)).toBe(false); + expect(strategy.checkQuoteSupport(request)).toStrictEqual({ + isSupported: false, + }); }); it('does not support first-time 7702 authorization lists when the Across quote has embedded destination actions', () => { @@ -441,7 +445,9 @@ describe('AcrossStrategy', () => { } as TransactionMeta, } as PayStrategyCheckQuoteSupportRequest; - expect(strategy.checkQuoteSupport(request)).toBe(false); + expect(strategy.checkQuoteSupport(request)).toStrictEqual({ + isSupported: false, + }); }); it('supports 7702 quotes that do not require an authorization list', () => { @@ -461,7 +467,7 @@ describe('AcrossStrategy', () => { quotes: [quote], transaction: TRANSACTION_META_MOCK, }), - ).toBe(true); + ).toStrictEqual({ isSupported: true }); }); it('returns false for unsupported destination actions', () => { diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index 0c942dd7d0..199bed1f85 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -5,6 +5,7 @@ import type { PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, + PayStrategyQuoteSupportResult, TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; @@ -79,7 +80,7 @@ export class AcrossStrategy implements PayStrategy { checkQuoteSupport( request: PayStrategyCheckQuoteSupportRequest, - ): boolean { + ): PayStrategyQuoteSupportResult { // Gas planning can discover that TransactionController would add an // authorization list for a first-time 7702 upgrade. `is7702` alone is not a // blocker because it also covers already-upgraded accounts. @@ -88,21 +89,23 @@ export class AcrossStrategy implements PayStrategy { ); if (!requiresAuthorizationList) { - return true; + return { isSupported: true }; } if (!isPredictWithdrawTransaction(request.transaction)) { - return false; + return { isSupported: false }; } // A first-time 7702 authorization list is acceptable here only because it is // attached to MetaMask's source-chain batch transaction. It must not be // smuggled into Across destination post-swap actions. - return request.quotes.every( - (quote) => - quote.request.isPostQuote === true && - quote.original.request.actions.length === 0, - ); + return { + isSupported: request.quotes.every( + (quote) => + quote.request.isPostQuote === true && + quote.original.request.actions.length === 0, + ), + }; } async getQuotes( diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts index 85ea4630cc..2b547170eb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -7,29 +7,22 @@ import type { TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; -import { - isQuoteValidationError, - validateQuoteExecution, -} from '../../utils/validation'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; +import { validateRelayQuoteSupport } from './relay-validation'; import { RelayStrategy } from './RelayStrategy'; -import { buildRelaySimulation } from './simulation'; import type { RelayQuote } from './types'; jest.mock('./relay-quotes'); -jest.mock('./simulation'); jest.mock('./relay-submit'); +jest.mock('./relay-validation'); jest.mock('../../utils/feature-flags'); -jest.mock('../../utils/validation'); describe('RelayStrategy', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); - const buildRelaySimulationMock = jest.mocked(buildRelaySimulation); - const isQuoteValidationErrorMock = jest.mocked(isQuoteValidationError); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); - const validateQuoteExecutionMock = jest.mocked(validateQuoteExecution); + const validateRelayQuoteSupportMock = jest.mocked(validateRelayQuoteSupport); const messenger = {} as never; @@ -68,9 +61,7 @@ describe('RelayStrategy', () => { enabled: true, }, }); - buildRelaySimulationMock.mockResolvedValue({ transactions: [] }); - isQuoteValidationErrorMock.mockReturnValue(false); - validateQuoteExecutionMock.mockResolvedValue(undefined); + validateRelayQuoteSupportMock.mockResolvedValue({ isSupported: true }); }); it('returns true from supports when relay is enabled', () => { @@ -106,119 +97,25 @@ describe('RelayStrategy', () => { expect(getRelayQuotesMock).toHaveBeenCalledWith(request); }); - it('validates quotes in checkQuoteSupport', async () => { + it('delegates checkQuoteSupport', async () => { const quote = buildQuote(); - - const strategy = new RelayStrategy(); - const result = await strategy.checkQuoteSupport({ - messenger, - quotes: [quote], - transaction: request.transaction, - }); - - expect(result).toStrictEqual({ isSupported: true }); - expect(buildRelaySimulationMock).toHaveBeenCalledWith({ - messenger, - quote, - transaction: request.transaction, - }); - expect(validateQuoteExecutionMock).toHaveBeenCalledWith({ - messenger, - quote, - signal: undefined, - simulation: { transactions: [] }, - }); - }); - - it('skips validation for Hyperliquid source quotes', async () => { - const quote = buildQuote({ isHyperliquidSource: true }); - - const strategy = new RelayStrategy(); - const result = await strategy.checkQuoteSupport({ - messenger, - quotes: [quote], - transaction: request.transaction, - }); - - expect(result).toStrictEqual({ isSupported: true }); - expect(buildRelaySimulationMock).not.toHaveBeenCalled(); - expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); - }); - - it('rethrows validation errors when the abort signal is aborted', async () => { - const quote = buildQuote(); - const controller = new AbortController(); - const error = new Error('Quote validation aborted'); - - controller.abort(); - validateQuoteExecutionMock.mockRejectedValue(error); - - const strategy = new RelayStrategy(); - - await expect( - strategy.checkQuoteSupport({ - messenger, - quotes: [quote], - signal: controller.signal, - transaction: request.transaction, - }), - ).rejects.toThrow(error); - }); - - it('returns quote validation errors from checkQuoteSupport', async () => { - const validationError = { - code: 'insufficient_source_balance', - message: 'Insufficient source token balance for quote', - strategy: TransactionPayStrategy.Relay, - } as const; - const quote = buildQuote(); - const error = { validationError } as unknown as Error; - - isQuoteValidationErrorMock.mockReturnValue(true); - validateQuoteExecutionMock.mockRejectedValue(error); - - const strategy = new RelayStrategy(); - const result = await strategy.checkQuoteSupport({ - messenger, - quotes: [quote], - transaction: request.transaction, - }); - - expect(result).toStrictEqual({ + const supportResult = { isSupported: false, - validationError, - }); - }); + validationError: 'RPC down', + }; - it('wraps unknown quote validation errors', async () => { - const quote = { - request: { - sourceChainId: '0x1' as Hex, - sourceTokenAddress: '0xabc' as Hex, - }, - strategy: TransactionPayStrategy.Relay, - } as TransactionPayQuote; - - buildRelaySimulationMock.mockResolvedValue({ transactions: [] }); - validateQuoteExecutionMock.mockRejectedValue(new Error('RPC down')); + validateRelayQuoteSupportMock.mockResolvedValue(supportResult); const strategy = new RelayStrategy(); - const result = await strategy.checkQuoteSupport({ + const checkRequest = { messenger, quotes: [quote], transaction: request.transaction, - }); + }; + const result = await strategy.checkQuoteSupport(checkRequest); - expect(result).toStrictEqual({ - isSupported: false, - validationError: { - chainId: '0x1', - code: 'quote_validation_unavailable', - message: 'RPC down', - strategy: TransactionPayStrategy.Relay, - tokenAddress: '0xabc', - }, - }); + expect(result).toStrictEqual(supportResult); + expect(validateRelayQuoteSupportMock).toHaveBeenCalledWith(checkRequest); }); function buildQuote( diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts index e0c007d76d..f557cdc25a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -5,16 +5,11 @@ import type { PayStrategyGetQuotesRequest, PayStrategyQuoteSupportResult, TransactionPayQuote, - TransactionPayQuoteValidationError, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; -import { - isQuoteValidationError, - validateQuoteExecution, -} from '../../utils/validation'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; -import { buildRelaySimulation } from './simulation'; +import { validateRelayQuoteSupport } from './relay-validation'; import type { RelayQuote } from './types'; export class RelayStrategy implements PayStrategy { @@ -32,37 +27,7 @@ export class RelayStrategy implements PayStrategy { async checkQuoteSupport( request: PayStrategyCheckQuoteSupportRequest, ): Promise { - for (const quote of request.quotes) { - if (shouldSkipValidation(quote)) { - continue; - } - - try { - const simulation = await buildRelaySimulation({ - messenger: request.messenger, - quote, - transaction: request.transaction, - }); - - await validateQuoteExecution({ - messenger: request.messenger, - quote, - signal: request.signal, - simulation, - }); - } catch (error) { - if (request.signal?.aborted) { - throw error; - } - - return { - isSupported: false, - validationError: getValidationError(quote, error), - }; - } - } - - return { isSupported: true }; + return await validateRelayQuoteSupport(request); } async execute( @@ -76,28 +41,3 @@ export class RelayStrategy implements PayStrategy { } } } - -function shouldSkipValidation(quote: TransactionPayQuote): boolean { - const { request } = quote; - - return Boolean( - request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, - ); -} - -function getValidationError( - quote: TransactionPayQuote, - error: unknown, -): TransactionPayQuoteValidationError { - if (isQuoteValidationError(error)) { - return error.validationError; - } - - return { - chainId: quote.request.sourceChainId, - code: 'quote_validation_unavailable', - message: (error as Error).message, - strategy: quote.strategy, - tokenAddress: quote.request.sourceTokenAddress, - }; -} 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 522cbaadb7..b3ca7cf2e7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -55,11 +55,15 @@ const FALLBACK_HASH = '0x0' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); -export type RelaySubmitParams = { +type RelaySubmitParams = { allParams: TransactionParams[]; normalizedParams: TransactionParams[]; }; +type RelaySubmitCalls = RelaySubmitParams & { + executeRequest?: RelayExecuteRequest; +}; + /** * Submits Relay quotes. * @@ -376,18 +380,18 @@ async function submitTransactions( await validateSourceBalance(quote, messenger); } - const { allParams, normalizedParams } = await buildRelaySubmitParams({ - messenger, - quote, - transaction, - }); + const { allParams, executeRequest, normalizedParams } = + await getRelaySubmitCalls({ + messenger, + quote, + transaction, + }); if (quote.original.metamask.isExecute) { return await submitViaRelayExecute( quote, - transaction, messenger, - allParams, + executeRequest as RelayExecuteRequest, ); } @@ -425,7 +429,37 @@ export function getRelayTransactionStepData( ); } -export async function buildRelaySubmitParams({ +export async function getRelaySubmitCalls({ + messenger, + quote, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const { allParams, normalizedParams } = await buildRelaySubmitParams({ + messenger, + quote, + transaction, + }); + const executeRequest = quote.original.metamask.isExecute + ? await buildRelayExecuteRequest({ + allParams, + messenger, + quote, + transaction, + }) + : undefined; + + return { + allParams, + ...(executeRequest ? { executeRequest } : {}), + normalizedParams, + }; +} + +async function buildRelaySubmitParams({ messenger, quote, transaction, @@ -547,23 +581,15 @@ async function buildDelegatedOriginalParams( * it to Relay's /execute endpoint for gasless execution. * * @param quote - Relay quote. - * @param transaction - Original transaction meta. * @param messenger - Controller messenger. - * @param allParams - All source transaction params to combine. + * @param executeBody - Relay execute request to submit. * @returns Fallback hash (actual hash comes from relay status polling). */ async function submitViaRelayExecute( quote: TransactionPayQuote, - transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, - allParams: TransactionParams[], + executeBody: RelayExecuteRequest, ): Promise { - const executeBody = await buildRelayExecuteRequest({ - allParams, - messenger, - quote, - transaction, - }); const { from } = quote.request; log('Submitting via Relay execute', { executeBody, from }); @@ -581,7 +607,7 @@ async function submitViaRelayExecute( return FALLBACK_HASH; } -export async function buildRelayExecuteRequest({ +async function buildRelayExecuteRequest({ allParams, messenger, quote, diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts similarity index 90% rename from packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts rename to packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts index dd2666337d..2f0db6eccb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/simulation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts @@ -16,11 +16,11 @@ import { simulateTransactions, } from '../../utils/sentinel'; import { getLiveTokenBalance } from '../../utils/token'; +import { isQuoteValidationError } from '../../utils/validation'; import { - isQuoteValidationError, - validateQuoteExecution, -} from '../../utils/validation'; -import { buildRelaySimulation, RelayQuoteValidationError } from './simulation'; + RelayQuoteValidationError, + validateRelayQuoteSupport, +} from './relay-validation'; import type { RelayQuote } from './types'; jest.mock('../../utils/feature-flags', () => ({ @@ -116,6 +116,36 @@ describe('Relay quote simulation validation', () => { }); }); + it('skips validation for Hyperliquid source quotes', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isHyperliquidSource: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + expect(simulateTransactionsMock).not.toHaveBeenCalled(); + }); + + it('skips validation for Polymarket deposit wallet quotes', async () => { + await validateRelayQuote({ + messenger, + quote: buildQuote({ + isPolymarketDepositWallet: true, + sourceAmountRaw: '500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + expect(simulateTransactionsMock).not.toHaveBeenCalled(); + }); + it('throws an insufficient source balance error when source amount exceeds live balance', async () => { await expect( validateRelayQuote({ @@ -127,12 +157,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - availableAmountRaw: '1000', - code: 'insufficient_source_balance', - message: 'Insufficient quote source amount', - requiredAmountRaw: '1500', - }, + validationError: 'Insufficient quote source amount', }); expect(simulateTransactionsMock).not.toHaveBeenCalled(); }); @@ -148,12 +173,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - availableAmountRaw: '1000', - code: 'insufficient_source_balance', - message: 'Insufficient balance for decoded quote amount', - requiredAmountRaw: '1500', - }, + validationError: 'Insufficient balance for decoded quote amount', }); expect(simulateTransactionsMock).not.toHaveBeenCalled(); }); @@ -168,10 +188,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'source_balance_unavailable', - message: 'Cannot validate payment token balance - RPC timeout', - }, + validationError: 'Cannot validate payment token balance - RPC timeout', }); }); @@ -187,10 +204,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_simulation_failed', - message: 'ERC20: transfer amount exceeds balance', - }, + validationError: 'ERC20: transfer amount exceeds balance', }); }); @@ -199,8 +213,7 @@ describe('Relay quote simulation validation', () => { transactions: [{ error: 'Quote simulation failed - execution reverted' }], }); rpcRequestMock.mockResolvedValueOnce({ - error: 'execution reverted', - calls: [{ error: 'ERC20: transfer amount exceeds balance' }], + error: 'ERC20: transfer amount exceeds balance', } as never); await expect( @@ -210,10 +223,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_simulation_failed', - message: 'ERC20: transfer amount exceeds balance', - }, + validationError: 'ERC20: transfer amount exceeds balance', }); expect(rpcRequestMock).toHaveBeenCalledWith( @@ -237,7 +247,7 @@ describe('Relay quote simulation validation', () => { transactions: [{ error: 'Quote simulation failed - execution reverted' }], }); rpcRequestMock.mockResolvedValueOnce({ - calls: [{ error: 'route reverted' }], + error: 'route reverted', } as never); await expect( @@ -247,9 +257,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - message: 'route reverted', - }, + validationError: 'route reverted', }); expect(rpcRequestMock).toHaveBeenCalledWith( @@ -337,10 +345,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_simulation_failed', - message: 'execution reverted: route reverted', - }, + validationError: 'execution reverted: route reverted', }); expect(rpcRequestMock).toHaveBeenNthCalledWith( @@ -367,10 +372,8 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_simulation_failed', - message: 'execution reverted: ERC20: transfer amount exceeds balance', - }, + validationError: + 'execution reverted: ERC20: transfer amount exceeds balance', }); }); @@ -386,10 +389,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_simulation_failed', - message: 'insufficient funds for gas * price + value', - }, + validationError: 'insufficient funds for gas * price + value', }); }); @@ -466,11 +466,7 @@ describe('Relay quote simulation validation', () => { }); it('identifies relay quote validation errors', async () => { - const error = new RelayQuoteValidationError({ - code: 'quote_simulation_failed', - message: 'boom', - strategy: TransactionPayStrategy.Relay, - }); + const error = new RelayQuoteValidationError('boom'); expect(isQuoteValidationError(error)).toBe(true); }); @@ -605,10 +601,7 @@ describe('Relay quote simulation validation', () => { transaction: TRANSACTION_MOCK, }), ).rejects.toMatchObject({ - validationError: { - code: 'quote_authorization_invalid', - message: 'Relay execute authorization list is incomplete', - }, + validationError: 'Relay execute authorization list is incomplete', }); }); @@ -621,7 +614,9 @@ describe('Relay quote simulation validation', () => { quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), transaction: TRANSACTION_MOCK, }), - ).rejects.toThrow(TypeError); + ).rejects.toMatchObject({ + validationError: expect.stringContaining('Cannot'), + }); }); it('skips required source amount check for post-quote requests', async () => { @@ -701,10 +696,12 @@ describe('Relay quote simulation validation', () => { function buildQuote({ isExecute = false, + isHyperliquidSource, is7702 = false, isPostQuote = false, gasLimits = [], paymentOverride, + isPolymarketDepositWallet, requestAuthorizationList, sourceAmountRaw, sourceTokenAddress = TOKEN_ADDRESS_MOCK, @@ -713,10 +710,12 @@ function buildQuote({ transferAmountRaw, }: { isExecute?: boolean; + isHyperliquidSource?: boolean; is7702?: boolean; isPostQuote?: boolean; gasLimits?: number[]; paymentOverride?: boolean; + isPolymarketDepositWallet?: boolean; requestAuthorizationList?: { address: Hex; chainId: Hex; @@ -772,6 +771,8 @@ function buildQuote({ } as RelayQuote, request: { from: FROM_MOCK, + isHyperliquidSource, + isPolymarketDepositWallet, isPostQuote, paymentOverride, sourceBalanceRaw: '1000', @@ -803,16 +804,16 @@ async function validateRelayQuote({ signal?: AbortSignal; transaction: TransactionMeta; }): Promise { - const simulation = await buildRelaySimulation({ + const result = await validateRelayQuoteSupport({ messenger, - quote, + quotes: [quote], + signal, transaction, }); - await validateQuoteExecution({ - messenger, - quote, - signal, - simulation, - }); + if (!result.isSupported) { + throw new RelayQuoteValidationError( + result.validationError ?? 'Relay quote is not supported', + ); + } } diff --git a/packages/transaction-pay-controller/src/strategy/relay/simulation.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts similarity index 64% rename from packages/transaction-pay-controller/src/strategy/relay/simulation.ts rename to packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts index 19a5950269..53907a23c8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/simulation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts @@ -7,18 +7,20 @@ import type { import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { TransactionPayStrategy } from '../../constants'; import type { + PayStrategyCheckQuoteSupportRequest, + PayStrategyQuoteSupportResult, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; import type { SimulationTransaction } from '../../utils/simulation'; -import { QuoteValidationError } from '../../utils/validation'; import type { QuoteValidationSimulation } from '../../utils/validation'; import { - buildRelayExecuteRequest, - buildRelaySubmitParams, -} from './relay-submit'; + isQuoteValidationError, + QuoteValidationError, + validateQuoteExecution, +} from '../../utils/validation'; +import { getRelaySubmitCalls } from './relay-submit'; import type { RelayExecuteRequest, RelayQuote } from './types'; const ERC7579_CALL_TYPE_BATCH = '01'; @@ -32,7 +34,43 @@ const erc7821Interface = new Interface([ export { QuoteValidationError as RelayQuoteValidationError }; -export async function buildRelaySimulation({ +export async function validateRelayQuoteSupport( + request: PayStrategyCheckQuoteSupportRequest, +): Promise { + for (const quote of request.quotes) { + if (shouldSkipValidation(quote)) { + continue; + } + + try { + const simulation = await buildRelayValidationSimulation({ + messenger: request.messenger, + quote, + transaction: request.transaction, + }); + + await validateQuoteExecution({ + messenger: request.messenger, + quote, + signal: request.signal, + simulation, + }); + } catch (error) { + if (request.signal?.aborted) { + throw error; + } + + return { + isSupported: false, + validationError: getValidationError(error), + }; + } + } + + return { isSupported: true }; +} + +async function buildRelayValidationSimulation({ messenger, quote, transaction, @@ -41,48 +79,36 @@ export async function buildRelaySimulation({ quote: TransactionPayQuote; transaction: TransactionMeta; }): Promise { - const { allParams } = await buildRelaySubmitParams({ + const submitCalls = await getRelaySubmitCalls({ messenger, quote, transaction, }); if (quote.original.metamask.isExecute) { - return await buildRelayExecuteSimulation({ - allParams, - messenger, + return buildRelayExecuteValidationSimulation( quote, - transaction, - }); + submitCalls.executeRequest as RelayExecuteRequest, + ); } if (quote.original.metamask.is7702) { - return buildRelay7702BatchSimulation(quote, allParams); + return buildRelay7702BatchValidationSimulation( + quote, + submitCalls.allParams, + ); } return { - transactions: allParams.map(toSimulationTransaction), + transactions: submitCalls.allParams.map(toSimulationTransaction), }; } -async function buildRelayExecuteSimulation({ - allParams, - messenger, - quote, - transaction, -}: { - allParams: TransactionParams[]; - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - transaction: TransactionMeta; -}): Promise { - const executeRequest = await buildRelayExecuteRequest({ - allParams, - messenger, - quote, - transaction, - }); - validateAuthorizationList(executeRequest, quote); +function buildRelayExecuteValidationSimulation( + quote: TransactionPayQuote, + executeRequest: RelayExecuteRequest, +): QuoteValidationSimulation { + validateAuthorizationList(executeRequest); const transactionToSimulate = { data: executeRequest.data.data, @@ -99,7 +125,7 @@ async function buildRelayExecuteSimulation({ }; } -function buildRelay7702BatchSimulation( +function buildRelay7702BatchValidationSimulation( quote: TransactionPayQuote, allParams: TransactionParams[], ): QuoteValidationSimulation { @@ -147,10 +173,7 @@ function buildEip7702BatchTransaction( }; } -function validateAuthorizationList( - executeRequest: RelayExecuteRequest, - quote: TransactionPayQuote, -): void { +function validateAuthorizationList(executeRequest: RelayExecuteRequest): void { for (const authorization of executeRequest.data.authorizationList ?? []) { if ( authorization.address === undefined || @@ -160,13 +183,9 @@ function validateAuthorizationList( authorization.s === undefined || authorization.yParity === undefined ) { - throw new QuoteValidationError({ - chainId: quote.request.sourceChainId, - code: 'quote_authorization_invalid', - message: 'Relay execute authorization list is incomplete', - strategy: TransactionPayStrategy.Relay, - tokenAddress: quote.request.sourceTokenAddress, - }); + throw new QuoteValidationError( + 'Relay execute authorization list is incomplete', + ); } } } @@ -185,6 +204,22 @@ function toSimulationTransaction( }; } +function shouldSkipValidation(quote: TransactionPayQuote): boolean { + const { request } = quote; + + return Boolean( + request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, + ); +} + +function getValidationError(error: unknown): string { + if (isQuoteValidationError(error)) { + return error.validationError; + } + + return (error as Error).message; +} + function decimalToHex(value: string): Hex { return new BigNumber(value).toString(16).replace(/^/u, '0x') as Hex; } diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index a9f4eb34b2..be7e076e93 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -335,8 +335,8 @@ export type TransactionData = { /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; - /** Most relevant validation error from the latest quote attempt. */ - quoteValidationError?: TransactionPayQuoteValidationError; + /** Most relevant validation error message from the latest quote attempt. */ + quoteValidationError?: string; /** Timestamp of when quotes were last updated. */ quotesLastUpdated?: number; @@ -435,49 +435,13 @@ export type TransactionPaySourceAmount = { targetTokenAddress: Hex; }; -/** Machine-readable reason a quote was rejected during validation. */ -export type TransactionPayQuoteValidationErrorCode = - | 'insufficient_source_balance' - | 'quote_authorization_invalid' - | 'quote_simulation_failed' - | 'quote_validation_unavailable' - | 'relay_execute_validation_failed' - | 'source_approval_reverted' - | 'source_balance_unavailable' - | 'source_token_contract_missing' - | 'source_transfer_reverted'; - -/** Validation error suitable for clients to surface instead of a generic no-quotes message. */ -export type TransactionPayQuoteValidationError = { - /** Machine-readable validation failure code. */ - code: TransactionPayQuoteValidationErrorCode; - - /** Human-readable fallback message. Clients may map `code` to localized copy. */ - message: string; - - /** Strategy whose quote failed validation. */ - strategy: TransactionPayStrategy; - - /** Available source token balance, if known. */ - availableAmountRaw?: string; - - /** Chain ID associated with the failed source token or transaction. */ - chainId?: Hex; - - /** Source token amount required by the quote, if known. */ - requiredAmountRaw?: string; - - /** Source token address associated with the failed quote, if known. */ - tokenAddress?: Hex; -}; - /** Result returned by a strategy's post-quote support check. */ export type PayStrategyQuoteSupportResult = { /** Whether the quote set is supported and can be presented to the user. */ isSupported: boolean; - /** Validation error explaining why the quote set was rejected. */ - validationError?: TransactionPayQuoteValidationError; + /** Validation error message explaining why the quote set was rejected. */ + validationError?: string; }; /** Source token used to pay for required tokens. */ @@ -744,10 +708,7 @@ export type PayStrategy = { */ checkQuoteSupport?: ( request: PayStrategyCheckQuoteSupportRequest, - ) => - | boolean - | PayStrategyQuoteSupportResult - | Promise; + ) => PayStrategyQuoteSupportResult | Promise; /** Retrieve batch transactions for quotes, if supported by the strategy. */ getBatchTransactions?: ( diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index b4499d5747..a91db23f74 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -215,11 +215,7 @@ describe('Quotes Utils', () => { }); it('stores quote validation errors when quotes are rejected', async () => { - const validationError = { - code: 'insufficient_source_balance' as const, - message: 'Insufficient source token balance for quote', - strategy: TransactionPayStrategy.Test, - }; + const validationError = 'Insufficient source token balance for quote'; checkStrategyQuoteSupportMock.mockResolvedValue({ isSupported: false, @@ -241,11 +237,7 @@ describe('Quotes Utils', () => { }); it('clears quote validation error when quote loading starts', async () => { - const validationError = { - code: 'insufficient_source_balance' as const, - message: 'Insufficient source token balance for quote', - strategy: TransactionPayStrategy.Test, - }; + const validationError = 'Insufficient source token balance for quote'; await run(); @@ -458,7 +450,9 @@ describe('Quotes Utils', () => { it('falls back to next strategy when post-quote support checks fail', async () => { const unsupportedStrategy = { supports: jest.fn().mockReturnValue(true), - checkQuoteSupport: jest.fn().mockResolvedValue(false), + checkQuoteSupport: jest.fn().mockResolvedValue({ + isSupported: false, + }), getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), getBatchTransactions: jest.fn(), execute: jest.fn(), diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 2a6443df7b..9c3e3b5536 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -11,7 +11,6 @@ import type { TransactionData, TransactionPayControllerMessenger, TransactionPayQuote, - TransactionPayQuoteValidationError, TransactionPayRequiredToken, TransactionPaySourceAmount, TransactionPayTotals, @@ -600,7 +599,7 @@ async function getQuotes( ): Promise<{ batchTransactions: BatchTransaction[]; quotes: TransactionPayQuote[]; - validationError?: TransactionPayQuoteValidationError; + validationError?: string; }> { const { id: transactionId } = transaction; const strategies = getStrategiesByName( @@ -630,7 +629,7 @@ async function getQuotes( transaction, }; - let validationError: TransactionPayQuoteValidationError | undefined; + let validationError: string | undefined; for (const { name, strategy } of strategies) { try { diff --git a/packages/transaction-pay-controller/src/utils/sentinel.ts b/packages/transaction-pay-controller/src/utils/sentinel.ts index 710163d44a..9d5ed1f74f 100644 --- a/packages/transaction-pay-controller/src/utils/sentinel.ts +++ b/packages/transaction-pay-controller/src/utils/sentinel.ts @@ -72,6 +72,9 @@ export async function simulateTransactions( request: SentinelSimulationRequest, ): Promise { const url = await getSimulationUrl(chainId); + + log('Simulation request', { chainId, request, url }); + const response = await fetch(url, { method: 'POST', headers: { @@ -106,14 +109,24 @@ async function getSimulationUrl(chainId: Hex): Promise { ); } - return getUrl(network.network); + const url = getUrl(network.network); + + log('Resolved simulation URL', { chainId, network, url }); + + return url; } async function getNetworkData(): Promise { + log('Fetching simulation networks'); + const response = await fetch( `${getUrl('ethereum-mainnet')}${ENDPOINT_NETWORKS}`, ); - return await response.json(); + const networkData = await response.json(); + + log('Fetched simulation networks', { networkData }); + + return networkData; } function getUrl(subdomain: string): string { diff --git a/packages/transaction-pay-controller/src/utils/simulation.test.ts b/packages/transaction-pay-controller/src/utils/simulation.test.ts index f285c63fab..e5cccab745 100644 --- a/packages/transaction-pay-controller/src/utils/simulation.test.ts +++ b/packages/transaction-pay-controller/src/utils/simulation.test.ts @@ -78,7 +78,7 @@ describe('simulateQuoteTransactions', () => { simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ name: 'TransactionPaySimulationError', - code: 'quote_simulation_failed', + message: 'network timeout', }); }); @@ -98,7 +98,6 @@ describe('simulateQuoteTransactions', () => { simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ name: 'TransactionPaySimulationError', - code: 'quote_validation_unavailable', message: 'Internal server error', }); }); @@ -110,7 +109,6 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: '0x1234abcd', }); }); @@ -122,7 +120,6 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'Panic(0)', }); }); @@ -134,7 +131,6 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'ERC20: transfer failed', }); }); @@ -146,7 +142,6 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'fallback route error', }); }); @@ -169,7 +164,7 @@ describe('simulateQuoteTransactions', () => { expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); }); - it('uses isQuoteSimulationFailure with String() when the thrown error is not an Error instance', async () => { + it('uses String() when the thrown error is not an Error instance', async () => { simulateTransactionsMock.mockRejectedValue({ code: 999, message: 'custom error', @@ -185,8 +180,7 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', - message: 'custom error', + message: '[object Object]', }); }); @@ -238,56 +232,40 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'rpc error string', }); }); - it('falls through to nested data messages when the top-level message is missing', async () => { + it('uses direct string RPC errors', async () => { simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); - rpcRequestMock.mockRejectedValueOnce({ - data: { message: 'nested rpc error string' }, - }); + rpcRequestMock.mockRejectedValueOnce('direct rpc error string'); await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', - message: 'nested rpc error string', + message: 'direct rpc error string', }); }); - it('falls through to nested error messages when message and data are missing', async () => { + it('ignores nested data messages when the top-level message is missing', async () => { simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); rpcRequestMock.mockRejectedValueOnce({ - error: { message: 'nested error rpc string' }, + data: { message: 'nested rpc error string' }, }); - await expect( - simulateQuoteTransactions(buildRequest()), - ).rejects.toMatchObject({ - code: 'quote_simulation_failed', - message: 'nested error rpc string', - }); + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); }); - it('falls through to original error messages when earlier fields are missing', async () => { + it('ignores unsupported chain errors when no RPC fallback message can be extracted', async () => { simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); - rpcRequestMock.mockRejectedValueOnce({ - originalError: { message: 'nested original rpc string' }, - }); + rpcRequestMock.mockRejectedValueOnce({ code: 3 }); - await expect( - simulateQuoteTransactions(buildRequest()), - ).rejects.toMatchObject({ - code: 'quote_simulation_failed', - message: 'nested original rpc string', - }); + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); }); - it('ignores unsupported chain errors when no RPC fallback message can be extracted', async () => { + it('ignores unsupported chain errors when RPC fallback throws null', async () => { simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); - rpcRequestMock.mockRejectedValueOnce({ code: 3 }); + rpcRequestMock.mockRejectedValueOnce(null); expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); }); @@ -302,7 +280,6 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'tx2 route error', }); }); @@ -322,12 +299,11 @@ describe('simulateQuoteTransactions', () => { await expect( simulateQuoteTransactions(buildRequest()), ).rejects.toMatchObject({ - code: 'quote_simulation_failed', message: 'callTrace direct error', }); }); - it('throws when Sentinel response transaction has a nested callTrace error', async () => { + it('ignores nested callTrace errors', async () => { simulateTransactionsMock.mockResolvedValue({ transactions: [ { @@ -337,20 +313,8 @@ describe('simulateQuoteTransactions', () => { }, ], }); - rpcRequestMock - .mockRejectedValueOnce( - new Error('method debug_traceCall not supported'), - ) - .mockRejectedValueOnce( - new Error('method eth_estimateGas not supported'), - ); - await expect( - simulateQuoteTransactions(buildRequest()), - ).rejects.toMatchObject({ - code: 'quote_simulation_failed', - message: 'nested callTrace error', - }); + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); }); }); diff --git a/packages/transaction-pay-controller/src/utils/simulation.ts b/packages/transaction-pay-controller/src/utils/simulation.ts index fe02f2b897..45706a8148 100644 --- a/packages/transaction-pay-controller/src/utils/simulation.ts +++ b/packages/transaction-pay-controller/src/utils/simulation.ts @@ -1,10 +1,9 @@ import { defaultAbiCoder } from '@ethersproject/abi'; +import { createModuleLogger } from '@metamask/utils'; import type { Hex, Json } from '@metamask/utils'; -import type { - TransactionPayControllerMessenger, - TransactionPayQuoteValidationErrorCode, -} from '../types'; +import { projectLogger } from '../logger'; +import type { TransactionPayControllerMessenger } from '../types'; import { isChainExcludedFromInfura } from './feature-flags'; import { rpcRequest } from './provider'; import { @@ -24,6 +23,8 @@ const QUOTE_SIMULATION_FAILED_PREFIX = /^Quote simulation failed\s*[-:]\s*/iu; const RPC_FALLBACK_UNSUPPORTED_REGEX = /(does not exist|invalid argument|invalid params|method .*not|not supported|too many arguments|unsupported)/iu; +const log = createModuleLogger(projectLogger, 'simulation'); + export type SimulationTransaction = SentinelSimulationTransaction; export type SimulationRequest = { @@ -60,12 +61,9 @@ type SimulationRequestWithOverrides = SimulationRequest & { }; export class TransactionPaySimulationError extends Error { - readonly code: TransactionPayQuoteValidationErrorCode; - - constructor(code: TransactionPayQuoteValidationErrorCode, message: string) { + constructor(message: string) { super(message); this.name = 'TransactionPaySimulationError'; - this.code = code; } } @@ -75,6 +73,12 @@ export async function simulateQuoteTransactions( const requestWithOverrides = addSimulationOverrides(request); let responseTransactions: SentinelSimulationResponseTransaction[]; + log('Simulating quote transactions', { + chainId: request.chainId, + hasMock7702From: Boolean(request.mock7702From), + transactionCount: request.transactions.length, + }); + try { const response = await simulateTransactions(request.chainId, { ...toSentinelRequest(requestWithOverrides), @@ -84,29 +88,24 @@ export async function simulateQuoteTransactions( }); responseTransactions = response.transactions; } catch (error) { + log('Sentinel simulation failed', { error }); + const fallbackResult = await getFallbackSimulationResult(requestWithOverrides); const fallbackError = fallbackResult?.error; if (fallbackResult?.isSupported && !fallbackError) { + log('RPC fallback simulation passed'); return; } if (!fallbackError && isSentinelChainUnsupportedError(error)) { + log('Skipping validation for Sentinel-unsupported chain'); return; } - const { message } = error as Error; - const code = - fallbackError || - isQuoteSimulationFailure(error) || - !(error instanceof SentinelSimulationError) - ? 'quote_simulation_failed' - : 'quote_validation_unavailable'; - throw new TransactionPaySimulationError( - code, - fallbackError ?? normalizeSimulationErrorMessage(message), + fallbackError ?? normalizeSimulationErrorMessage(getErrorMessage(error)), ); } @@ -128,8 +127,13 @@ async function validateSimulationResponse( const fallbackError = (await getFallbackSimulationResult(request, index)) ?.error; + log('Simulation response transaction failed', { + error, + fallbackError, + index, + }); + throw new TransactionPaySimulationError( - 'quote_simulation_failed', fallbackError ?? normalizeSimulationErrorMessage(error), ); } @@ -165,12 +169,20 @@ async function getFallbackSimulationResult( transactionIndex = 0, ): Promise { if (request.transactions.length !== 1) { + log('Skipping RPC fallback for multi-transaction simulation', { + transactionCount: request.transactions.length, + }); + return undefined; } const transaction = request.transactions[transactionIndex]; if (!transaction) { + log('Skipping RPC fallback for missing simulation transaction', { + transactionIndex, + }); + return undefined; } @@ -180,10 +192,16 @@ async function getFallbackSimulationResult( ); if (debugTraceCallResult?.isSupported) { + log('debug_traceCall fallback completed', debugTraceCallResult); + return debugTraceCallResult; } - return await getEstimateGasResult(request, transaction); + const estimateGasResult = await getEstimateGasResult(request, transaction); + + log('eth_estimateGas fallback completed', estimateGasResult); + + return estimateGasResult; } async function getDebugTraceCallResult( @@ -191,6 +209,8 @@ async function getDebugTraceCallResult( transaction: SimulationTransaction, ): Promise { try { + log('Running debug_traceCall fallback'); + const trace = await rpcRequest({ messenger: request.messenger, chainId: request.chainId, @@ -211,6 +231,8 @@ async function getDebugTraceCallResult( isSupported: true, }; } catch (error) { + log('debug_traceCall fallback failed', { error }); + return getRpcFallbackErrorResult(error); } } @@ -220,6 +242,8 @@ async function getEstimateGasResult( transaction: SimulationTransaction, ): Promise { try { + log('Running eth_estimateGas fallback'); + await rpcRequest({ messenger: request.messenger, chainId: request.chainId, @@ -228,6 +252,8 @@ async function getEstimateGasResult( options: getRpcFallbackRequestOptions(request), }); } catch (error) { + log('eth_estimateGas fallback failed', { error }); + return getRpcFallbackErrorResult(error); } @@ -261,67 +287,39 @@ function getRpcFallbackRequestOptions(request: SimulationRequest): { } function getRpcCallTraceError(trace: RpcCallTrace): string | undefined { - const ownErrors = [ + const errors = [ trace.revertReason, decodeRevertData(trace.output), trace.error, - ]; - const nestedErrors = (trace.calls ?? []) - .map((call) => getRpcCallTraceError(call)) - .filter((error): error is string => error !== undefined); - const errors = [...ownErrors, ...nestedErrors] + ] .filter((error): error is string => error !== undefined) .map(normalizeSimulationErrorMessage); - return errors.find((error) => !isGenericSimulationError(error)) ?? errors[0]; + return errors[0]; } function getRpcErrorMessage(error: unknown): string | undefined { - return ( - decodeRevertData(findRevertData(error)) ?? - findErrorMessage(error)?.replace(/^Error: /u, '') - ); -} - -function findRevertData(value: unknown): Hex | undefined { - if (typeof value === 'string') { - return isRevertData(value) ? (value as Hex) : undefined; + if (typeof error === 'string') { + return decodeRevertData(error as Hex) ?? error; } - if (!value || typeof value !== 'object') { - return undefined; + if (error instanceof Error) { + return error.message.replace(/^Error: /u, ''); } - const valueRecord = value as Record; - - return ( - findRevertData(valueRecord.data) ?? - findRevertData(valueRecord.error) ?? - findRevertData(valueRecord.originalError) - ); -} - -function findErrorMessage(value: unknown): string | undefined { - if (value instanceof Error) { - return value.message; + if (!error || typeof error !== 'object') { + return undefined; } - if (typeof value === 'string') { - return value; - } + const { data, message } = error as Record; - if (!value || typeof value !== 'object') { - return undefined; + if (typeof data === 'string') { + return decodeRevertData(data as Hex) ?? data; } - const valueRecord = value as Record; - - return ( - findErrorMessage(valueRecord.message) ?? - findErrorMessage(valueRecord.data) ?? - findErrorMessage(valueRecord.error) ?? - findErrorMessage(valueRecord.originalError) - ); + return typeof message === 'string' + ? message.replace(/^Error: /u, '') + : undefined; } function decodeRevertData(data?: Hex): string | undefined { @@ -376,12 +374,6 @@ function toRpcCallTransaction( }; } -function isQuoteSimulationFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - - return QUOTE_SIMULATION_FAILED_PREFIX.test(message); -} - function isSentinelChainUnsupportedError(error: unknown): boolean { return ( error instanceof SentinelSimulationError && @@ -393,42 +385,24 @@ function normalizeSimulationErrorMessage(message: string): string { return message.replace(QUOTE_SIMULATION_FAILED_PREFIX, ''); } -function isGenericSimulationError(message: string): boolean { - return /^(execution reverted|reverted)$/iu.test(message.trim()); -} - -function isRevertData(value: string): boolean { - return ( - value.startsWith(ERROR_STRING_SELECTOR) || value.startsWith(PANIC_SELECTOR) - ); -} - function getCallTraceError( transaction: SentinelSimulationResponseTransaction, ): string | undefined { - return findCallTraceError(transaction.callTrace); -} + const { callTrace } = transaction; -function findCallTraceError( - callTrace: SentinelSimulationResponseTransaction['callTrace'], -): string | undefined { if (!callTrace) { return undefined; } - if (callTrace.error) { - return callTrace.error; - } - - for (const nestedCall of callTrace.calls ?? []) { - const error = findCallTraceError(nestedCall); + return decodeRevertData(callTrace.output) ?? callTrace.error; +} - if (error) { - return error; - } +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; } - return undefined; + return String(error); } function getAccountUpgradeOverride(account: Hex): Record { diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index a3ce4b3372..23c6a51ee2 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -135,24 +135,8 @@ describe('Strategy Utils', () => { quotes: [], } as never; - it('wraps boolean false into a support result', async () => { - const strategy = { - checkQuoteSupport: jest.fn().mockReturnValue(false), - getQuotes: jest.fn(), - execute: jest.fn(), - }; - - expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ - isSupported: false, - }); - expect(strategy.checkQuoteSupport).toHaveBeenCalledWith(request); - }); - it('passes through a structured support result unchanged', async () => { - const validationError = { - code: 'quote_simulation_failed', - message: 'boom', - }; + const validationError = 'boom'; const strategy = { checkQuoteSupport: jest.fn().mockReturnValue({ isSupported: false, diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 82438b4f1e..ce9a4d9e96 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -112,11 +112,5 @@ export async function checkStrategyQuoteSupport( return { isSupported: true }; } - const result = await strategy.checkQuoteSupport(request); - - if (typeof result === 'boolean') { - return { isSupported: result }; - } - - return result; + return await strategy.checkQuoteSupport(request); } diff --git a/packages/transaction-pay-controller/src/utils/validation.ts b/packages/transaction-pay-controller/src/utils/validation.ts index cc5a6189c8..612f4af306 100644 --- a/packages/transaction-pay-controller/src/utils/validation.ts +++ b/packages/transaction-pay-controller/src/utils/validation.ts @@ -6,7 +6,6 @@ import { BigNumber } from 'bignumber.js'; import type { TransactionPayControllerMessenger, TransactionPayQuote, - TransactionPayQuoteValidationError, } from '../types'; import { SimulationTransaction, @@ -35,10 +34,10 @@ export type QuoteValidationRequest = { }; export class QuoteValidationError extends Error { - readonly validationError: TransactionPayQuoteValidationError; + readonly validationError: string; - constructor(validationError: TransactionPayQuoteValidationError) { - super(validationError.message); + constructor(validationError: string) { + super(validationError); this.name = 'QuoteValidationError'; this.validationError = validationError; } @@ -72,13 +71,7 @@ export async function validateQuoteExecution({ throwIfAborted(signal); if (error instanceof TransactionPaySimulationError) { - throw new QuoteValidationError({ - chainId: quote.request.sourceChainId, - code: error.code, - message: error.message, - strategy: quote.strategy, - tokenAddress: quote.request.sourceTokenAddress, - }); + throw new QuoteValidationError(error.message); } throw error; @@ -110,15 +103,9 @@ async function getLiveSourceBalance( normalizedSourceTokenAddress, ); } catch (error) { - throw new QuoteValidationError({ - chainId: sourceChainId, - code: 'source_balance_unavailable', - message: `Cannot validate payment token balance - ${ - (error as Error).message - }`, - strategy: quote.strategy, - tokenAddress: normalizedSourceTokenAddress, - }); + throw new QuoteValidationError( + `Cannot validate payment token balance - ${(error as Error).message}`, + ); } } @@ -137,12 +124,7 @@ function validateRequiredSourceAmount( return; } - throwInsufficientBalanceError( - quote, - liveBalance, - requiredAmount.toString(10), - 'Insufficient quote source amount', - ); + throwInsufficientBalanceError('Insufficient quote source amount'); } function validateDecodedSourceTransfers( @@ -160,9 +142,6 @@ function validateDecodedSourceTransfers( } throwInsufficientBalanceError( - quote, - liveBalance, - requiredAmount, 'Insufficient balance for decoded quote amount', ); } @@ -211,28 +190,8 @@ function decodeTransferAmount(data: Hex): string | undefined { } } -function throwInsufficientBalanceError( - quote: TransactionPayQuote, - liveBalance: string, - requiredAmountRaw: string, - message: string, -): never { - const { sourceChainId, sourceTokenAddress } = quote.request; - const normalizedSourceTokenAddress = normalizeTokenAddress( - sourceTokenAddress, - sourceChainId, - TokenAddressTarget.MetaMask, - ); - - throw new QuoteValidationError({ - availableAmountRaw: liveBalance, - chainId: sourceChainId, - code: 'insufficient_source_balance', - message, - requiredAmountRaw, - strategy: quote.strategy, - tokenAddress: normalizedSourceTokenAddress, - }); +function throwInsufficientBalanceError(message: string): never { + throw new QuoteValidationError(message); } function throwIfAborted(signal?: AbortSignal): void { From 5322519b3dc738534292d675fa753820e269e18e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 21:49:30 +0100 Subject: [PATCH 5/6] refactor(transaction-pay-controller): derive relay validation from submit calls --- .../src/strategy/relay/relay-submit.ts | 125 +++++++++++++++++ .../src/strategy/relay/relay-validation.ts | 127 +----------------- 2 files changed, 130 insertions(+), 122 deletions(-) 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 b3ca7cf2e7..023dc19691 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -1,3 +1,4 @@ +import { defaultAbiCoder, Interface } from '@ethersproject/abi'; import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; @@ -21,6 +22,7 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags'; import { getNetworkClientId } from '../../utils/provider'; +import type { SimulationTransaction } from '../../utils/simulation'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -32,6 +34,7 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import type { QuoteValidationSimulation } from '../../utils/validation'; import { RELAY_DEPOSIT_TYPES, RELAY_FAILURE_STATUSES, @@ -52,9 +55,17 @@ import type { } from './types'; const FALLBACK_HASH = '0x0' as Hex; +const ERC7579_CALL_TYPE_BATCH = '01'; +const ERC7579_EXEC_TYPE_DEFAULT = '00'; +const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); +const erc7821Interface = new Interface([ + 'function execute(bytes32 mode, bytes executionData)', +]); + type RelaySubmitParams = { allParams: TransactionParams[]; normalizedParams: TransactionParams[]; @@ -62,6 +73,7 @@ type RelaySubmitParams = { type RelaySubmitCalls = RelaySubmitParams & { executeRequest?: RelayExecuteRequest; + getValidationSimulation: () => QuoteValidationSimulation; }; /** @@ -455,6 +467,12 @@ export async function getRelaySubmitCalls({ return { allParams, ...(executeRequest ? { executeRequest } : {}), + getValidationSimulation: () => + buildRelaySubmitValidationSimulation({ + allParams, + executeRequest, + quote, + }), normalizedParams, }; } @@ -671,6 +689,113 @@ async function buildRelayExecuteRequest({ }; } +function buildRelaySubmitValidationSimulation({ + allParams, + executeRequest, + quote, +}: { + allParams: TransactionParams[]; + executeRequest?: RelayExecuteRequest; + quote: TransactionPayQuote; +}): QuoteValidationSimulation { + if (executeRequest) { + return buildRelayExecuteValidationSimulation(quote, executeRequest); + } + + if (quote.original.metamask.is7702) { + return buildRelay7702BatchValidationSimulation(quote, allParams); + } + + return { + transactions: allParams.map(toSimulationTransaction), + }; +} + +function buildRelayExecuteValidationSimulation( + quote: TransactionPayQuote, + executeRequest: RelayExecuteRequest, +): QuoteValidationSimulation { + const transactionToSimulate = { + data: executeRequest.data.data, + from: quote.request.from, + to: executeRequest.data.to, + value: decimalToHex(executeRequest.data.value), + }; + + return { + ...(executeRequest.data.authorizationList?.length + ? { mock7702From: quote.request.from } + : {}), + transactions: [transactionToSimulate], + }; +} + +function buildRelay7702BatchValidationSimulation( + quote: TransactionPayQuote, + allParams: TransactionParams[], +): QuoteValidationSimulation { + const { from } = quote.request; + const batchTransaction = buildEip7702BatchTransaction(from, allParams, quote); + + return { + ...(quote.original.request.authorizationList?.length + ? { mock7702From: from } + : {}), + transactions: [batchTransaction], + }; +} + +function buildEip7702BatchTransaction( + from: Hex, + allParams: TransactionParams[], + quote: TransactionPayQuote, +): SimulationTransaction { + const calls = allParams.map((params) => [ + (params.to as Hex | undefined) ?? ZERO_ADDRESS, + params.value ?? '0x0', + params.data ?? '0x', + ]); + const mode = + `0x${ERC7579_CALL_TYPE_BATCH}${ERC7579_EXEC_TYPE_DEFAULT}`.padEnd( + 66, + '0', + ) as Hex; + const executionData = defaultAbiCoder.encode( + [CALLS_SIGNATURE], + [calls], + ) as Hex; + const gas = quote.original.metamask.gasLimits[0]; + + return { + data: erc7821Interface.encodeFunctionData('execute', [ + mode, + executionData, + ]) as Hex, + from, + ...(gas === undefined ? {} : { gas: toHex(gas) }), + to: from, + value: '0x0', + }; +} + +function toSimulationTransaction( + params: TransactionParams, +): SimulationTransaction { + return { + data: params.data as Hex | undefined, + from: params.from as Hex, + gas: params.gas as Hex | undefined, + maxFeePerGas: params.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: params.maxPriorityFeePerGas as Hex | undefined, + to: params.to as Hex | undefined, + value: (params.value as Hex | undefined) ?? '0x0', + }; +} + +function decimalToHex(value: string): Hex { + return new BigNumber(value).toString(16).replace(/^/u, '0x') as Hex; +} + /** * Submit source transactions via the TransactionController. * diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts index 53907a23c8..9e134c9988 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts @@ -1,19 +1,10 @@ -import { defaultAbiCoder, Interface } from '@ethersproject/abi'; -import { toHex } from '@metamask/controller-utils'; -import type { - TransactionMeta, - TransactionParams, -} from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { PayStrategyCheckQuoteSupportRequest, PayStrategyQuoteSupportResult, - TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; -import type { SimulationTransaction } from '../../utils/simulation'; import type { QuoteValidationSimulation } from '../../utils/validation'; import { isQuoteValidationError, @@ -23,15 +14,6 @@ import { import { getRelaySubmitCalls } from './relay-submit'; import type { RelayExecuteRequest, RelayQuote } from './types'; -const ERC7579_CALL_TYPE_BATCH = '01'; -const ERC7579_EXEC_TYPE_DEFAULT = '00'; -const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; - -const erc7821Interface = new Interface([ - 'function execute(bytes32 mode, bytes executionData)', -]); - export { QuoteValidationError as RelayQuoteValidationError }; export async function validateRelayQuoteSupport( @@ -75,7 +57,7 @@ async function buildRelayValidationSimulation({ quote, transaction, }: { - messenger: TransactionPayControllerMessenger; + messenger: PayStrategyCheckQuoteSupportRequest['messenger']; quote: TransactionPayQuote; transaction: TransactionMeta; }): Promise { @@ -85,92 +67,11 @@ async function buildRelayValidationSimulation({ transaction, }); - if (quote.original.metamask.isExecute) { - return buildRelayExecuteValidationSimulation( - quote, - submitCalls.executeRequest as RelayExecuteRequest, - ); - } - - if (quote.original.metamask.is7702) { - return buildRelay7702BatchValidationSimulation( - quote, - submitCalls.allParams, - ); + if (submitCalls.executeRequest) { + validateAuthorizationList(submitCalls.executeRequest); } - return { - transactions: submitCalls.allParams.map(toSimulationTransaction), - }; -} - -function buildRelayExecuteValidationSimulation( - quote: TransactionPayQuote, - executeRequest: RelayExecuteRequest, -): QuoteValidationSimulation { - validateAuthorizationList(executeRequest); - - const transactionToSimulate = { - data: executeRequest.data.data, - from: quote.request.from, - to: executeRequest.data.to, - value: decimalToHex(executeRequest.data.value), - }; - - return { - ...(executeRequest.data.authorizationList?.length - ? { mock7702From: quote.request.from } - : {}), - transactions: [transactionToSimulate], - }; -} - -function buildRelay7702BatchValidationSimulation( - quote: TransactionPayQuote, - allParams: TransactionParams[], -): QuoteValidationSimulation { - const { from } = quote.request; - const batchTransaction = buildEip7702BatchTransaction(from, allParams, quote); - - return { - ...(quote.original.request.authorizationList?.length - ? { mock7702From: from } - : {}), - transactions: [batchTransaction], - }; -} - -function buildEip7702BatchTransaction( - from: Hex, - allParams: TransactionParams[], - quote: TransactionPayQuote, -): SimulationTransaction { - const calls = allParams.map((params) => [ - (params.to as Hex | undefined) ?? ZERO_ADDRESS, - params.value ?? '0x0', - params.data ?? '0x', - ]); - const mode = - `0x${ERC7579_CALL_TYPE_BATCH}${ERC7579_EXEC_TYPE_DEFAULT}`.padEnd( - 66, - '0', - ) as Hex; - const executionData = defaultAbiCoder.encode( - [CALLS_SIGNATURE], - [calls], - ) as Hex; - const gas = quote.original.metamask.gasLimits[0]; - - return { - data: erc7821Interface.encodeFunctionData('execute', [ - mode, - executionData, - ]) as Hex, - from, - ...(gas === undefined ? {} : { gas: toHex(gas) }), - to: from, - value: '0x0', - }; + return submitCalls.getValidationSimulation(); } function validateAuthorizationList(executeRequest: RelayExecuteRequest): void { @@ -190,20 +91,6 @@ function validateAuthorizationList(executeRequest: RelayExecuteRequest): void { } } -function toSimulationTransaction( - params: TransactionParams, -): SimulationTransaction { - return { - data: params.data as Hex | undefined, - from: params.from as Hex, - gas: params.gas as Hex | undefined, - maxFeePerGas: params.maxFeePerGas as Hex | undefined, - maxPriorityFeePerGas: params.maxPriorityFeePerGas as Hex | undefined, - to: params.to as Hex | undefined, - value: (params.value as Hex | undefined) ?? '0x0', - }; -} - function shouldSkipValidation(quote: TransactionPayQuote): boolean { const { request } = quote; @@ -219,7 +106,3 @@ function getValidationError(error: unknown): string { return (error as Error).message; } - -function decimalToHex(value: string): Hex { - return new BigNumber(value).toString(16).replace(/^/u, '0x') as Hex; -} From 8d2856d1b0d9bb30860d820392ce595090b3e8ef Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 23:51:00 +0100 Subject: [PATCH 6/6] refactor(transaction-pay-controller): reuse 7702 batch encoder --- packages/transaction-controller/CHANGELOG.md | 4 + packages/transaction-controller/src/index.ts | 5 +- .../transaction-controller/src/utils/batch.ts | 162 +++++++------ .../src/strategy/relay/relay-submit.ts | 216 +++++++++++------- .../strategy/relay/relay-validation.test.ts | 1 - 5 files changed, 237 insertions(+), 151 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e08cc10730..decf38d103 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `generateEIP7702BatchTransaction` for building EIP-7702 batch transaction calldata from nested calls ([#9143](https://github.com/MetaMask/core/pull/9143)) + ## [68.0.0] ### Changed diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 3439ba812d..8becde3c1d 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -128,7 +128,10 @@ export { WalletDevice, } from './types'; export { mergeGasFeeEstimates } from './utils/gas-flow'; -export { decodeAuthorizationSignature } from './utils/eip7702'; +export { + decodeAuthorizationSignature, + generateEIP7702BatchTransaction, +} from './utils/eip7702'; export { isEIP1559Transaction, normalizeTransactionParams, diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index c5a2017460..a45bc2dd39 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -108,6 +108,17 @@ type IsAtomicBatchSupportedRequestInternal = { publicKeyEIP7702?: Hex; }; +type PrepareEIP7702BatchTransactionRequest = { + messenger: TransactionControllerMessenger; + publicKeyEIP7702?: Hex; + request: TransactionBatchRequest; +}; + +type EIP7702BatchTransaction = { + nestedTransactions: NestedTransactionMetadata[]; + txParams: TransactionParams; +}; + const log = createModuleLogger(projectLogger, 'batch'); export const ERROR_MESSAGE_NO_UPGRADE_CONTRACT = @@ -238,79 +249,19 @@ export function generateBatchId(): Hex { return bytesToHex(idBytes); } -/** - * Generate the metadata for a nested transaction. - * - * @param request - The batch request. - * @param singleRequest - The request for a single transaction. - * @param messenger - The transaction controller messenger. - * @param networkClientId - The network client ID. - * @returns The metadata for the nested transaction. - */ -async function getNestedTransactionMeta( - request: TransactionBatchRequest, - singleRequest: TransactionBatchSingleRequest, - messenger: TransactionControllerMessenger, - networkClientId: NetworkClientId, -): Promise { - const { from } = request; - const { params, type: requestedType } = singleRequest; - - if (requestedType) { - return { - ...params, - type: requestedType, - }; - } - - const { type: determinedType } = await determineTransactionType( - { from, ...params }, - { messenger, networkClientId }, - ); - - return { - ...params, - type: determinedType, - }; -} - -/** - * Process a batch transaction using an EIP-7702 transaction. - * - * @param request - The request object including the user request and necessary callbacks. - * @returns The batch result object including the batch ID. - */ -async function addTransactionBatchWith7702( - request: AddTransactionBatchRequest, -): Promise { - const { - addTransaction, - messenger, - publicKeyEIP7702, - request: userRequest, - } = request; +async function prepareEIP7702BatchTransaction( + request: PrepareEIP7702BatchTransactionRequest, +): Promise { + const { messenger, publicKeyEIP7702, request: userRequest } = request; const { atomic, - batchId: batchIdOverride, disableUpgrade, from, - gasFeeToken, gasLimit7702, - isInternal, networkClientId, - origin, overwriteUpgrade, - requestId, - requiredAssets, - requireApproval, - securityAlertId, - skipInitialGasEstimate, transactions, - excludeNativeTokenForFee, - isGasFeeIncluded, - isGasFeeSponsored, - validateSecurity, } = userRequest; const chainId = getChainId({ messenger, networkClientId }); @@ -389,6 +340,89 @@ async function addTransactionBatchWith7702( txParams.authorizationList = [{ address: upgradeContractAddress }]; } + return { nestedTransactions, txParams }; +} + +/** + * Generate the metadata for a nested transaction. + * + * @param request - The batch request. + * @param singleRequest - The request for a single transaction. + * @param messenger - The transaction controller messenger. + * @param networkClientId - The network client ID. + * @returns The metadata for the nested transaction. + */ +async function getNestedTransactionMeta( + request: TransactionBatchRequest, + singleRequest: TransactionBatchSingleRequest, + messenger: TransactionControllerMessenger, + networkClientId: NetworkClientId, +): Promise { + const { from } = request; + const { params, type: requestedType } = singleRequest; + + if (requestedType) { + return { + ...params, + type: requestedType, + }; + } + + const { type: determinedType } = await determineTransactionType( + { from, ...params }, + { messenger, networkClientId }, + ); + + return { + ...params, + type: determinedType, + }; +} + +/** + * Process a batch transaction using an EIP-7702 transaction. + * + * @param request - The request object including the user request and necessary callbacks. + * @returns The batch result object including the batch ID. + */ +async function addTransactionBatchWith7702( + request: AddTransactionBatchRequest, +): Promise { + const { + addTransaction, + messenger, + publicKeyEIP7702, + request: userRequest, + } = request; + + const { + batchId: batchIdOverride, + gasFeeToken, + isInternal, + networkClientId, + origin, + requestId, + requiredAssets, + requireApproval, + securityAlertId, + skipInitialGasEstimate, + transactions, + excludeNativeTokenForFee, + isGasFeeIncluded, + isGasFeeSponsored, + validateSecurity, + } = userRequest; + + const { nestedTransactions, txParams } = await prepareEIP7702BatchTransaction( + { + messenger, + publicKeyEIP7702, + request: userRequest, + }, + ); + + const chainId = getChainId({ messenger, networkClientId }); + if (validateSecurity) { const securityRequest: ValidateSecurityRequest = { method: 'eth_sendTransaction', 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 023dc19691..ddf9078ac1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -1,10 +1,15 @@ -import { defaultAbiCoder, Interface } from '@ethersproject/abi'; import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; -import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionParams } from '@metamask/transaction-controller'; +import { + generateEIP7702BatchTransaction, + TransactionType, +} from '@metamask/transaction-controller'; import type { AuthorizationList, + BatchTransactionParams, + TransactionBatchRequest, + TransactionBatchSingleRequest, TransactionMeta, + TransactionParams, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -55,17 +60,9 @@ import type { } from './types'; const FALLBACK_HASH = '0x0' as Hex; -const ERC7579_CALL_TYPE_BATCH = '01'; -const ERC7579_EXEC_TYPE_DEFAULT = '00'; -const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); -const erc7821Interface = new Interface([ - 'function execute(bytes32 mode, bytes executionData)', -]); - type RelaySubmitParams = { allParams: TransactionParams[]; normalizedParams: TransactionParams[]; @@ -735,46 +732,37 @@ function buildRelay7702BatchValidationSimulation( allParams: TransactionParams[], ): QuoteValidationSimulation { const { from } = quote.request; - const batchTransaction = buildEip7702BatchTransaction(from, allParams, quote); + const firstParam = allParams[0]; + const batchTransaction = generateEIP7702BatchTransaction( + from, + allParams.map(toBatchTransactionParams), + ); return { ...(quote.original.request.authorizationList?.length ? { mock7702From: from } : {}), - transactions: [batchTransaction], + transactions: [ + toSimulationTransaction({ + ...batchTransaction, + from, + gas: getGasLimit7702(quote), + maxFeePerGas: firstParam?.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: firstParam?.maxPriorityFeePerGas as + | Hex + | undefined, + }), + ], }; } -function buildEip7702BatchTransaction( - from: Hex, - allParams: TransactionParams[], - quote: TransactionPayQuote, -): SimulationTransaction { - const calls = allParams.map((params) => [ - (params.to as Hex | undefined) ?? ZERO_ADDRESS, - params.value ?? '0x0', - params.data ?? '0x', - ]); - const mode = - `0x${ERC7579_CALL_TYPE_BATCH}${ERC7579_EXEC_TYPE_DEFAULT}`.padEnd( - 66, - '0', - ) as Hex; - const executionData = defaultAbiCoder.encode( - [CALLS_SIGNATURE], - [calls], - ) as Hex; - const gas = quote.original.metamask.gasLimits[0]; - +function toBatchTransactionParams( + params: TransactionParams, +): BatchTransactionParams { return { - data: erc7821Interface.encodeFunctionData('execute', [ - mode, - executionData, - ]) as Hex, - from, - ...(gas === undefined ? {} : { gas: toHex(gas) }), - to: from, - value: '0x0', + data: params.data as Hex | undefined, + to: params.to as Hex | undefined, + value: params.value as Hex | undefined, }; } @@ -897,49 +885,16 @@ async function submitViaTransactionController( }, ); } else { - const gasLimit7702 = metamask.is7702 - ? toHex(metamask.gasLimits[0]) - : undefined; - - const prependCount = allParams.length - normalizedParams.length; - - const transactions = allParams.map((singleParams, index) => { - const gasLimit = gasLimits[index]; - const gas = - gasLimit === undefined || gasLimit7702 ? undefined : toHex(gasLimit); - - return { - params: { - data: singleParams.data as Hex, - gas, - maxFeePerGas: singleParams.maxFeePerGas as Hex, - maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, - to: singleParams.to as Hex, - value: singleParams.value as Hex, - }, - type: getTransactionType( - prependCount, - index, - getEffectiveTransactionType(transaction), - normalizedParams.length, - ), - }; - }); - - await messenger.call('TransactionController:addTransactionBatch', { - from, - disable7702: !gasLimit7702, - disableHook: Boolean(gasLimit7702), - disableSequential: Boolean(gasLimit7702), - gasFeeToken, - gasLimit7702, - networkClientId, - origin: ORIGIN_METAMASK, - isInternal: true, - overwriteUpgrade: true, - requireApproval: false, - transactions, - }); + await messenger.call( + 'TransactionController:addTransactionBatch', + buildRelayTransactionBatchRequest({ + allParams, + messenger, + normalizedParams, + quote, + transaction, + }), + ); } end(); @@ -962,6 +917,97 @@ async function submitViaTransactionController( return hash as Hex; } +function buildRelayTransactionBatchRequest({ + allParams, + messenger, + normalizedParams, + quote, + transaction, +}: { + allParams: TransactionParams[]; + messenger: TransactionPayControllerMessenger; + normalizedParams: TransactionParams[]; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): TransactionBatchRequest { + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { metamask } = quote.original; + const networkClientId = getNetworkClientId(messenger, sourceChainId); + const gasFeeToken = quote.fees?.isSourceGasFeeToken + ? sourceTokenAddress + : undefined; + const gasLimit7702 = getGasLimit7702(quote); + + return { + from, + disable7702: !gasLimit7702, + disableHook: Boolean(gasLimit7702), + disableSequential: Boolean(gasLimit7702), + gasFeeToken, + gasLimit7702, + networkClientId, + origin: ORIGIN_METAMASK, + isInternal: true, + overwriteUpgrade: true, + requireApproval: false, + transactions: buildRelayBatchTransactions({ + allParams, + gasLimit7702, + gasLimits: metamask.gasLimits, + normalizedParams, + transaction, + }), + }; +} + +function buildRelayBatchTransactions({ + allParams, + gasLimit7702, + gasLimits, + normalizedParams, + transaction, +}: { + allParams: TransactionParams[]; + gasLimit7702?: Hex; + gasLimits: number[]; + normalizedParams: TransactionParams[]; + transaction: TransactionMeta; +}): TransactionBatchSingleRequest[] { + const prependCount = allParams.length - normalizedParams.length; + + return allParams.map((singleParams, index) => { + const gasLimit = gasLimits[index]; + const gas = + gasLimit === undefined || gasLimit7702 ? undefined : toHex(gasLimit); + + return { + params: { + data: singleParams.data as Hex, + gas, + maxFeePerGas: singleParams.maxFeePerGas as Hex, + maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, + to: singleParams.to as Hex, + value: singleParams.value as Hex, + }, + type: getTransactionType( + prependCount, + index, + getEffectiveTransactionType(transaction), + normalizedParams.length, + ), + }; + }); +} + +function getGasLimit7702( + quote: TransactionPayQuote, +): Hex | undefined { + const { gasLimits, is7702 } = quote.original.metamask; + const gasLimit = gasLimits[0]; + + return is7702 && gasLimit !== undefined ? toHex(gasLimit) : undefined; +} + /** * Determine the transaction type for a given index in the batch. * diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts index 2f0db6eccb..07471383fc 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts @@ -549,7 +549,6 @@ describe('Relay quote simulation validation', () => { s: '0x2' as Hex, yParity: '0x0' as Hex, }; - await validateRelayQuote({ messenger, quote: buildQuote({