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/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/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 0d44f891d8..2b547170eb 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, @@ -8,17 +9,20 @@ import type { import { getPayStrategiesConfig } from '../../utils/feature-flags'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; +import { validateRelayQuoteSupport } from './relay-validation'; import { RelayStrategy } from './RelayStrategy'; import type { RelayQuote } from './types'; jest.mock('./relay-quotes'); jest.mock('./relay-submit'); +jest.mock('./relay-validation'); jest.mock('../../utils/feature-flags'); describe('RelayStrategy', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); + const validateRelayQuoteSupportMock = jest.mocked(validateRelayQuoteSupport); const messenger = {} as never; @@ -57,6 +61,7 @@ describe('RelayStrategy', () => { enabled: true, }, }); + validateRelayQuoteSupportMock.mockResolvedValue({ isSupported: true }); }); it('returns true from supports when relay is enabled', () => { @@ -92,6 +97,40 @@ describe('RelayStrategy', () => { expect(getRelayQuotesMock).toHaveBeenCalledWith(request); }); + it('delegates checkQuoteSupport', async () => { + const quote = buildQuote(); + const supportResult = { + isSupported: false, + validationError: 'RPC down', + }; + + validateRelayQuoteSupportMock.mockResolvedValue(supportResult); + + const strategy = new RelayStrategy(); + const checkRequest = { + messenger, + quotes: [quote], + transaction: request.transaction, + }; + const result = await strategy.checkQuoteSupport(checkRequest); + + expect(result).toStrictEqual(supportResult); + expect(validateRelayQuoteSupportMock).toHaveBeenCalledWith(checkRequest); + }); + + function buildQuote( + requestOverrides: Partial['request']> = {}, + ): TransactionPayQuote { + return { + request: { + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + ...requestOverrides, + }, + 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 8ed8d23c54..f557cdc25a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -1,12 +1,15 @@ import type { PayStrategy, + PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, + PayStrategyQuoteSupportResult, TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; import { getRelayQuotes } from './relay-quotes'; import { submitRelayQuotes } from './relay-submit'; +import { validateRelayQuoteSupport } from './relay-validation'; import type { RelayQuote } from './types'; export class RelayStrategy implements PayStrategy { @@ -21,6 +24,12 @@ export class RelayStrategy implements PayStrategy { return getRelayQuotes(request); } + async checkQuoteSupport( + request: PayStrategyCheckQuoteSupportRequest, + ): Promise { + return await validateRelayQuoteSupport(request); + } + async execute( request: PayStrategyExecuteRequest, ): ReturnType['execute']> { 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..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,9 +1,15 @@ 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'; @@ -21,6 +27,7 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags'; import { getNetworkClientId } from '../../utils/provider'; +import type { SimulationTransaction } from '../../utils/simulation'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -32,6 +39,7 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import type { QuoteValidationSimulation } from '../../utils/validation'; import { RELAY_DEPOSIT_TYPES, RELAY_FAILURE_STATUSES, @@ -55,6 +63,16 @@ const FALLBACK_HASH = '0x0' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); +type RelaySubmitParams = { + allParams: TransactionParams[]; + normalizedParams: TransactionParams[]; +}; + +type RelaySubmitCalls = RelaySubmitParams & { + executeRequest?: RelayExecuteRequest; + getValidationSimulation: () => QuoteValidationSimulation; +}; + /** * Submits Relay quotes. * @@ -270,7 +288,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 +382,110 @@ 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, executeRequest, normalizedParams } = + await getRelaySubmitCalls({ + messenger, + quote, + transaction, + }); + + if (quote.original.metamask.isExecute) { + return await submitViaRelayExecute( + quote, + messenger, + executeRequest as RelayExecuteRequest, + ); + } + + 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 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 } : {}), + getValidationSimulation: () => + buildRelaySubmitValidationSimulation({ + allParams, + executeRequest, + quote, + }), + normalizedParams, + }; +} + +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 +541,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; } /** @@ -505,22 +596,47 @@ 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 { 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; +} + +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 +659,7 @@ async function submitViaRelayExecute( log('Delegation result for source calls', delegation); - const executeBody: RelayExecuteRequest = { + return { executionKind: 'rawCalls', data: { chainId: Number(sourceChainId), @@ -568,20 +684,104 @@ async function submitViaRelayExecute( }, requestId, }; +} - log('Submitting via Relay execute', { executeBody, from }); +function buildRelaySubmitValidationSimulation({ + allParams, + executeRequest, + quote, +}: { + allParams: TransactionParams[]; + executeRequest?: RelayExecuteRequest; + quote: TransactionPayQuote; +}): QuoteValidationSimulation { + if (executeRequest) { + return buildRelayExecuteValidationSimulation(quote, executeRequest); + } - 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}`); + if (quote.original.metamask.is7702) { + return buildRelay7702BatchValidationSimulation(quote, allParams); } - log('Relay execute response', result); + return { + transactions: allParams.map(toSimulationTransaction), + }; +} - return FALLBACK_HASH; +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 firstParam = allParams[0]; + const batchTransaction = generateEIP7702BatchTransaction( + from, + allParams.map(toBatchTransactionParams), + ); + + return { + ...(quote.original.request.authorizationList?.length + ? { mock7702From: from } + : {}), + transactions: [ + toSimulationTransaction({ + ...batchTransaction, + from, + gas: getGasLimit7702(quote), + maxFeePerGas: firstParam?.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: firstParam?.maxPriorityFeePerGas as + | Hex + | undefined, + }), + ], + }; +} + +function toBatchTransactionParams( + params: TransactionParams, +): BatchTransactionParams { + return { + data: params.data as Hex | undefined, + to: params.to as Hex | undefined, + value: params.value as Hex | undefined, + }; +} + +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; } /** @@ -685,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(); @@ -750,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 new file mode 100644 index 0000000000..07471383fc --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts @@ -0,0 +1,818 @@ +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 { NATIVE_TOKEN_ADDRESS, TransactionPayStrategy } from '../../constants'; +import { getMessengerMock } from '../../tests/messenger-mock'; +import type { + TransactionPayControllerMessenger, + 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 { isQuoteValidationError } from '../../utils/validation'; +import { + RelayQuoteValidationError, + validateRelayQuoteSupport, +} from './relay-validation'; +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 EIP7702_DELEGATOR_ADDRESS = + '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b' as Hex; +const TRANSACTION_MOCK = { + id: 'tx-id', + 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); + const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); + const rpcRequestMock = jest.mocked(rpcRequest); + const simulateTransactionsMock = jest.mocked(simulateTransactions); + const { + findNetworkClientIdByChainIdMock, + getControllerStateMock, + getDelegationTransactionMock, + getPaymentOverrideDataMock, + getRemoteFeatureFlagControllerStateMock, + messenger, + } = getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + findNetworkClientIdByChainIdMock.mockReturnValue('network-client-id'); + getFeatureFlagsMock.mockReturnValue({ + relayFallbackGas: { max: 123 }, + } as never); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + remoteFeatureFlags: {}, + } as never); + getControllerStateMock.mockReturnValue({ transactionData: {} } 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('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({ + messenger, + quote: buildQuote({ + sourceAmountRaw: '1500', + transferAmountRaw: '500', + }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: 'Insufficient quote source amount', + }); + 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: 'Insufficient balance for decoded quote amount', + }); + 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: '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: '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: 'ERC20: transfer amount exceeds balance', + } as never); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: '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({ + error: 'route reverted', + } as never); + + await expect( + validateRelayQuote({ + messenger, + quote: buildQuote({ sourceAmountRaw: '500', transferAmountRaw: '500' }), + transaction: TRANSACTION_MOCK, + }), + ).rejects.toMatchObject({ + validationError: '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: '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: + '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: '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${EIP7702_DELEGATOR_ADDRESS.slice(2)}`, + }, + }, + transactions: [ + expect.objectContaining({ + data: '0x1234', + from: FROM_MOCK, + to: '0x5555555555555555555555555555555555555555', + value: '0x0', + }), + ], + }), + ); + }); + + 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('boom'); + + 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: '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.toMatchObject({ + validationError: expect.stringContaining('Cannot'), + }); + }); + + 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, + isHyperliquidSource, + is7702 = false, + isPostQuote = false, + gasLimits = [], + paymentOverride, + isPolymarketDepositWallet, + requestAuthorizationList, + sourceAmountRaw, + sourceTokenAddress = TOKEN_ADDRESS_MOCK, + stepData, + transactionCount = 1, + transferAmountRaw, +}: { + isExecute?: boolean; + isHyperliquidSource?: boolean; + is7702?: boolean; + isPostQuote?: boolean; + gasLimits?: number[]; + paymentOverride?: boolean; + isPolymarketDepositWallet?: boolean; + requestAuthorizationList?: { + address: Hex; + chainId: Hex; + nonce: Hex; + r: Hex; + s: Hex; + yParity: Hex; + }[]; + sourceAmountRaw: string; + sourceTokenAddress?: Hex; + 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 }, + request: requestAuthorizationList + ? { authorizationList: requestAuthorizationList } + : {}, + 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, + isHyperliquidSource, + isPolymarketDepositWallet, + isPostQuote, + paymentOverride, + sourceBalanceRaw: '1000', + sourceChainId: CHAIN_ID_MOCK, + sourceTokenAddress, + 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; +} + +async function validateRelayQuote({ + messenger, + quote, + signal, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + signal?: AbortSignal; + transaction: TransactionMeta; +}): Promise { + const result = await validateRelayQuoteSupport({ + messenger, + quotes: [quote], + signal, + transaction, + }); + + if (!result.isSupported) { + throw new RelayQuoteValidationError( + result.validationError ?? 'Relay quote is not supported', + ); + } +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts new file mode 100644 index 0000000000..9e134c9988 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts @@ -0,0 +1,108 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { + PayStrategyCheckQuoteSupportRequest, + PayStrategyQuoteSupportResult, + TransactionPayQuote, +} from '../../types'; +import type { QuoteValidationSimulation } from '../../utils/validation'; +import { + isQuoteValidationError, + QuoteValidationError, + validateQuoteExecution, +} from '../../utils/validation'; +import { getRelaySubmitCalls } from './relay-submit'; +import type { RelayExecuteRequest, RelayQuote } from './types'; + +export { QuoteValidationError as RelayQuoteValidationError }; + +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, +}: { + messenger: PayStrategyCheckQuoteSupportRequest['messenger']; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise { + const submitCalls = await getRelaySubmitCalls({ + messenger, + quote, + transaction, + }); + + if (submitCalls.executeRequest) { + validateAuthorizationList(submitCalls.executeRequest); + } + + return submitCalls.getValidationSimulation(); +} + +function validateAuthorizationList(executeRequest: RelayExecuteRequest): 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 QuoteValidationError( + 'Relay execute authorization list is incomplete', + ); + } + } +} + +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; +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 15897ece30..be7e076e93 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 message from the latest quote attempt. */ + quoteValidationError?: string; + /** Timestamp of when quotes were last updated. */ quotesLastUpdated?: number; @@ -432,6 +435,15 @@ export type TransactionPaySourceAmount = { targetTokenAddress: 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 message explaining why the quote set was rejected. */ + validationError?: string; +}; + /** Source token used to pay for required tokens. */ export type TransactionPaymentToken = { /** Address of the payment token. */ @@ -696,7 +708,7 @@ export type PayStrategy = { */ checkQuoteSupport?: ( request: PayStrategyCheckQuoteSupportRequest, - ) => boolean | 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 69fe267549..a91db23f74 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,45 @@ describe('Quotes Utils', () => { }); }); + it('stores quote validation errors when quotes are rejected', async () => { + const validationError = 'Insufficient source token balance for quote'; + + 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 = 'Insufficient source token balance for quote'; + + 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: { @@ -409,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 098911a2de..9c3e3b5536 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -101,6 +101,7 @@ export async function updateQuotes( updateTransactionData(transactionId, (data) => { data.isLoading = true; + data.quoteValidationError = undefined; }); try { @@ -134,7 +135,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 +174,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 +599,7 @@ async function getQuotes( ): Promise<{ batchTransactions: BatchTransaction[]; quotes: TransactionPayQuote[]; + validationError?: string; }> { const { id: transactionId } = transaction; const strategies = getStrategiesByName( @@ -626,6 +629,8 @@ async function getQuotes( transaction, }; + let validationError: string | undefined; + for (const { name, strategy } of strategies) { try { const support = await checkStrategySupport(strategy, request); @@ -654,7 +659,9 @@ async function getQuotes( transaction, }); - if (!quoteSupport) { + if (!quoteSupport.isSupported) { + validationError ??= quoteSupport.validationError; + log('Strategy does not support quotes', { strategy: name, transactionId, @@ -697,5 +704,6 @@ async function getQuotes( return { batchTransactions: [], quotes: [], + validationError, }; } 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/sentinel.ts b/packages/transaction-pay-controller/src/utils/sentinel.ts new file mode 100644 index 0000000000..9d5ed1f74f --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/sentinel.ts @@ -0,0 +1,134 @@ +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); + + log('Simulation request', { chainId, request, url }); + + 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}`, + ); + } + + 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}`, + ); + const networkData = await response.json(); + + log('Fetched simulation networks', { networkData }); + + return networkData; +} + +function getUrl(subdomain: string): string { + return BASE_URL.replace('{0}', subdomain); +} 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..e5cccab745 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/simulation.test.ts @@ -0,0 +1,339 @@ +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', + message: 'network timeout', + }); + }); + + 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', + 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({ + 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({ + 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({ + 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({ + 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 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({ + message: '[object Object]', + }); + }); + + 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({ + message: 'rpc error string', + }); + }); + + it('uses direct string RPC errors', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce('direct rpc error string'); + + await expect( + simulateQuoteTransactions(buildRequest()), + ).rejects.toMatchObject({ + message: 'direct rpc error string', + }); + }); + + it('ignores nested data messages when the top-level message is missing', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce({ + data: { message: 'nested rpc error string' }, + }); + + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); + }); + + 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(); + }); + + it('ignores unsupported chain errors when RPC fallback throws null', async () => { + simulateTransactionsMock.mockRejectedValue(CHAIN_UNSUPPORTED_ERROR); + rpcRequestMock.mockRejectedValueOnce(null); + + 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({ + 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({ + message: 'callTrace direct error', + }); + }); + + it('ignores nested callTrace errors', async () => { + simulateTransactionsMock.mockResolvedValue({ + transactions: [ + { + callTrace: { + calls: [{}, { error: 'nested callTrace error' }], + }, + }, + ], + }); + + expect(await simulateQuoteTransactions(buildRequest())).toBeUndefined(); + }); + }); + + 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/simulation.ts b/packages/transaction-pay-controller/src/utils/simulation.ts new file mode 100644 index 0000000000..45706a8148 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/simulation.ts @@ -0,0 +1,414 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import { createModuleLogger } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; + +import { projectLogger } from '../logger'; +import type { TransactionPayControllerMessenger } 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; + +const log = createModuleLogger(projectLogger, 'simulation'); + +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 { + constructor(message: string) { + super(message); + this.name = 'TransactionPaySimulationError'; + } +} + +export async function simulateQuoteTransactions( + request: SimulationRequest, +): Promise { + 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), + withCallTrace: true, + withGas: true, + withLogs: true, + }); + 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; + } + + throw new TransactionPaySimulationError( + fallbackError ?? normalizeSimulationErrorMessage(getErrorMessage(error)), + ); + } + + 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; + + log('Simulation response transaction failed', { + error, + fallbackError, + index, + }); + + throw new TransactionPaySimulationError( + 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) { + 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; + } + + const debugTraceCallResult = await getDebugTraceCallResult( + request, + transaction, + ); + + if (debugTraceCallResult?.isSupported) { + log('debug_traceCall fallback completed', debugTraceCallResult); + + return debugTraceCallResult; + } + + const estimateGasResult = await getEstimateGasResult(request, transaction); + + log('eth_estimateGas fallback completed', estimateGasResult); + + return estimateGasResult; +} + +async function getDebugTraceCallResult( + request: SimulationRequestWithOverrides, + transaction: SimulationTransaction, +): Promise { + try { + log('Running debug_traceCall fallback'); + + 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) { + log('debug_traceCall fallback failed', { error }); + + return getRpcFallbackErrorResult(error); + } +} + +async function getEstimateGasResult( + request: SimulationRequestWithOverrides, + transaction: SimulationTransaction, +): Promise { + try { + log('Running eth_estimateGas fallback'); + + await rpcRequest({ + messenger: request.messenger, + chainId: request.chainId, + method: 'eth_estimateGas', + params: getRpcCallParams(transaction, request), + options: getRpcFallbackRequestOptions(request), + }); + } catch (error) { + log('eth_estimateGas fallback failed', { 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 errors = [ + trace.revertReason, + decodeRevertData(trace.output), + trace.error, + ] + .filter((error): error is string => error !== undefined) + .map(normalizeSimulationErrorMessage); + + return errors[0]; +} + +function getRpcErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string') { + return decodeRevertData(error as Hex) ?? error; + } + + if (error instanceof Error) { + return error.message.replace(/^Error: /u, ''); + } + + if (!error || typeof error !== 'object') { + return undefined; + } + + const { data, message } = error as Record; + + if (typeof data === 'string') { + return decodeRevertData(data as Hex) ?? data; + } + + return typeof message === 'string' + ? message.replace(/^Error: /u, '') + : undefined; +} + +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 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 getCallTraceError( + transaction: SentinelSimulationResponseTransaction, +): string | undefined { + const { callTrace } = transaction; + + if (!callTrace) { + return undefined; + } + + return decodeRevertData(callTrace.output) ?? callTrace.error; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +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/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 29017e80d5..23c6a51ee2 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -135,15 +135,21 @@ describe('Strategy Utils', () => { quotes: [], } as never; - it('uses checkQuoteSupport when available', async () => { + it('passes through a structured support result unchanged', async () => { + const validationError = 'boom'; const strategy = { - checkQuoteSupport: jest.fn().mockReturnValue(false), + checkQuoteSupport: jest.fn().mockReturnValue({ + isSupported: false, + validationError, + }), getQuotes: jest.fn(), execute: jest.fn(), }; - expect(await checkStrategyQuoteSupport(strategy, request)).toBe(false); - expect(strategy.checkQuoteSupport).toHaveBeenCalledWith(request); + expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ + isSupported: false, + validationError, + }); }); it('defaults to supported when no post-quote support check is provided', async () => { @@ -152,7 +158,9 @@ describe('Strategy Utils', () => { execute: jest.fn(), }; - expect(await checkStrategyQuoteSupport(strategy, request)).toBe(true); + expect(await checkStrategyQuoteSupport(strategy, request)).toStrictEqual({ + isSupported: true, + }); }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index b968c0712b..ce9a4d9e96 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,10 @@ 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 }; } - return true; + 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 new file mode 100644 index 0000000000..612f4af306 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/validation.ts @@ -0,0 +1,201 @@ +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, +} 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: string; + + constructor(validationError: string) { + super(validationError); + 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(error.message); + } + + 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( + `Cannot validate payment token balance - ${(error as Error).message}`, + ); + } +} + +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('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( + '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(message: string): never { + throw new QuoteValidationError(message); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Quote validation aborted'); + } +}