From 7b25870dcbbfa7c54eaad4df3886efced47f5c56 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sat, 6 Jun 2026 08:21:19 +0100 Subject: [PATCH 01/19] feat(transaction-pay-controller): flat signature steps in server strategy Replace separate steps and signatureSteps arrays with a single flat ServerStep union discriminated by type ('transaction' | 'signature'). - ServerTransactionStep for on-chain steps - ServerSignatureStep for off-chain EIP-712 sign + POST flows - signatureFormat is required ('queryParam' | 'rsv'), drop 'field' - submitSignatureStep processes each signature step individually - Filter transaction steps for gas estimation and on-chain submission --- .../src/strategy/server/perps.ts | 45 ++---- .../src/strategy/server/server-quotes.ts | 32 +++- .../src/strategy/server/server-submit.ts | 140 ++++++++++++++++-- .../src/strategy/server/types.ts | 28 +++- 4 files changed, 190 insertions(+), 55 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.ts b/packages/transaction-pay-controller/src/strategy/server/perps.ts index 90cb6332be..12db1914ae 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.ts @@ -11,12 +11,6 @@ import { } from '../../constants'; import type { QuoteRequest } from '../../types'; -/** - * Shared 20-byte sentinel address emitted by the server strategy to flag a - * Hyperliquid perps deposit. Backend providers translate this to their own - * on-chain destination (e.g. Relay's 16-byte HyperCore USDC sentinel, Across's - * native USDC-PERPS token at the same address). - */ export const SERVER_HYPERCORE_USDC_PERPS_ADDRESS = '0x2100000000000000000000000000000000000000' as Hex; @@ -26,16 +20,6 @@ const HYPERLIQUID_BRIDGE_ADDRESS_LOWER = const HYPERLIQUID_BRIDGE_CALLDATA_FRAGMENT = HYPERLIQUID_BRIDGE_ADDRESS_LOWER.slice(2); -/** - * Detect whether a quote request represents a Hyperliquid perps deposit by - * sniffing the parent transaction calldata for a reference to the Hyperliquid - * bridge contract. Transaction type is intentionally NOT consulted so that any - * caller funnelling a bridge deposit through Pay is supported. - * - * @param request - Quote request from the transaction-pay controller. - * @param transaction - Parent transaction whose calldata is inspected. - * @returns Whether the request matches a Hyperliquid bridge deposit. - */ export function isServerPerpsDepositRequest( request: Pick< QuoteRequest, @@ -55,24 +39,14 @@ export function isServerPerpsDepositRequest( return transactionDataReferencesBridge(transaction); } -/** - * Translate a Hyperliquid perps-deposit quote request into the HyperCore - * direct-deposit shape with a provider-agnostic sentinel destination. Backend - * providers detect the sentinel and rewrite it to their respective on-chain - * destinations. - * - * Transaction pay starts from the parent on-chain asset (Arbitrum USDC, - * 6 decimals); HyperCore expects an 8-decimal amount, so the target amount is - * shifted accordingly. - * - * @param request - Quote request from the transaction-pay controller. - * @param transaction - Parent transaction whose calldata is inspected. - * @returns Normalized request, or the original request if not a perps deposit. - */ export function normalizeServerPerpsRequest( request: QuoteRequest, transaction: Pick, ): QuoteRequest { + if (request.isHyperliquidSource) { + return normalizePerpsWithdrawRequest(request); + } + if (!isServerPerpsDepositRequest(request, transaction)) { return request; } @@ -87,6 +61,17 @@ export function normalizeServerPerpsRequest( }; } +function normalizePerpsWithdrawRequest(request: QuoteRequest): QuoteRequest { + return { + ...request, + sourceChainId: CHAIN_ID_HYPERCORE, + sourceTokenAddress: SERVER_HYPERCORE_USDC_PERPS_ADDRESS, + sourceTokenAmount: new BigNumber(request.sourceTokenAmount) + .shiftedBy(HYPERCORE_USDC_DECIMALS - USDC_DECIMALS) + .toFixed(0), + }; +} + function transactionDataReferencesBridge( transaction: Pick, ): boolean { diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 7bb2d527d4..3a26c6a254 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -38,7 +38,7 @@ import type { ServerQuote, ServerQuoteRequest, ServerQuoteResult, - ServerQuoteStep, + ServerTransactionStep, } from './types'; import { ServerTradeType } from './types'; @@ -65,6 +65,12 @@ type SourceNetworkCost = Pick< maxPriorityFeePerGas: string | undefined; }; +function isTransactionStep( + step: ServerQuote['steps'][number], +): step is ServerTransactionStep { + return step.type === 'transaction'; +} + /** * Fetch server intents-api quotes and normalize them into Transaction Pay quotes. * @@ -146,7 +152,10 @@ async function buildServerQuoteRequest( targetTokenAddress, } = normalizedRequest; - const useExactInput = (isMaxAmount ?? false) || (isPostQuote ?? false); + const useExactInput = + (isMaxAmount ?? false) || + (isPostQuote ?? false) || + Boolean(normalizedRequest.isHyperliquidSource); const singleData = getSingleTransactionData(transaction); const isHypercore = targetChainId === CHAIN_ID_HYPERCORE; const isTokenTransfer = @@ -158,8 +167,11 @@ async function buildServerQuoteRequest( recipient = decodeTransferRecipient(singleData); } + const isHypercoreSource = sourceChainId === CHAIN_ID_HYPERCORE; const supportsGasless = - accountSupports7702 && isEIP7702Chain(messenger, sourceChainId); + !isHypercoreSource && + accountSupports7702 && + isEIP7702Chain(messenger, sourceChainId); const body: ServerQuoteRequest = { source: { chainId: Number(sourceChainId), token: sourceTokenAddress }, @@ -181,6 +193,7 @@ async function buildServerQuoteRequest( hasNoData || isTokenTransfer || isHypercore || + isHypercoreSource || (isPostQuote ?? false) || (isMaxAmount ?? false); @@ -222,7 +235,8 @@ function shouldRequestQuote(quoteRequest: QuoteRequest): boolean { return ( quoteRequest.targetAmountMinimum !== '0' || Boolean(quoteRequest.isPostQuote) || - Boolean(quoteRequest.isMaxAmount) + Boolean(quoteRequest.isMaxAmount) || + Boolean(quoteRequest.isHyperliquidSource) ); } @@ -233,11 +247,13 @@ async function normalizeQuote( ): Promise> { const { quote } = result; const { gasless } = quote; + const transactionSteps = quote.steps.filter(isTransactionStep); + const isSignatureOnly = transactionSteps.length === 0; const sourceNetwork = await calculateSourceNetworkCost({ - gasless, + gasless: gasless || isSignatureOnly, messenger, quoteRequest, - steps: quote.steps, + steps: transactionSteps, }); const sourceFiatRate = getTokenFiatRate( @@ -323,7 +339,7 @@ async function calculateSourceNetworkCost({ gasless: boolean; messenger: TransactionPayControllerMessenger; quoteRequest: QuoteRequest; - steps: ServerQuoteStep[]; + steps: ServerTransactionStep[]; }): Promise { const noFees = { estimate: ZERO_AMOUNT, @@ -452,7 +468,7 @@ async function calculateSourceNetworkCost({ } function stepToGasTransaction( - step: ServerQuoteStep, + step: ServerTransactionStep, from: Hex, ): QuoteGasTransaction { return { diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 2407cb4c75..1965fb5a01 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -1,4 +1,5 @@ -import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; +import { ORIGIN_METAMASK, successfulFetch, toHex } from '@metamask/controller-utils'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { TransactionMeta, TransactionParams, @@ -26,7 +27,8 @@ import { import { getServerStatus, submitServerIntent } from './server-api'; import type { ServerQuote, - ServerQuoteStep, + ServerSignatureStep, + ServerTransactionStep, ServerStatusResponse, ServerSubmitRequest, } from './types'; @@ -34,6 +36,18 @@ import { ServerStatus } from './types'; const log = createModuleLogger(projectLogger, 'server-strategy'); +function isSignatureStep( + step: ServerQuote['steps'][number], +): step is ServerSignatureStep { + return step.type === 'signature'; +} + +function isTransactionStep( + step: ServerQuote['steps'][number], +): step is ServerTransactionStep { + return step.type === 'transaction'; +} + /** * Submits server intent quotes. * @@ -78,10 +92,20 @@ async function executeSingleServerQuote( }, ); - if (quote.original.gasless) { - await submitViaServerExecute(quote, messenger, transaction); - } else { - await submitViaTransactionController(quote, messenger, transaction); + const { steps } = quote.original; + const signatureSteps = steps.filter(isSignatureStep); + const transactionSteps = steps.filter(isTransactionStep); + + for (const step of signatureSteps) { + await submitSignatureStep(step, transaction.txParams.from as Hex, messenger); + } + + if (transactionSteps.length > 0) { + if (quote.original.gasless) { + await submitViaServerExecute(quote, messenger, transaction); + } else { + await submitViaTransactionController(quote, messenger, transaction); + } } const targetHash = await waitForServerCompletion( @@ -117,11 +141,13 @@ async function submitViaServerExecute( sourceChainId, ); - const nestedTransactions = quote.original.steps.map((step) => ({ - data: step.data, - to: step.to, - value: step.value as Hex, - })); + const nestedTransactions = quote.original.steps + .filter(isTransactionStep) + .map((step) => ({ + data: step.data, + to: step.to, + value: step.value as Hex, + })); const sourceCallTransaction = { ...transaction, @@ -177,7 +203,7 @@ async function submitViaTransactionController( transaction: TransactionMeta, ): Promise { const { from, sourceChainId, sourceTokenAddress } = quote.request; - const { steps } = quote.original; + const steps = quote.original.steps.filter(isTransactionStep); if (steps.length === 0) { throw new Error('Server quote has no steps to submit'); @@ -326,8 +352,96 @@ async function submitViaTransactionController( } } +async function submitSignatureStep( + step: ServerSignatureStep, + from: Hex, + messenger: TransactionPayControllerMessenger, +): Promise { + const { sign, post } = step; + + const typedData = { + domain: sign.domain, + types: { + ...sign.types, + EIP712Domain: deriveEIP712DomainType(sign.domain), + }, + primaryType: sign.primaryType, + message: sign.value, + }; + + log('Signing typed data for signature step', { stepId: step.id, primaryType: sign.primaryType }); + + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { from, data: JSON.stringify(typedData) }, + SignTypedDataVersion.V4, + ); + + await postSignatureStepResult(post.endpoint, post.method, post.body, signature, post.signatureFormat); +} + +async function postSignatureStepResult( + endpoint: string, + method: string, + body: Record, + signature: string, + signatureFormat: 'queryParam' | 'rsv', +): Promise { + let url = endpoint; + let postBody: Record; + + if (signatureFormat === 'queryParam') { + url = `${endpoint}?signature=${signature}`; + postBody = body; + } else { + // eslint-disable-next-line id-length + const r = signature.slice(0, 66); + // eslint-disable-next-line id-length + const s = `0x${signature.slice(66, 130)}`; + // eslint-disable-next-line id-length + const v = parseInt(signature.slice(130, 132), 16); + postBody = { ...body, signature: { r, s, v } }; + } + + log('Posting signature step result', { url, signatureFormat }); + + let result: unknown; + + try { + const response = await successfulFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postBody), + }); + + result = await response.json(); + } catch (error) { + throw new Error( + `Signature step POST failed: ${(error as Error).message}`, + ); + } + + log('Signature step POST response', result); +} + +const DOMAIN_FIELD_MAP: Record = { + name: { name: 'name', type: 'string' }, + version: { name: 'version', type: 'string' }, + chainId: { name: 'chainId', type: 'uint256' }, + verifyingContract: { name: 'verifyingContract', type: 'address' }, + salt: { name: 'salt', type: 'bytes32' }, +}; + +function deriveEIP712DomainType( + domain: Record, +): { name: string; type: string }[] { + return Object.keys(DOMAIN_FIELD_MAP) + .filter((key) => key in domain) + .map((key) => DOMAIN_FIELD_MAP[key]); +} + function stepToParams( - step: ServerQuoteStep, + step: ServerTransactionStep, from: Hex, gasLimit?: number, clientMaxFeePerGas?: string, diff --git a/packages/transaction-pay-controller/src/strategy/server/types.ts b/packages/transaction-pay-controller/src/strategy/server/types.ts index 5aad191f86..654b640e27 100644 --- a/packages/transaction-pay-controller/src/strategy/server/types.ts +++ b/packages/transaction-pay-controller/src/strategy/server/types.ts @@ -21,8 +21,8 @@ export type ServerQuoteAmount = { formatted: string; }; -/** A single on-chain step returned by the server quote endpoint. */ -export type ServerQuoteStep = { +export type ServerTransactionStep = { + type: 'transaction'; chainId: number; to: Hex; data: Hex; @@ -32,6 +32,26 @@ export type ServerQuoteStep = { maxPriorityFeePerGas?: string; }; +export type ServerSignatureStep = { + type: 'signature'; + id: string; + sign: { + signatureKind: string; + domain: Record; + types: Record; + primaryType: string; + value: Record; + }; + post: { + endpoint: string; + method: string; + body: Record; + signatureFormat: 'queryParam' | 'rsv'; + }; +}; + +export type ServerStep = ServerTransactionStep | ServerSignatureStep; + /** A call to include in the quote request (for delegation flows). */ export type ServerCall = { to: Hex; @@ -78,7 +98,7 @@ export type ServerQuotePayload = { output: ServerQuoteAmount; fees: ServerQuoteFees; duration: number; - steps: ServerQuoteStep[]; + steps: ServerStep[]; gasless: boolean; }; @@ -117,7 +137,7 @@ export type ServerQuote = { fees: ServerQuoteFees; duration: number; client: ServerQuoteClient; - steps: ServerQuoteStep[]; + steps: ServerStep[]; gasless: boolean; }; From 71ec27adcd11c82bde35684b616602ae73b68e3d Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 7 Jun 2026 12:44:47 +0100 Subject: [PATCH 02/19] feat(transaction-pay-controller): parity improvements to server strategy Bring the server strategy closer to relay strategy standards: - Fix accountOverride: sign signature steps with quote.request.from (already resolved override account) not transaction.txParams.from - Validate HyperLiquid deposit response: assert status === 'ok' for rsv-format signature steps; throw on failure instead of silently continuing to polling - Validate source balance before on-chain submission; skip for isHyperliquidSource and isPostQuote flows - Migrate isPostQuote prepend: detect hasAccountOverride and prepend raw original tx or delegation-wrapped version accordingly - Migrate paymentOverride at quote time: processMoneyAccountPostQuote rewrites server quote body with money-account override calls - Migrate paymentOverride at submit time: prependPaymentOverrideParams prepends getPaymentOverrideData calls before relay deposit steps - Extract buildTransactionParams: pure function mapping transaction steps + gas context to TransactionParams[] - Migrate transaction type logic: getRelayDepositType, getEffectiveTransactionType, getTransactionType reusing RELAY_DEPOSIT_TYPES from the relay strategy constants --- .../src/strategy/server/server-quotes.ts | 103 +++- .../src/strategy/server/server-submit.ts | 512 ++++++++++++++---- 2 files changed, 510 insertions(+), 105 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 3a26c6a254..9a604ec01e 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -1,11 +1,18 @@ import { Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + AuthorizationList, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { CHAIN_ID_HYPERCORE, TransactionPayStrategy } from '../../constants'; +import { + CHAIN_ID_HYPERCORE, + PaymentOverride, + TransactionPayStrategy, +} from '../../constants'; import { projectLogger } from '../../logger'; import type { PayStrategyGetQuotesRequest, @@ -144,6 +151,7 @@ async function buildServerQuoteRequest( from, isMaxAmount, isPostQuote, + paymentOverride, sourceChainId, sourceTokenAddress, sourceTokenAmount, @@ -197,7 +205,17 @@ async function buildServerQuoteRequest( (isPostQuote ?? false) || (isMaxAmount ?? false); - if (!skipDelegation) { + if ( + isPostQuote && + paymentOverride === PaymentOverride.MoneyAccount + ) { + await processMoneyAccountPostQuote( + transaction, + normalizedRequest, + body, + messenger, + ); + } else if (!skipDelegation) { const delegation = await messenger.call( 'TransactionPayController:getDelegationTransaction', { transaction }, @@ -217,20 +235,83 @@ async function buildServerQuoteRequest( ]; if (delegation.authorizationList?.length) { - body.authorizationList = delegation.authorizationList.map((entry) => ({ - address: entry.address, - chainId: Number(entry.chainId), - nonce: Number(entry.nonce), - r: entry.r as Hex, - s: entry.s as Hex, - yParity: Number(entry.yParity), - })); + body.authorizationList = normalizeAuthorizationList( + delegation.authorizationList, + ); } } return body; } +function normalizeAuthorizationList( + authorizationList: AuthorizationList, +): NonNullable { + return authorizationList.map((entry) => ({ + address: entry.address as Hex, + chainId: Number(entry.chainId), + nonce: Number(entry.nonce), + r: entry.r as Hex, + s: entry.s as Hex, + yParity: Number(entry.yParity), + })); +} + +async function processMoneyAccountPostQuote( + transaction: TransactionMeta, + request: QuoteRequest, + body: ServerQuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { transactionData: transactionDataList } = messenger.call( + 'TransactionPayController:getState', + ); + + const transactionData = transactionDataList[transaction.id]; + const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; + + const { + calls: overrideCalls, + recipient, + authorizationList, + } = await messenger.call('TransactionPayController:getPaymentOverrideData', { + amount: amountHuman, + transaction, + transactionData, + }); + + if (!overrideCalls.length) { + log('No payment override calls for money account post-quote'); + return; + } + + const fundingRecipient = recipient ?? request.from; + + body.tradeType = ServerTradeType.ExactInput; + body.amount = request.sourceTokenAmount; + + body.calls = [ + { + data: buildTransferData(fundingRecipient, request.sourceTokenAmount), + to: request.targetTokenAddress, + value: '0x0', + }, + ...overrideCalls.map((call) => ({ + data: call.data as Hex, + to: call.to as Hex, + value: ((call.value ?? '0x0') as Hex), + })), + ]; + + if (authorizationList?.length) { + body.authorizationList = normalizeAuthorizationList(authorizationList); + } + + log('Added money account post-quote calls to server quote body', { + callCount: overrideCalls.length, + }); +} + function shouldRequestQuote(quoteRequest: QuoteRequest): boolean { return ( quoteRequest.targetAmountMinimum !== '0' || diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 1965fb5a01..0fead692c5 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -3,11 +3,13 @@ import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { TransactionMeta, TransactionParams, + TransactionType, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { PERPS_DEPOSIT_TYPES } from '../../constants'; import { projectLogger } from '../../logger'; import type { PayStrategyExecuteRequest, @@ -18,12 +20,19 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags'; +import { getNetworkClientId } from '../../utils/provider'; +import { + getLiveTokenBalance, + normalizeTokenAddress, + TokenAddressTarget, +} from '../../utils/token'; import { collectTransactionIds, getTransaction, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { RELAY_DEPOSIT_TYPES } from '../relay/constants'; import { getServerStatus, submitServerIntent } from './server-api'; import type { ServerQuote, @@ -36,6 +45,14 @@ import { ServerStatus } from './types'; const log = createModuleLogger(projectLogger, 'server-strategy'); +const DOMAIN_FIELD_MAP: Record = { + name: { name: 'name', type: 'string' }, + version: { name: 'version', type: 'string' }, + chainId: { name: 'chainId', type: 'uint256' }, + verifyingContract: { name: 'verifyingContract', type: 'address' }, + salt: { name: 'salt', type: 'bytes32' }, +}; + function isSignatureStep( step: ServerQuote['steps'][number], ): step is ServerSignatureStep { @@ -92,19 +109,69 @@ async function executeSingleServerQuote( }, ); + const { from, isHyperliquidSource, isPostQuote, paymentOverride } = + quote.request; + const { steps } = quote.original; const signatureSteps = steps.filter(isSignatureStep); const transactionSteps = steps.filter(isTransactionStep); + // Sign and POST any off-chain signature steps before on-chain submission. + // Use quote.request.from (which is already resolved to accountOverride when + // set) rather than transaction.txParams.from (the original EOA). for (const step of signatureSteps) { - await submitSignatureStep(step, transaction.txParams.from as Hex, messenger); + await submitSignatureStep(step, from, messenger); } if (transactionSteps.length > 0) { + // Skip balance check for gasless signature-only or HyperLiquid source flows + // (no on-chain debit on source side) and post-quote flows (funds come from + // the Safe after the original tx executes as part of the batch). + if (!isHyperliquidSource && !isPostQuote) { + await validateSourceBalance(quote, messenger); + } + + // Build allParams: start with relay-provided transaction steps, then + // optionally prepend payment override or post-quote original tx calls. + let allParams = buildTransactionParams( + transactionSteps, + from, + quote.original.client.gasLimits, + quote.original.client.maxFeePerGas, + quote.original.client.maxPriorityFeePerGas, + getEffectiveTransactionType(transaction), + ); + + if (paymentOverride) { + allParams = await prependPaymentOverrideParams( + allParams, + quote, + transaction, + messenger, + ); + } else if (isPostQuote && transaction.txParams.to) { + allParams = await prependPostQuoteParams( + allParams, + quote, + transaction, + messenger, + ); + } + if (quote.original.gasless) { - await submitViaServerExecute(quote, messenger, transaction); + await submitViaServerExecute( + quote, + allParams, + messenger, + transaction, + ); } else { - await submitViaTransactionController(quote, messenger, transaction); + await submitViaTransactionController( + quote, + allParams, + messenger, + transaction, + ); } } @@ -130,24 +197,328 @@ async function executeSingleServerQuote( return { transactionHash: targetHash }; } -async function submitViaServerExecute( +/** + * Build a flat array of TransactionParams from server transaction steps. + * + * Pure function — no messenger calls, no side effects. + * + * @param steps - Transaction steps from the server quote. + * @param from - Sender address (resolved accountOverride when set). + * @param gasLimits - Per-step gas limits from client-side estimation. + * @param clientMaxFeePerGas - Client-side max fee per gas fallback. + * @param clientMaxPriorityFeePerGas - Client-side max priority fee per gas fallback. + * @param originalType - Effective type of the parent transaction (for relay deposit type mapping). + * @returns Array of normalized TransactionParams, one per step. + */ +function buildTransactionParams( + steps: ServerTransactionStep[], + from: Hex, + gasLimits: number[], + clientMaxFeePerGas: string | undefined, + clientMaxPriorityFeePerGas: string | undefined, + originalType: TransactionMeta['type'], +): TransactionParams[] { + return steps.map((step, i) => { + let gas: Hex | undefined; + const gasLimit = gasLimits[i]; + + if (gasLimit) { + gas = toHex(gasLimit); + } else if (step.gasLimit) { + gas = toHex(step.gasLimit); + } + + const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; + const resolvedMaxPriorityFeePerGas = + step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; + + const params: TransactionParams = { + data: step.data, + from, + gas, + maxFeePerGas: resolvedMaxFeePerGas + ? toHex(resolvedMaxFeePerGas) + : undefined, + maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas + ? toHex(resolvedMaxPriorityFeePerGas) + : undefined, + to: step.to, + type: getTransactionType(i, steps.length, originalType), + value: toHex(step.value), + }; + + log('Built transaction params for step', { + index: i, + step: { + gasLimit: step.gasLimit, + maxFeePerGas: step.maxFeePerGas, + maxPriorityFeePerGas: step.maxPriorityFeePerGas, + value: step.value, + }, + params: { + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, + type: params.type, + value: params.value, + }, + }); + + return params; + }); +} + +/** + * Determine the relay deposit transaction type for a step at the given index. + * + * Single-step quotes always use the deposit type. In multi-step quotes the + * first step is an approval and subsequent steps are deposits — matching the + * Relay strategy's convention. + * + * @param index - Zero-based index of the step within the transaction step array. + * @param totalSteps - Total number of transaction steps. + * @param originalType - Effective type of the parent transaction. + * @returns The mapped TransactionType for this step. + */ +function getTransactionType( + index: number, + totalSteps: number, + originalType: TransactionMeta['type'], +): TransactionType { + const depositType = getRelayDepositType(originalType); + + if (totalSteps === 1) { + return depositType; + } + + return index === 0 + ? ('tokenMethodApprove' as TransactionType) + : depositType; +} + +/** + * Get the relay deposit transaction type based on the parent transaction type. + * + * @param originalType - Type of the parent transaction. + * @returns The mapped relay deposit type, or `relayDeposit` as a fallback. + */ +function getRelayDepositType( + originalType: TransactionMeta['type'], +): TransactionType { + return ( + (originalType && RELAY_DEPOSIT_TYPES[originalType]) ?? + ('relayDeposit' as TransactionType) + ); +} + +/** + * Get the effective transaction type, resolving through batch-type parent + * transactions to find the nested perps/predict type. + * + * @param transaction - The transaction metadata. + * @returns The resolved type from nested transactions, or the top-level type. + */ +function getEffectiveTransactionType( + transaction: TransactionMeta, +): TransactionMeta['type'] { + if (transaction.type !== ('batch' as TransactionType)) { + return transaction.type; + } + + const nestedType = transaction.nestedTransactions?.find( + (tx) => tx.type && RELAY_DEPOSIT_TYPES[tx.type] !== undefined, + )?.type; + + return nestedType ?? transaction.type; +} + +/** + * Prepend payment override calls before the relay transaction steps. + * + * For money-account payment override flows the override account supplies + * the source funds. The override transactions must be batched ahead of the + * relay deposit steps. + * + * @param relayParams - Already-built relay step params. + * @param quote - Server quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Combined params with override calls prepended. + */ +async function prependPaymentOverrideParams( + relayParams: TransactionParams[], quote: TransactionPayQuote, + transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, +): Promise { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + const { calls: overrideCalls } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: quote.sourceAmount.human, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + if (!overrideCalls.length) { + log('No payment override calls to prepend'); + return relayParams; + } + + log('Prepending payment override calls', { count: overrideCalls.length }); + + return [...(overrideCalls as TransactionParams[]), ...relayParams]; +} + +/** + * Prepend the original transaction (or a delegation-wrapped version) before + * the relay deposit steps for post-quote flows. + * + * In post-quote flows the source tokens are held in the Safe and only become + * available after the original transaction executes as part of the batch. + * When an accountOverride is active the override account cannot directly + * execute the original call, so it is wrapped in a delegation transaction. + * + * @param relayParams - Already-built relay step params. + * @param quote - Server quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Combined params with the original tx prepended. + */ +async function prependPostQuoteParams( + relayParams: TransactionParams[], + quote: TransactionPayQuote, transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const hasAccountOverride = + quote.request.from.toLowerCase() !== + (transaction.txParams.from as Hex).toLowerCase(); + + let prependedParams: TransactionParams; + + if (hasAccountOverride) { + prependedParams = await buildDelegatedOriginalParams( + transaction, + messenger, + ); + } else { + prependedParams = { + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams; + } + + log('Prepending post-quote original tx', { hasAccountOverride }); + + return [prependedParams, ...relayParams]; +} + +/** + * Build TransactionParams for a delegation that redeems the original + * post-quote transaction on behalf of the override account. + * + * @param transaction - Original transaction meta to be redeemed. + * @param messenger - Controller messenger. + * @returns Transaction params for the delegation tx. + */ +async function buildDelegatedOriginalParams( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + + log('Delegation result for post-quote original tx', delegation); + + return { + data: delegation.data, + from: transaction.txParams.from as Hex, + to: delegation.to, + value: delegation.value, + }; +} + +/** + * Validate that the user's source token balance covers the quote's required + * source amount before submitting on-chain. + * + * Reads the live balance via RPC rather than the cached state so it reflects + * any concurrent spends. Throws fast with a clear error instead of letting + * the on-chain transaction revert. + * + * @param quote - Server quote containing the required source amount. + * @param messenger - Controller messenger. + */ +async function validateSourceBalance( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, ): Promise { - const { from, sourceChainId } = quote.request; - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', + const { from, sourceChainId, sourceTokenAddress } = quote.request; + + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, sourceChainId, + TokenAddressTarget.MetaMask, ); - const nestedTransactions = quote.original.steps - .filter(isTransactionStep) - .map((step) => ({ - data: step.data, - to: step.to, - value: step.value as Hex, - })); + let currentBalance: string; + + try { + currentBalance = await getLiveTokenBalance( + messenger, + from, + sourceChainId, + normalizedSourceTokenAddress, + ); + } catch (error) { + throw new Error( + `Cannot validate payment token balance - ${(error as Error).message}`, + ); + } + + const requiredAmount = new BigNumber(quote.sourceAmount.raw); + const balance = new BigNumber(currentBalance); + + log('Validating source balance', { + from, + sourceChainId, + sourceTokenAddress, + currentBalance, + requiredAmount: requiredAmount.toString(10), + }); + + if (balance.isLessThan(requiredAmount)) { + throw new Error( + `Insufficient source token balance for server deposit. ` + + `Required: ${requiredAmount.toString(10)}, ` + + `Available: ${balance.toString(10)}`, + ); + } +} + +async function submitViaServerExecute( + quote: TransactionPayQuote, + allParams: TransactionParams[], + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): Promise { + const { from, sourceChainId } = quote.request; + const networkClientId = getNetworkClientId(messenger, sourceChainId); + + const nestedTransactions = allParams.map((params) => ({ + data: (params.data ?? '0x') as Hex, + to: params.to as Hex, + value: (params.value ?? '0x0') as Hex, + })); const sourceCallTransaction = { ...transaction, @@ -199,26 +570,14 @@ async function submitViaServerExecute( async function submitViaTransactionController( quote: TransactionPayQuote, + allParams: TransactionParams[], messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, ): Promise { const { from, sourceChainId, sourceTokenAddress } = quote.request; - const steps = quote.original.steps.filter(isTransactionStep); + const { gasLimits, is7702 } = quote.original.client; - if (steps.length === 0) { - throw new Error('Server quote has no steps to submit'); - } - - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', - sourceChainId, - ); - - const { gasLimits, is7702, maxFeePerGas, maxPriorityFeePerGas } = - quote.original.client; - const transactionParams = steps.map((step, i) => - stepToParams(step, from, gasLimits[i], maxFeePerGas, maxPriorityFeePerGas), - ); + const networkClientId = getNetworkClientId(messenger, sourceChainId); const gasFeeToken = quote.fees.isSourceGasFeeToken ? sourceTokenAddress : undefined; @@ -228,8 +587,8 @@ async function submitViaTransactionController( gasFeeToken, is7702, networkClientId, + paramCount: allParams.length, sourceChainId, - stepCount: steps.length, }); const transactionIds: string[] = []; @@ -254,7 +613,7 @@ async function submitViaTransactionController( ); try { - if (transactionParams.length === 1) { + if (allParams.length === 1) { const addTransactionOptions = { gasFeeToken, isInternal: true, @@ -264,19 +623,19 @@ async function submitViaTransactionController( }; log('Calling addTransaction', { - params: transactionParams[0], + params: allParams[0], options: addTransactionOptions, }); await messenger.call( 'TransactionController:addTransaction', - transactionParams[0], + allParams[0], addTransactionOptions, ); } else { const gasLimit7702 = is7702 ? toHex(gasLimits[0]) : undefined; - const batchTransactions = transactionParams.map((params, i) => { + const batchTransactions = allParams.map((params, i) => { const gas = (gasLimit7702 ?? (gasLimits[i] === undefined ? undefined : params.gas)) as | Hex @@ -291,6 +650,7 @@ async function submitViaTransactionController( to: params.to as Hex, value: params.value as Hex, }, + type: params.type, }; }); @@ -369,7 +729,10 @@ async function submitSignatureStep( message: sign.value, }; - log('Signing typed data for signature step', { stepId: step.id, primaryType: sign.primaryType }); + log('Signing typed data for signature step', { + stepId: step.id, + primaryType: sign.primaryType, + }); const signature = await messenger.call( 'KeyringController:signTypedMessage', @@ -377,7 +740,13 @@ async function submitSignatureStep( SignTypedDataVersion.V4, ); - await postSignatureStepResult(post.endpoint, post.method, post.body, signature, post.signatureFormat); + await postSignatureStepResult( + post.endpoint, + post.method, + post.body, + signature, + post.signatureFormat, + ); } async function postSignatureStepResult( @@ -422,15 +791,20 @@ async function postSignatureStepResult( } log('Signature step POST response', result); -} -const DOMAIN_FIELD_MAP: Record = { - name: { name: 'name', type: 'string' }, - version: { name: 'version', type: 'string' }, - chainId: { name: 'chainId', type: 'uint256' }, - verifyingContract: { name: 'verifyingContract', type: 'address' }, - salt: { name: 'salt', type: 'bytes32' }, -}; + // For rsv-format steps (HyperLiquid exchange endpoint) the response body + // carries an explicit status field. Validate it here so a failed deposit + // throws immediately rather than silently proceeding to the polling phase. + if (signatureFormat === 'rsv') { + const status = (result as { status?: string })?.status; + + if (status !== 'ok') { + throw new Error( + `Signature step rejected by server: ${JSON.stringify(result)}`, + ); + } + } +} function deriveEIP712DomainType( domain: Record, @@ -440,56 +814,6 @@ function deriveEIP712DomainType( .map((key) => DOMAIN_FIELD_MAP[key]); } -function stepToParams( - step: ServerTransactionStep, - from: Hex, - gasLimit?: number, - clientMaxFeePerGas?: string, - clientMaxPriorityFeePerGas?: string, -): TransactionParams { - let gas: Hex | undefined; - if (gasLimit) { - gas = toHex(gasLimit); - } else if (step.gasLimit) { - gas = toHex(step.gasLimit); - } - - const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; - const resolvedMaxPriorityFeePerGas = - step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; - - const params = { - data: step.data, - from, - gas, - maxFeePerGas: resolvedMaxFeePerGas - ? toHex(resolvedMaxFeePerGas) - : undefined, - maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas - ? toHex(resolvedMaxPriorityFeePerGas) - : undefined, - to: step.to, - value: toHex(step.value), - }; - - log('Step to params', { - step: { - gasLimit: step.gasLimit, - maxFeePerGas: step.maxFeePerGas, - maxPriorityFeePerGas: step.maxPriorityFeePerGas, - value: step.value, - }, - params: { - gas: params.gas, - maxFeePerGas: params.maxFeePerGas, - maxPriorityFeePerGas: params.maxPriorityFeePerGas, - value: params.value, - }, - }); - - return params; -} - async function waitForServerCompletion( quote: ServerQuote, messenger: TransactionPayControllerMessenger, From 82c998e6289d08f65c601357959d8a54e1957629 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 7 Jun 2026 12:48:56 +0100 Subject: [PATCH 03/19] refactor(transaction-pay-controller): consolidate transaction step building and submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildTransactionParams now async and owns full allParams construction: takes quote + transaction + messenger, maps steps via stepToParams, then prepends payment override or post-quote calls internally - submitTransactionSteps extracted: owns validate + build + gasless branch; no-ops for signature-only quotes - executeSingleServerQuote reduced to a clean 3-phase sequence: submitSignatureSteps → submitTransactionSteps → waitForCompletion - stepToParams extracted as private helper for single-step conversion --- .../src/strategy/server/server-submit.ts | 261 ++++++++++-------- 1 file changed, 146 insertions(+), 115 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 0fead692c5..71223f3c9b 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -9,7 +9,6 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { PERPS_DEPOSIT_TYPES } from '../../constants'; import { projectLogger } from '../../logger'; import type { PayStrategyExecuteRequest, @@ -109,72 +108,18 @@ async function executeSingleServerQuote( }, ); - const { from, isHyperliquidSource, isPostQuote, paymentOverride } = - quote.request; + // Phase 1: off-chain signature steps (e.g. Relay authorize, HyperLiquid deposit). + // Use quote.request.from (resolved accountOverride) not transaction.txParams.from. + const signatureSteps = quote.original.steps.filter(isSignatureStep); - const { steps } = quote.original; - const signatureSteps = steps.filter(isSignatureStep); - const transactionSteps = steps.filter(isTransactionStep); - - // Sign and POST any off-chain signature steps before on-chain submission. - // Use quote.request.from (which is already resolved to accountOverride when - // set) rather than transaction.txParams.from (the original EOA). for (const step of signatureSteps) { - await submitSignatureStep(step, from, messenger); + await submitSignatureStep(step, quote.request.from, messenger); } - if (transactionSteps.length > 0) { - // Skip balance check for gasless signature-only or HyperLiquid source flows - // (no on-chain debit on source side) and post-quote flows (funds come from - // the Safe after the original tx executes as part of the batch). - if (!isHyperliquidSource && !isPostQuote) { - await validateSourceBalance(quote, messenger); - } - - // Build allParams: start with relay-provided transaction steps, then - // optionally prepend payment override or post-quote original tx calls. - let allParams = buildTransactionParams( - transactionSteps, - from, - quote.original.client.gasLimits, - quote.original.client.maxFeePerGas, - quote.original.client.maxPriorityFeePerGas, - getEffectiveTransactionType(transaction), - ); - - if (paymentOverride) { - allParams = await prependPaymentOverrideParams( - allParams, - quote, - transaction, - messenger, - ); - } else if (isPostQuote && transaction.txParams.to) { - allParams = await prependPostQuoteParams( - allParams, - quote, - transaction, - messenger, - ); - } - - if (quote.original.gasless) { - await submitViaServerExecute( - quote, - allParams, - messenger, - transaction, - ); - } else { - await submitViaTransactionController( - quote, - allParams, - messenger, - transaction, - ); - } - } + // Phase 2: on-chain transaction steps (if any). + await submitTransactionSteps(quote, messenger, transaction); + // Phase 3: poll until the intent is confirmed on the target chain. const targetHash = await waitForServerCompletion( quote.original, messenger, @@ -198,74 +143,160 @@ async function executeSingleServerQuote( } /** - * Build a flat array of TransactionParams from server transaction steps. + * Submit the on-chain transaction steps for a server quote. + * + * Validates the source balance, builds the complete set of params (including + * any post-quote or payment-override prepends), then dispatches to the + * gasless execute path or TransactionController depending on the quote. * - * Pure function — no messenger calls, no side effects. + * No-ops when the quote has no transaction steps (signature-only flows). * - * @param steps - Transaction steps from the server quote. - * @param from - Sender address (resolved accountOverride when set). + * @param quote - Server quote. + * @param messenger - Controller messenger. + * @param transaction - Original transaction meta. + */ +async function submitTransactionSteps( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): Promise { + const transactionSteps = quote.original.steps.filter(isTransactionStep); + + if (transactionSteps.length === 0) { + return; + } + + const { isHyperliquidSource, isPostQuote } = quote.request; + + // Skip balance check for HyperLiquid source flows (no on-chain debit) and + // post-quote flows (funds come from the Safe after the original tx executes). + if (!isHyperliquidSource && !isPostQuote) { + await validateSourceBalance(quote, messenger); + } + + const allParams = await buildTransactionParams( + quote, + transactionSteps, + transaction, + messenger, + ); + + if (quote.original.gasless) { + await submitViaServerExecute(quote, allParams, messenger, transaction); + } else { + await submitViaTransactionController(quote, allParams, messenger, transaction); + } +} + +/** + * Build the complete flat array of TransactionParams for on-chain submission. + * + * Converts server transaction steps to params, then prepends any additional + * calls required by the payment override or post-quote flow. The returned + * array is ready to pass directly to submitViaServerExecute or + * submitViaTransactionController. + * + * @param quote - Server quote. + * @param transactionSteps - Transaction steps from the quote (pre-filtered). + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Complete ordered array of TransactionParams for submission. + */ +async function buildTransactionParams( + quote: TransactionPayQuote, + transactionSteps: ServerTransactionStep[], + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const { from, isPostQuote, paymentOverride } = quote.request; + const { gasLimits, maxFeePerGas, maxPriorityFeePerGas } = + quote.original.client; + const originalType = getEffectiveTransactionType(transaction); + + const relayParams = transactionSteps.map((step, i) => + stepToParams(step, i, transactionSteps.length, from, gasLimits, maxFeePerGas, maxPriorityFeePerGas, originalType), + ); + + if (paymentOverride) { + return prependPaymentOverrideParams(relayParams, quote, transaction, messenger); + } + + if (isPostQuote && transaction.txParams.to) { + return prependPostQuoteParams(relayParams, quote, transaction, messenger); + } + + return relayParams; +} + +/** + * Convert a single server transaction step to TransactionParams. + * + * @param step - The transaction step. + * @param index - Zero-based position within the transaction steps array. + * @param totalSteps - Total number of transaction steps (for type mapping). + * @param from - Sender address. * @param gasLimits - Per-step gas limits from client-side estimation. * @param clientMaxFeePerGas - Client-side max fee per gas fallback. * @param clientMaxPriorityFeePerGas - Client-side max priority fee per gas fallback. - * @param originalType - Effective type of the parent transaction (for relay deposit type mapping). - * @returns Array of normalized TransactionParams, one per step. + * @param originalType - Effective type of the parent transaction. + * @returns Normalized TransactionParams for this step. */ -function buildTransactionParams( - steps: ServerTransactionStep[], +function stepToParams( + step: ServerTransactionStep, + index: number, + totalSteps: number, from: Hex, gasLimits: number[], clientMaxFeePerGas: string | undefined, clientMaxPriorityFeePerGas: string | undefined, originalType: TransactionMeta['type'], -): TransactionParams[] { - return steps.map((step, i) => { - let gas: Hex | undefined; - const gasLimit = gasLimits[i]; - - if (gasLimit) { - gas = toHex(gasLimit); - } else if (step.gasLimit) { - gas = toHex(step.gasLimit); - } +): TransactionParams { + let gas: Hex | undefined; + const gasLimit = gasLimits[index]; + + if (gasLimit) { + gas = toHex(gasLimit); + } else if (step.gasLimit) { + gas = toHex(step.gasLimit); + } - const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; - const resolvedMaxPriorityFeePerGas = - step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; + const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; + const resolvedMaxPriorityFeePerGas = + step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; - const params: TransactionParams = { - data: step.data, - from, - gas, - maxFeePerGas: resolvedMaxFeePerGas - ? toHex(resolvedMaxFeePerGas) - : undefined, - maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas - ? toHex(resolvedMaxPriorityFeePerGas) - : undefined, - to: step.to, - type: getTransactionType(i, steps.length, originalType), - value: toHex(step.value), - }; - - log('Built transaction params for step', { - index: i, - step: { - gasLimit: step.gasLimit, - maxFeePerGas: step.maxFeePerGas, - maxPriorityFeePerGas: step.maxPriorityFeePerGas, - value: step.value, - }, - params: { - gas: params.gas, - maxFeePerGas: params.maxFeePerGas, - maxPriorityFeePerGas: params.maxPriorityFeePerGas, - type: params.type, - value: params.value, - }, - }); + const params: TransactionParams = { + data: step.data, + from, + gas, + maxFeePerGas: resolvedMaxFeePerGas + ? toHex(resolvedMaxFeePerGas) + : undefined, + maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas + ? toHex(resolvedMaxPriorityFeePerGas) + : undefined, + to: step.to, + type: getTransactionType(index, totalSteps, originalType), + value: toHex(step.value), + }; - return params; + log('Built transaction params for step', { + index, + step: { + gasLimit: step.gasLimit, + maxFeePerGas: step.maxFeePerGas, + maxPriorityFeePerGas: step.maxPriorityFeePerGas, + value: step.value, + }, + params: { + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, + type: params.type, + value: params.value, + }, }); + + return params; } /** From 7d91110bf94f6847cb0c8b1d0fa9295eac09f737 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 7 Jun 2026 12:58:06 +0100 Subject: [PATCH 04/19] fix(transaction-pay-controller): fix server strategy tests for generic steps - Add type: 'transaction' to step fixtures in server-quotes and server-submit tests - Mock getLiveTokenBalance in server-submit tests (validateSourceBalance path) - Add fees, chainId/decimals/token fields to ORIGINAL_QUOTE_MOCK to match ServerQuote type - Throw 'Server quote has no steps to submit' for non-gasless quotes with no transaction steps - Rename stepToParams -> transactionStepToParams for clarity --- .../src/strategy/server/server-quotes.test.ts | 4 +++ .../src/strategy/server/server-submit.test.ts | 28 +++++++++++++++++-- .../src/strategy/server/server-submit.ts | 9 ++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index a838fe3563..320f662e08 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -90,6 +90,7 @@ const FULFILLED_RESULT_MOCK = { }, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, @@ -497,6 +498,7 @@ describe('server-quotes', () => { gasless: false, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, maxFeePerGas: '0x1', @@ -627,6 +629,7 @@ describe('server-quotes', () => { ...NON_GASLESS_RESULT_MOCK.quote, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, @@ -685,6 +688,7 @@ describe('server-quotes', () => { ...NON_GASLESS_RESULT_MOCK.quote, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index a1991fdb21..543e5ded5f 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -12,6 +12,7 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags'; +import { getLiveTokenBalance } from '../../utils/token'; import { collectTransactionIds, getTransaction, @@ -23,6 +24,10 @@ import { ServerProviderName, ServerStatus } from './types'; import type { ServerQuote } from './types'; jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/token', () => ({ + ...jest.requireActual('../../utils/token'), + getLiveTokenBalance: jest.fn(), +})); jest.mock('../../utils/transaction', () => ({ ...jest.requireActual('../../utils/transaction'), collectTransactionIds: jest.fn(), @@ -61,14 +66,27 @@ const ORIGINAL_QUOTE_MOCK: ServerQuote = { maxPriorityFeePerGas: '500000000', }, duration: 30, + fees: { metamask: '0', provider: '0', subsidized: false }, gasless: true, id: 'server-intent-id', - input: { formatted: '1.23', raw: '1230000' }, - output: { formatted: '1', raw: '1000000' }, + input: { + chainId: 137, + decimals: 6, + formatted: '1.23', + raw: '1230000', + token: '0x5555555555555555555555555555555555555555' as Hex, + }, + output: { + chainId: 1, + decimals: 6, + formatted: '1', + raw: '1000000', + token: '0x6666666666666666666666666666666666666666' as Hex, + }, provider: ServerProviderName.Relay, - providerFeeUsd: '0.01', steps: [ { + type: 'transaction' as const, chainId: 137, data: '0xstepdata' as Hex, to: '0x4444444444444444444444444444444444444444' as Hex, @@ -122,6 +140,7 @@ const DELEGATION_MOCK = { }; describe('submitServerQuotes', () => { + const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); const getServerPollingIntervalMock = jest.mocked(getServerPollingInterval); const getServerPollingTimeoutMock = jest.mocked(getServerPollingTimeout); const getServerStatusMock = jest.mocked(getServerStatus); @@ -154,6 +173,7 @@ describe('submitServerQuotes', () => { findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); getDelegationTransactionMock.mockResolvedValue(DELEGATION_MOCK); + getLiveTokenBalanceMock.mockResolvedValue('999999999999999999'); getServerPollingIntervalMock.mockReturnValue(0); getServerPollingTimeoutMock.mockReturnValue(undefined); getServerStatusMock.mockResolvedValue({ @@ -435,6 +455,7 @@ describe('submitServerQuotes', () => { steps: [ ORIGINAL_QUOTE_MOCK.steps[0], { + type: 'transaction' as const, chainId: 137, data: '0xseconddata' as Hex, to: '0x9999999999999999999999999999999999999999' as Hex, @@ -591,6 +612,7 @@ describe('submitServerQuotes', () => { steps: [ ORIGINAL_QUOTE_MOCK.steps[0], { + type: 'transaction' as const, chainId: 137, data: '0xseconddata' as Hex, to: '0x9999999999999999999999999999999999999999' as Hex, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 71223f3c9b..09b754579b 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -163,6 +163,9 @@ async function submitTransactionSteps( const transactionSteps = quote.original.steps.filter(isTransactionStep); if (transactionSteps.length === 0) { + if (!quote.original.gasless) { + throw new Error('Server quote has no steps to submit'); + } return; } @@ -214,7 +217,7 @@ async function buildTransactionParams( const originalType = getEffectiveTransactionType(transaction); const relayParams = transactionSteps.map((step, i) => - stepToParams(step, i, transactionSteps.length, from, gasLimits, maxFeePerGas, maxPriorityFeePerGas, originalType), + transactionStepToParams(step, i, transactionSteps.length, from, gasLimits, maxFeePerGas, maxPriorityFeePerGas, originalType), ); if (paymentOverride) { @@ -229,7 +232,7 @@ async function buildTransactionParams( } /** - * Convert a single server transaction step to TransactionParams. + * Converts a single server transaction step to TransactionParams. * * @param step - The transaction step. * @param index - Zero-based position within the transaction steps array. @@ -241,7 +244,7 @@ async function buildTransactionParams( * @param originalType - Effective type of the parent transaction. * @returns Normalized TransactionParams for this step. */ -function stepToParams( +function transactionStepToParams( step: ServerTransactionStep, index: number, totalSteps: number, From 051f446726fb62052d6e053566d851bfecdddc59 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 7 Jun 2026 13:03:02 +0100 Subject: [PATCH 05/19] fix(transaction-pay-controller): cast batch transaction type to TransactionType --- .../src/strategy/server/server-submit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 09b754579b..b742460900 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -684,7 +684,7 @@ async function submitViaTransactionController( to: params.to as Hex, value: params.value as Hex, }, - type: params.type, + type: params.type as TransactionType | undefined, }; }); From f5a1bdf249a982e147bfbda91ed1ee6049ddd7a1 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 07:55:01 +0100 Subject: [PATCH 06/19] feat(transaction-pay-controller): trigger quote refresh on txParams.to and requiredAssets changes Previously subscribeTransactionChanges only detected txParams.data changes as a signal to re-evaluate a transaction. Add txParams.to and requiredAssets to the change detection so quote requests are refreshed when the destination or required asset list changes. --- .../src/utils/transaction.test.ts | 95 +++++++++++++++++++ .../src/utils/transaction.ts | 5 +- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 6189adb600..2b18d55e2b 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -149,6 +149,101 @@ describe('Transaction Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); }); + it('updates state when txParams.to changes', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: '0xnewrecipient' as Hex, + }, + }, + ], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); + }); + + it('updates state when requiredAssets changes', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + requiredAssets: [{ address: '0xtoken' as Hex, chainId: '0x1' as Hex }], + }, + ], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); + }); + + it('does not update state when txParams.data, txParams.to, and requiredAssets are unchanged', () => { + const updateTransactionDataMock = jest.fn(); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.submitted, + }, + ], + } as TransactionControllerState, + [], + ); + + // Only the initial new-transaction event triggers the update + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + }); + it.each(FINALIZED_STATUSES)( 'removes state if transaction status is %s', (status) => { diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 2394906505..3e3ebf0fdc 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -79,7 +79,10 @@ export function subscribeTransactionChanges( return ( previousTransaction && - previousTransaction?.txParams.data !== tx.txParams.data + (previousTransaction?.txParams.data !== tx.txParams.data || + previousTransaction?.txParams.to !== tx.txParams.to || + JSON.stringify(previousTransaction?.requiredAssets) !== + JSON.stringify(tx.requiredAssets)) ); }); From a103c74cc6492548fb3e29279b3964bde0f39154 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 07:55:53 +0100 Subject: [PATCH 07/19] chore(transaction-pay-controller): update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4d1e2c817a..fbbe090e7e 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add generic signature steps to the server pay strategy ([#9051](https://github.com/MetaMask/core/pull/9051)) + - `ServerTransactionStep` and `ServerSignatureStep` discriminated union replaces the previous flat step shape; steps array is now typed as `ServerStep[]`. + - Signature steps (type `'signature'`) carry EIP-712 sign parameters and a `post` descriptor (`endpoint`, `method`, `body`, `signatureFormat`); supported formats are `'queryParam'` and `'rsv'`. + - `submitServerQuotes` now executes a three-phase sequence per quote: off-chain signature steps → on-chain transaction steps → status polling; signature steps are signed with `quote.request.from` so account-override flows work correctly. + - Source token balance is validated against live RPC before on-chain submission, skipping the check for HyperLiquid source and post-quote flows. + - Quote refresh is now triggered when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger. - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed From 9235aa2068a35117386cff3d954884387f2be48b Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 07:59:56 +0100 Subject: [PATCH 08/19] docs(transaction-pay-controller): restore JSDoc on perps.ts exports --- .../src/strategy/server/perps.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.ts b/packages/transaction-pay-controller/src/strategy/server/perps.ts index 12db1914ae..eb1e74c598 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.ts @@ -11,6 +11,12 @@ import { } from '../../constants'; import type { QuoteRequest } from '../../types'; +/** + * Shared 20-byte sentinel address emitted by the server strategy to flag a + * Hyperliquid perps deposit. Backend providers translate this to their own + * on-chain destination (e.g. Relay's 16-byte HyperCore USDC sentinel, Across's + * native USDC-PERPS token at the same address). + */ export const SERVER_HYPERCORE_USDC_PERPS_ADDRESS = '0x2100000000000000000000000000000000000000' as Hex; @@ -20,6 +26,16 @@ const HYPERLIQUID_BRIDGE_ADDRESS_LOWER = const HYPERLIQUID_BRIDGE_CALLDATA_FRAGMENT = HYPERLIQUID_BRIDGE_ADDRESS_LOWER.slice(2); +/** + * Detect whether a quote request represents a Hyperliquid perps deposit by + * sniffing the parent transaction calldata for a reference to the Hyperliquid + * bridge contract. Transaction type is intentionally NOT consulted so that any + * caller funnelling a bridge deposit through Pay is supported. + * + * @param request - Quote request from the transaction-pay controller. + * @param transaction - Parent transaction whose calldata is inspected. + * @returns Whether the request matches a Hyperliquid bridge deposit. + */ export function isServerPerpsDepositRequest( request: Pick< QuoteRequest, @@ -39,6 +55,24 @@ export function isServerPerpsDepositRequest( return transactionDataReferencesBridge(transaction); } +/** + * Translate a Hyperliquid perps-deposit quote request into the HyperCore + * direct-deposit shape with a provider-agnostic sentinel destination. Backend + * providers detect the sentinel and rewrite it to their respective on-chain + * destinations. + * + * Transaction pay starts from the parent on-chain asset (Arbitrum USDC, + * 6 decimals); HyperCore expects an 8-decimal amount, so the target amount is + * shifted accordingly. + * + * Also handles the perps-withdraw direction: when `request.isHyperliquidSource` + * is set the source is rewritten to the HyperCore sentinel and the amount is + * shifted from 8 to 6 decimals. + * + * @param request - Quote request from the transaction-pay controller. + * @param transaction - Parent transaction whose calldata is inspected. + * @returns Normalized request, or the original request if not a perps flow. + */ export function normalizeServerPerpsRequest( request: QuoteRequest, transaction: Pick, From 2c2e75fa894721c6b1d64f1d46332bb077d160be Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 09:49:26 +0100 Subject: [PATCH 09/19] test(transaction-pay-controller): add coverage for signature steps, payment override, and perps withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests for signature step sign+POST with queryParam and rsv formats - Add tests for gasless no-op and insufficient balance paths - Add tests for processMoneyAccountPostQuote in server-quotes - Add tests for normalizePerpsWithdrawRequest isHyperliquidSource path - Fix perps.ts decimal shift bug: withdraw converts 8→6 decimals (÷100) - Remove dead code in calculateSourceNetworkCost (unreachable steps guard) - 1126/1126 tests passing, 100% coverage --- .../src/strategy/server/perps.test.ts | 18 + .../src/strategy/server/perps.ts | 2 +- .../src/strategy/server/server-quotes.test.ts | 247 +++++++++++- .../src/strategy/server/server-quotes.ts | 5 - .../src/strategy/server/server-submit.test.ts | 372 +++++++++++++++++- 5 files changed, 635 insertions(+), 9 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts index 118401aa3e..e1a000e6d0 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts @@ -141,5 +141,23 @@ describe('strategy/server/perps', () => { expect(result).toBe(baseRequest); }); + + it('rewrites source chain, token, and amount when isHyperliquidSource is true', () => { + const withdrawRequest: QuoteRequest = { + ...baseRequest, + isHyperliquidSource: true, + sourceChainId: '0xa4b1', + sourceTokenAmount: '100000000', + }; + + const result = normalizeServerPerpsRequest( + withdrawRequest, + innocuousTransaction, + ); + + expect(result.sourceChainId).toBe(CHAIN_ID_HYPERCORE); + expect(result.sourceTokenAddress).toBe(SERVER_HYPERCORE_USDC_PERPS_ADDRESS); + expect(result.sourceTokenAmount).toBe('1000000'); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.ts b/packages/transaction-pay-controller/src/strategy/server/perps.ts index eb1e74c598..d4f71fe730 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.ts @@ -101,7 +101,7 @@ function normalizePerpsWithdrawRequest(request: QuoteRequest): QuoteRequest { sourceChainId: CHAIN_ID_HYPERCORE, sourceTokenAddress: SERVER_HYPERCORE_USDC_PERPS_ADDRESS, sourceTokenAmount: new BigNumber(request.sourceTokenAmount) - .shiftedBy(HYPERCORE_USDC_DECIMALS - USDC_DECIMALS) + .shiftedBy(USDC_DECIMALS - HYPERCORE_USDC_DECIMALS) .toFixed(0), }; } diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index 320f662e08..b4b957cd81 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -1,7 +1,11 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { CHAIN_ID_HYPERCORE, TransactionPayStrategy } from '../../constants'; +import { + CHAIN_ID_HYPERCORE, + PaymentOverride, + TransactionPayStrategy, +} from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { GetDelegationTransactionCallback, @@ -125,7 +129,12 @@ describe('server-quotes', () => { const fetchServerQuoteMock = jest.mocked(fetchServerQuote); const getSlippageMock = jest.mocked(getSlippage); const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); - const { getDelegationTransactionMock, messenger } = getMessengerMock(); + const { + getControllerStateMock, + getDelegationTransactionMock, + getPaymentOverrideDataMock, + messenger, + } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); @@ -715,5 +724,239 @@ describe('server-quotes', () => { }), ); }); + + it('zeroes source network fees when quote has no steps and is not gasless', async () => { + fetchServerQuoteMock.mockResolvedValue({ + results: [ + { + ...FULFILLED_RESULT_MOCK, + quote: { + ...FULFILLED_RESULT_MOCK.quote, + gasless: false, + steps: [], + }, + }, + ], + }); + + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual( + expect.objectContaining({ raw: '0' }), + ); + }); + }); + + describe('processMoneyAccountPostQuote', () => { + const OVERRIDE_CALL_MOCK = { + data: '0xoverride' as Hex, + to: '0xcccc000000000000000000000000000000000000' as Hex, + value: '0x0' as Hex, + }; + + beforeEach(() => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_META_MOCK.id as string]: { + tokens: [{ amountHuman: '1.5' }], + }, + }, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: undefined, + } as never); + }); + + it('adds override calls and transfer call to server quote body when isPostQuote + MoneyAccount', async () => { + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + calls: expect.arrayContaining([ + expect.objectContaining({ to: OVERRIDE_CALL_MOCK.to }), + ]), + }), + undefined, + ); + }); + + it('falls back to request.from as recipient when getPaymentOverrideData returns no recipient', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: undefined, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + // The transfer call targets the source token address with FROM_MOCK as recipient. + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + calls: expect.arrayContaining([ + expect.objectContaining({ + to: TARGET_TOKEN_ADDRESS_MOCK, + // data encodes FROM_MOCK (lower-cased, no 0x prefix) as the recipient + data: expect.stringContaining( + FROM_MOCK.slice(2).toLowerCase(), + ) as string, + }), + ]), + }), + undefined, + ); + }); + + it('attaches authorizationList when getPaymentOverrideData returns one', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: [ + { + address: '0xaaaa000000000000000000000000000000000000' as Hex, + chainId: '0x1' as Hex, + nonce: '0x1' as Hex, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: '0x1' as Hex, + }, + ], + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + authorizationList: expect.arrayContaining([ + expect.objectContaining({ address: '0xaaaa000000000000000000000000000000000000' }), + ]), + }), + undefined, + ); + }); + + it('falls back to 0 amount when transactionData has no tokens', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith( + expect.objectContaining({ amount: '0' }), + ); + }); + + it('defaults override call value to 0x0 when call.value is undefined', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [{ to: '0xcccc000000000000000000000000000000000000' as Hex, data: '0xdata' as Hex }], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + calls: expect.arrayContaining([ + expect.objectContaining({ to: '0xcccc000000000000000000000000000000000000', value: '0x0' }), + ]), + }), + undefined, + ); + }); + + it('skips override when getPaymentOverrideData returns empty calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + recipient: undefined, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.not.objectContaining({ calls: expect.anything() }), + undefined, + ); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 9a604ec01e..7bc5f4d153 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -436,11 +436,6 @@ async function calculateSourceNetworkCost({ return noFees; } - if (steps.length === 0) { - log('No quote steps; zeroing source network fees'); - return noFees; - } - const { from, sourceChainId, sourceTokenAddress } = quoteRequest; const firstStep = steps[0]; const chainIdHex = toHex(firstStep.chainId); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 543e5ded5f..c33632432b 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -1,9 +1,11 @@ import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; +import { successfulFetch } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; import { getMessengerMock } from '../../tests/messenger-mock'; +import { PaymentOverride } from '../../constants'; import type { PayStrategyExecuteRequest, TransactionPayQuote, @@ -21,8 +23,12 @@ import { import { getServerStatus, submitServerIntent } from './server-api'; import { submitServerQuotes } from './server-submit'; import { ServerProviderName, ServerStatus } from './types'; -import type { ServerQuote } from './types'; +import type { ServerQuote, ServerSignatureStep } from './types'; +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); jest.mock('../../utils/feature-flags'); jest.mock('../../utils/token', () => ({ ...jest.requireActual('../../utils/token'), @@ -150,7 +156,9 @@ describe('submitServerQuotes', () => { addTransactionMock: addTxMock, addTransactionBatchMock: addTxBatchMock, findNetworkClientIdByChainIdMock, + getControllerStateMock, getDelegationTransactionMock, + getPaymentOverrideDataMock, getTransactionControllerStateMock, messenger, updateTransactionMock, @@ -385,6 +393,38 @@ describe('submitServerQuotes', () => { expect(addTxBatchMock).not.toHaveBeenCalled(); }); + it('falls back to 0x / 0x0 for undefined data and value in override calls', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + transactions: [], + } as never); + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [{ to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex }], + recipient: undefined, + authorizationList: undefined, + } as never); + + const req = { + ...request, + quotes: [ + { + ...cloneDeep(QUOTE_MOCK), + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }, + ], + }; + + await submitServerQuotes(req); + + expect(submitServerIntentMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ data: DELEGATION_MOCK.data }), + ); + }); + it('continues polling when getServerStatus throws a network error', async () => { getServerStatusMock .mockRejectedValueOnce(new Error('network error')) @@ -631,4 +671,334 @@ describe('submitServerQuotes', () => { ); }); }); + + describe('signature steps', () => { + const SIGNATURE_MOCK = '0x' + 'a'.repeat(64) + 'b'.repeat(64) + '1c'; + const SIGNATURE_STEP_MOCK: ServerSignatureStep = { + type: 'signature' as const, + id: 'sig-step-1', + sign: { + domain: { name: 'Test', chainId: 137 }, + types: { Order: [{ name: 'amount', type: 'uint256' }] }, + primaryType: 'Order', + value: { amount: '1000' }, + }, + post: { + endpoint: 'https://api.example.com/sign', + method: 'POST', + body: { orderId: 'abc' }, + signatureFormat: 'queryParam' as const, + }, + }; + + const signTypedMessageMock = jest.fn(); + const successfulFetchMock = jest.mocked(successfulFetch); + + // Register the handler once — can't re-register after the first test. + // The outer beforeEach resets mock implementations so we re-apply in + // our own beforeEach which runs after the outer one. + beforeAll(() => { + messenger.registerActionHandler( + 'KeyringController:signTypedMessage' as never, + signTypedMessageMock as never, + ); + }); + + beforeEach(() => { + signTypedMessageMock.mockResolvedValue(SIGNATURE_MOCK); + successfulFetchMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ success: true }), + } as never); + }); + + const buildSignatureRequest = ( + overrides: Partial = {}, + ): PayStrategyExecuteRequest => { + const quote = cloneDeep(QUOTE_MOCK); + quote.original = { + ...quote.original, + steps: [SIGNATURE_STEP_MOCK], + gasless: true, + ...overrides, + }; + return { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + }; + + it('signs and POSTs signature step with queryParam format', async () => { + await submitServerQuotes(buildSignatureRequest()); + + expect(successfulFetchMock).toHaveBeenCalledWith( + `${SIGNATURE_STEP_MOCK.post.endpoint}?signature=${SIGNATURE_MOCK}`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('signs and POSTs signature step with rsv format', async () => { + successfulFetchMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ status: 'ok' }), + } as never); + + const rsvStep: ServerSignatureStep = { + ...SIGNATURE_STEP_MOCK, + post: { ...SIGNATURE_STEP_MOCK.post, signatureFormat: 'rsv' as const }, + }; + + await submitServerQuotes(buildSignatureRequest({ steps: [rsvStep] })); + + expect(successfulFetchMock).toHaveBeenCalledWith( + SIGNATURE_STEP_MOCK.post.endpoint, + expect.objectContaining({ + body: expect.stringContaining('"signature"'), + }), + ); + }); + + it('throws when rsv POST response status is not ok', async () => { + successfulFetchMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ status: 'error', msg: 'rejected' }), + } as never); + + const rsvStep: ServerSignatureStep = { + ...SIGNATURE_STEP_MOCK, + post: { ...SIGNATURE_STEP_MOCK.post, signatureFormat: 'rsv' as const }, + }; + + await expect( + submitServerQuotes(buildSignatureRequest({ steps: [rsvStep] })), + ).rejects.toThrow('Signature step rejected by server'); + }); + + it('throws when signature POST fails', async () => { + successfulFetchMock.mockRejectedValue(new Error('network error')); + + await expect( + submitServerQuotes(buildSignatureRequest()), + ).rejects.toThrow('Signature step POST failed: network error'); + }); + + it('no-ops transaction phase when gasless quote has only signature steps', async () => { + await submitServerQuotes(buildSignatureRequest()); + + expect(addTxMock).not.toHaveBeenCalled(); + expect(addTxBatchMock).not.toHaveBeenCalled(); + expect(submitServerIntentMock).not.toHaveBeenCalled(); + expect(getServerStatusMock).toHaveBeenCalled(); + }); + }); + + describe('validateSourceBalance', () => { + it('throws when source balance is insufficient', async () => { + jest.mocked(getLiveTokenBalance).mockResolvedValue('0'); + + const quote = cloneDeep(QUOTE_MOCK); + quote.original.gasless = false; + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).rejects.toThrow( + 'Insufficient source token balance', + ); + }); + + it('wraps getLiveTokenBalance errors with context message', async () => { + jest + .mocked(getLiveTokenBalance) + .mockRejectedValue(new Error('RPC timeout')); + + const quote = cloneDeep(QUOTE_MOCK); + quote.original.gasless = false; + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).rejects.toThrow( + 'Cannot validate payment token balance - RPC timeout', + ); + }); + }); + + describe('buildTransactionParams', () => { + const buildRequest = ( + overrides: Partial> = {}, + ): PayStrategyExecuteRequest => { + const quote = { ...cloneDeep(QUOTE_MOCK), ...overrides }; + quote.original.gasless = false; + return { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + }; + + beforeEach(() => { + jest.mocked(collectTransactionIds).mockImplementation( + (_chainId, _from, _messenger, onTransactionId) => { + onTransactionId('submitted-tx-id'); + return { end: jest.fn() }; + }, + ); + jest.mocked(getTransaction).mockReturnValue({ + hash: '0xsubmitted' as Hex, + } as TransactionMeta); + jest.mocked(waitForTransactionConfirmed).mockResolvedValue(undefined); + addTxMock.mockResolvedValue({ + result: Promise.resolve('0xsubmitted' as Hex), + } as never); + getControllerStateMock.mockReturnValue({ + transactionData: {}, + transactions: [], + } as never); + }); + + it('skips prepend when paymentOverride returns empty calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + recipient: undefined, + authorizationList: undefined, + } as never); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }), + ); + + // No calls prepended — relay step submitted directly via TransactionController + expect(addTxMock).toHaveBeenCalled(); + }); + + it('prepends payment override calls before relay steps', async () => { + const overrideCall = { + data: '0xoverridedata' as Hex, + to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + value: '0x0' as Hex, + }; + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [overrideCall], + recipient: undefined, + authorizationList: undefined, + } as never); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ params: expect.objectContaining({ to: overrideCall.to }) }), + ]), + }), + ); + }); + + it('prepends original tx before relay steps in post-quote flow (no account override)', async () => { + const transaction = cloneDeep(TRANSACTION_META_MOCK); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: ORIGINAL_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + gasless: false, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + to: transaction.txParams.to, + }), + }), + ]), + }), + ); + }); + + it('prepends delegation tx before relay steps in post-quote flow with account override', async () => { + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: QUOTE_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + gasless: false, + }, + }), + ); + + expect(getDelegationTransactionMock).toHaveBeenCalled(); + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ to: DELEGATION_MOCK.to }), + }), + ]), + }), + ); + }); + + it('resolves effective type through batch transaction to nested perps type', async () => { + const batchTransaction: TransactionMeta = { + ...TRANSACTION_META_MOCK, + type: 'batch' as never, + nestedTransactions: [ + { + type: 'relayPerpsDeposit' as never, + to: '0x1111111111111111111111111111111111111111' as Hex, + data: '0x', + }, + ], + }; + + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [{ ...cloneDeep(QUOTE_MOCK), original: { ...ORIGINAL_QUOTE_MOCK, gasless: false } }], + transaction: batchTransaction, + }; + + await submitServerQuotes(req); + + expect(addTxMock).toHaveBeenCalled(); + }); + }); }); From 765205ae8f2010c2e7868751acd2e490197ca84c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:04:55 +0100 Subject: [PATCH 10/19] fix: apply prettier formatting --- .../src/strategy/server/perps.test.ts | 4 +- .../src/strategy/server/server-quotes.test.ts | 16 ++++++-- .../src/strategy/server/server-quotes.ts | 7 +--- .../src/strategy/server/server-submit.test.ts | 29 ++++++++------ .../src/strategy/server/server-submit.ts | 39 ++++++++++++++----- .../src/utils/transaction.test.ts | 4 +- 6 files changed, 68 insertions(+), 31 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts index e1a000e6d0..facb01c49e 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts @@ -156,7 +156,9 @@ describe('strategy/server/perps', () => { ); expect(result.sourceChainId).toBe(CHAIN_ID_HYPERCORE); - expect(result.sourceTokenAddress).toBe(SERVER_HYPERCORE_USDC_PERPS_ADDRESS); + expect(result.sourceTokenAddress).toBe( + SERVER_HYPERCORE_USDC_PERPS_ADDRESS, + ); expect(result.sourceTokenAmount).toBe('1000000'); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index b4b957cd81..91f67ca430 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -871,7 +871,9 @@ describe('server-quotes', () => { messenger, expect.objectContaining({ authorizationList: expect.arrayContaining([ - expect.objectContaining({ address: '0xaaaa000000000000000000000000000000000000' }), + expect.objectContaining({ + address: '0xaaaa000000000000000000000000000000000000', + }), ]), }), undefined, @@ -903,7 +905,12 @@ describe('server-quotes', () => { it('defaults override call value to 0x0 when call.value is undefined', async () => { getPaymentOverrideDataMock.mockResolvedValue({ - calls: [{ to: '0xcccc000000000000000000000000000000000000' as Hex, data: '0xdata' as Hex }], + calls: [ + { + to: '0xcccc000000000000000000000000000000000000' as Hex, + data: '0xdata' as Hex, + }, + ], recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, authorizationList: undefined, } as never); @@ -925,7 +932,10 @@ describe('server-quotes', () => { messenger, expect.objectContaining({ calls: expect.arrayContaining([ - expect.objectContaining({ to: '0xcccc000000000000000000000000000000000000', value: '0x0' }), + expect.objectContaining({ + to: '0xcccc000000000000000000000000000000000000', + value: '0x0', + }), ]), }), undefined, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 7bc5f4d153..022d931d54 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -205,10 +205,7 @@ async function buildServerQuoteRequest( (isPostQuote ?? false) || (isMaxAmount ?? false); - if ( - isPostQuote && - paymentOverride === PaymentOverride.MoneyAccount - ) { + if (isPostQuote && paymentOverride === PaymentOverride.MoneyAccount) { await processMoneyAccountPostQuote( transaction, normalizedRequest, @@ -299,7 +296,7 @@ async function processMoneyAccountPostQuote( ...overrideCalls.map((call) => ({ data: call.data as Hex, to: call.to as Hex, - value: ((call.value ?? '0x0') as Hex), + value: (call.value ?? '0x0') as Hex, })), ]; diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index c33632432b..2ace6a452a 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -1,11 +1,11 @@ +import { successfulFetch } from '@metamask/controller-utils'; import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import { successfulFetch } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; -import { getMessengerMock } from '../../tests/messenger-mock'; import { PaymentOverride } from '../../constants'; +import { getMessengerMock } from '../../tests/messenger-mock'; import type { PayStrategyExecuteRequest, TransactionPayQuote, @@ -777,9 +777,9 @@ describe('submitServerQuotes', () => { it('throws when signature POST fails', async () => { successfulFetchMock.mockRejectedValue(new Error('network error')); - await expect( - submitServerQuotes(buildSignatureRequest()), - ).rejects.toThrow('Signature step POST failed: network error'); + await expect(submitServerQuotes(buildSignatureRequest())).rejects.toThrow( + 'Signature step POST failed: network error', + ); }); it('no-ops transaction phase when gasless quote has only signature steps', async () => { @@ -848,12 +848,12 @@ describe('submitServerQuotes', () => { }; beforeEach(() => { - jest.mocked(collectTransactionIds).mockImplementation( - (_chainId, _from, _messenger, onTransactionId) => { + jest + .mocked(collectTransactionIds) + .mockImplementation((_chainId, _from, _messenger, onTransactionId) => { onTransactionId('submitted-tx-id'); return { end: jest.fn() }; - }, - ); + }); jest.mocked(getTransaction).mockReturnValue({ hash: '0xsubmitted' as Hex, } as TransactionMeta); @@ -912,7 +912,9 @@ describe('submitServerQuotes', () => { expect(addTxBatchMock).toHaveBeenCalledWith( expect.objectContaining({ transactions: expect.arrayContaining([ - expect.objectContaining({ params: expect.objectContaining({ to: overrideCall.to }) }), + expect.objectContaining({ + params: expect.objectContaining({ to: overrideCall.to }), + }), ]), }), ); @@ -992,7 +994,12 @@ describe('submitServerQuotes', () => { accountSupports7702: true, isSmartTransaction: (): boolean => false, messenger, - quotes: [{ ...cloneDeep(QUOTE_MOCK), original: { ...ORIGINAL_QUOTE_MOCK, gasless: false } }], + quotes: [ + { + ...cloneDeep(QUOTE_MOCK), + original: { ...ORIGINAL_QUOTE_MOCK, gasless: false }, + }, + ], transaction: batchTransaction, }; diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index b742460900..b0da97f7cb 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -1,4 +1,8 @@ -import { ORIGIN_METAMASK, successfulFetch, toHex } from '@metamask/controller-utils'; +import { + ORIGIN_METAMASK, + successfulFetch, + toHex, +} from '@metamask/controller-utils'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { TransactionMeta, @@ -187,7 +191,12 @@ async function submitTransactionSteps( if (quote.original.gasless) { await submitViaServerExecute(quote, allParams, messenger, transaction); } else { - await submitViaTransactionController(quote, allParams, messenger, transaction); + await submitViaTransactionController( + quote, + allParams, + messenger, + transaction, + ); } } @@ -217,11 +226,25 @@ async function buildTransactionParams( const originalType = getEffectiveTransactionType(transaction); const relayParams = transactionSteps.map((step, i) => - transactionStepToParams(step, i, transactionSteps.length, from, gasLimits, maxFeePerGas, maxPriorityFeePerGas, originalType), + transactionStepToParams( + step, + i, + transactionSteps.length, + from, + gasLimits, + maxFeePerGas, + maxPriorityFeePerGas, + originalType, + ), ); if (paymentOverride) { - return prependPaymentOverrideParams(relayParams, quote, transaction, messenger); + return prependPaymentOverrideParams( + relayParams, + quote, + transaction, + messenger, + ); } if (isPostQuote && transaction.txParams.to) { @@ -325,9 +348,7 @@ function getTransactionType( return depositType; } - return index === 0 - ? ('tokenMethodApprove' as TransactionType) - : depositType; + return index === 0 ? ('tokenMethodApprove' as TransactionType) : depositType; } /** @@ -819,9 +840,7 @@ async function postSignatureStepResult( result = await response.json(); } catch (error) { - throw new Error( - `Signature step POST failed: ${(error as Error).message}`, - ); + throw new Error(`Signature step POST failed: ${(error as Error).message}`); } log('Signature step POST response', result); diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 2b18d55e2b..18317fed27 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -204,7 +204,9 @@ describe('Transaction Utils', () => { transactions: [ { ...TRANSACTION_META_MOCK, - requiredAssets: [{ address: '0xtoken' as Hex, chainId: '0x1' as Hex }], + requiredAssets: [ + { address: '0xtoken' as Hex, chainId: '0x1' as Hex }, + ], }, ], } as TransactionControllerState, From dd0baad519ec5f0a4880943992fe3674d1cdc20c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:15:51 +0100 Subject: [PATCH 11/19] Fix ESLint errors in server strategy files --- .../src/strategy/server/server-quotes.test.ts | 2 +- .../src/strategy/server/server-quotes.ts | 4 ++-- .../src/strategy/server/server-submit.test.ts | 4 ++-- .../src/strategy/server/server-submit.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index 91f67ca430..26ce8dc7f6 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -762,7 +762,7 @@ describe('server-quotes', () => { beforeEach(() => { getControllerStateMock.mockReturnValue({ transactionData: { - [TRANSACTION_META_MOCK.id as string]: { + [TRANSACTION_META_MOCK.id]: { tokens: [{ amountHuman: '1.5' }], }, }, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 022d931d54..93a8b29760 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -245,7 +245,7 @@ function normalizeAuthorizationList( authorizationList: AuthorizationList, ): NonNullable { return authorizationList.map((entry) => ({ - address: entry.address as Hex, + address: entry.address, chainId: Number(entry.chainId), nonce: Number(entry.nonce), r: entry.r as Hex, @@ -296,7 +296,7 @@ async function processMoneyAccountPostQuote( ...overrideCalls.map((call) => ({ data: call.data as Hex, to: call.to as Hex, - value: (call.value ?? '0x0') as Hex, + value: (call.value ?? '0x0'), })), ]; diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 2ace6a452a..88f859e2f4 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -673,7 +673,7 @@ describe('submitServerQuotes', () => { }); describe('signature steps', () => { - const SIGNATURE_MOCK = '0x' + 'a'.repeat(64) + 'b'.repeat(64) + '1c'; + const SIGNATURE_MOCK = `0x${'a'.repeat(64)}${'b'.repeat(64)}1c`; const SIGNATURE_STEP_MOCK: ServerSignatureStep = { type: 'signature' as const, id: 'sig-step-1', @@ -761,7 +761,7 @@ describe('submitServerQuotes', () => { it('throws when rsv POST response status is not ok', async () => { successfulFetchMock.mockResolvedValue({ - json: jest.fn().mockResolvedValue({ status: 'error', msg: 'rejected' }), + json: jest.fn().mockResolvedValue({ status: 'error', message: 'rejected' }), } as never); const rsvStep: ServerSignatureStep = { diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index b0da97f7cb..dc9b1ca4be 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -863,7 +863,7 @@ function deriveEIP712DomainType( domain: Record, ): { name: string; type: string }[] { return Object.keys(DOMAIN_FIELD_MAP) - .filter((key) => key in domain) + .filter((key) => Object.hasOwn(domain, key)) .map((key) => DOMAIN_FIELD_MAP[key]); } From 4b47690260a8bb57f3959cfe93113381a3718ee5 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:19:33 +0100 Subject: [PATCH 12/19] fix: apply Prettier formatting --- .../src/strategy/server/server-quotes.ts | 2 +- .../src/strategy/server/server-submit.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 93a8b29760..3b71e75d6d 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -296,7 +296,7 @@ async function processMoneyAccountPostQuote( ...overrideCalls.map((call) => ({ data: call.data as Hex, to: call.to as Hex, - value: (call.value ?? '0x0'), + value: call.value ?? '0x0', })), ]; diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 88f859e2f4..9b1951390a 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -761,7 +761,9 @@ describe('submitServerQuotes', () => { it('throws when rsv POST response status is not ok', async () => { successfulFetchMock.mockResolvedValue({ - json: jest.fn().mockResolvedValue({ status: 'error', message: 'rejected' }), + json: jest + .fn() + .mockResolvedValue({ status: 'error', message: 'rejected' }), } as never); const rsvStep: ServerSignatureStep = { From 098c6ac53cca7e296ffc8613f88036eac48440c1 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:26:50 +0100 Subject: [PATCH 13/19] fix: replace Object.hasOwn with hasOwnProperty for ES2021 compat --- .../src/strategy/server/server-submit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index dc9b1ca4be..ed8dec3915 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -863,7 +863,7 @@ function deriveEIP712DomainType( domain: Record, ): { name: string; type: string }[] { return Object.keys(DOMAIN_FIELD_MAP) - .filter((key) => Object.hasOwn(domain, key)) + .filter((key) => Object.prototype.hasOwnProperty.call(domain, key)) .map((key) => DOMAIN_FIELD_MAP[key]); } From d86af0a0e5b2a847dbf7b6bf91563e0614389594 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:34:31 +0100 Subject: [PATCH 14/19] chore: simplify changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index fbbe090e7e..04ea6e42dd 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,11 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add generic signature steps to the server pay strategy ([#9051](https://github.com/MetaMask/core/pull/9051)) - - `ServerTransactionStep` and `ServerSignatureStep` discriminated union replaces the previous flat step shape; steps array is now typed as `ServerStep[]`. - - Signature steps (type `'signature'`) carry EIP-712 sign parameters and a `post` descriptor (`endpoint`, `method`, `body`, `signatureFormat`); supported formats are `'queryParam'` and `'rsv'`. - - `submitServerQuotes` now executes a three-phase sequence per quote: off-chain signature steps → on-chain transaction steps → status polling; signature steps are signed with `quote.request.from` so account-override flows work correctly. - - Source token balance is validated against live RPC before on-chain submission, skipping the check for HyperLiquid source and post-quote flows. - - Quote refresh is now triggered when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger. + - Added types: `ServerStep`, `ServerTransactionStep`, `ServerSignatureStep` - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed From 071779be6a984a818df68a4ad2b5469310446fda Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:54:34 +0100 Subject: [PATCH 15/19] chore: simplify changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 04ea6e42dd..74641d7513 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add generic signature steps to the server pay strategy ([#9051](https://github.com/MetaMask/core/pull/9051)) - - Added types: `ServerStep`, `ServerTransactionStep`, `ServerSignatureStep` - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed From e08c290a97f2d7e1923e59317b8103ef90885fe7 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:54:47 +0100 Subject: [PATCH 16/19] chore: expand changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 74641d7513..06331ba0a9 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add generic signature steps to the server pay strategy ([#9051](https://github.com/MetaMask/core/pull/9051)) +- Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows for account override and payment override quotes ([#9051](https://github.com/MetaMask/core/pull/9051)) - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed From 7996ccf5fb65158b3e31a4f71638ce1b88423676 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 10:58:35 +0100 Subject: [PATCH 17/19] chore: update changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 06331ba0a9..f1710ec782 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows for account override and payment override quotes ([#9051](https://github.com/MetaMask/core/pull/9051)) +- Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows ([#9051](https://github.com/MetaMask/core/pull/9051)) + - Trigger quote refresh when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed From a2b096586e24b53a0b456d25e5c5ec5d89915f50 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 9 Jun 2026 17:27:26 +0100 Subject: [PATCH 18/19] fix(transaction-pay-controller): remove id/signatureKind from ServerSignatureStep, fix balance check and batch gas alignment --- .../src/strategy/server/server-submit.test.ts | 1 - .../src/strategy/server/server-submit.ts | 21 ++++++++++--------- .../src/strategy/server/types.ts | 2 -- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 9b1951390a..438178cc4d 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -676,7 +676,6 @@ describe('submitServerQuotes', () => { const SIGNATURE_MOCK = `0x${'a'.repeat(64)}${'b'.repeat(64)}1c`; const SIGNATURE_STEP_MOCK: ServerSignatureStep = { type: 'signature' as const, - id: 'sig-step-1', sign: { domain: { name: 'Test', chainId: 137 }, types: { Order: [{ name: 'amount', type: 'uint256' }] }, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index ed8dec3915..b5c41cbf8e 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -173,11 +173,12 @@ async function submitTransactionSteps( return; } - const { isHyperliquidSource, isPostQuote } = quote.request; + const { isHyperliquidSource, isPostQuote, paymentOverride } = quote.request; - // Skip balance check for HyperLiquid source flows (no on-chain debit) and - // post-quote flows (funds come from the Safe after the original tx executes). - if (!isHyperliquidSource && !isPostQuote) { + // Skip balance check for HyperLiquid source flows (no on-chain debit), + // post-quote flows (funds come from the Safe after the original tx executes), + // and payment-override flows (funds are supplied by the override account). + if (!isHyperliquidSource && !isPostQuote && !paymentOverride) { await validateSourceBalance(quote, messenger); } @@ -690,11 +691,12 @@ async function submitViaTransactionController( } else { const gasLimit7702 = is7702 ? toHex(gasLimits[0]) : undefined; - const batchTransactions = allParams.map((params, i) => { - const gas = (gasLimit7702 ?? - (gasLimits[i] === undefined ? undefined : params.gas)) as - | Hex - | undefined; + const batchTransactions = allParams.map((params) => { + // params.gas was already resolved correctly by transactionStepToParams + // (indexed by relay-step position), so use it directly. Indexing + // allParams position into gasLimits would be wrong when payment- + // override or post-quote params are prepended. + const gas = (gasLimit7702 ?? params.gas) as Hex | undefined; return { params: { @@ -785,7 +787,6 @@ async function submitSignatureStep( }; log('Signing typed data for signature step', { - stepId: step.id, primaryType: sign.primaryType, }); diff --git a/packages/transaction-pay-controller/src/strategy/server/types.ts b/packages/transaction-pay-controller/src/strategy/server/types.ts index 654b640e27..2148705eb8 100644 --- a/packages/transaction-pay-controller/src/strategy/server/types.ts +++ b/packages/transaction-pay-controller/src/strategy/server/types.ts @@ -34,9 +34,7 @@ export type ServerTransactionStep = { export type ServerSignatureStep = { type: 'signature'; - id: string; sign: { - signatureKind: string; domain: Record; types: Record; primaryType: string; From c75fcc1e355507e33c4f821a51ce65e184e4b9e1 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 11 Jun 2026 13:44:36 +0100 Subject: [PATCH 19/19] fix(transaction-pay-controller): add fee caps to prepended post-quote tx, allow signature-only non-gasless flows --- .../src/strategy/server/server-submit.test.ts | 66 ++++++++++++++++++- .../src/strategy/server/server-submit.ts | 14 +++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 438178cc4d..4c129d43ba 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -791,6 +791,27 @@ describe('submitServerQuotes', () => { expect(submitServerIntentMock).not.toHaveBeenCalled(); expect(getServerStatusMock).toHaveBeenCalled(); }); + + it('does not throw for a signature-only non-gasless quote (no transaction steps)', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.original.steps = [SIGNATURE_STEP_MOCK]; + quote.original.gasless = false; + + successfulFetchMock.mockResolvedValue({ + json: async () => ({}), + ok: true, + } as Response); + + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).resolves.not.toThrow(); + }); }); describe('validateSourceBalance', () => { @@ -944,6 +965,45 @@ describe('submitServerQuotes', () => { expect.objectContaining({ params: expect.objectContaining({ to: transaction.txParams.to, + maxFeePerGas: expect.any(String), + maxPriorityFeePerGas: expect.any(String), + }), + }), + ]), + }), + ); + }); + + it('prepends original tx with undefined fee caps when client has no fee estimates', async () => { + const transaction = cloneDeep(TRANSACTION_META_MOCK); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: ORIGINAL_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + client: { + ...ORIGINAL_QUOTE_MOCK.client, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }, + gasless: false, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + to: transaction.txParams.to, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, }), }), ]), @@ -971,7 +1031,11 @@ describe('submitServerQuotes', () => { expect.objectContaining({ transactions: expect.arrayContaining([ expect.objectContaining({ - params: expect.objectContaining({ to: DELEGATION_MOCK.to }), + params: expect.objectContaining({ + to: DELEGATION_MOCK.to, + maxFeePerGas: expect.any(String), + maxPriorityFeePerGas: expect.any(String), + }), }), ]), }), diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index b5c41cbf8e..973e63facf 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -167,7 +167,10 @@ async function submitTransactionSteps( const transactionSteps = quote.original.steps.filter(isTransactionStep); if (transactionSteps.length === 0) { - if (!quote.original.gasless) { + // Signature-only quotes (all steps are signature steps, no transaction + // steps) have nothing to submit on-chain even when gasless is false. + const hasSignatureSteps = quote.original.steps.some(isSignatureStep); + if (!quote.original.gasless && !hasSignatureSteps) { throw new Error('Server quote has no steps to submit'); } return; @@ -455,6 +458,8 @@ async function prependPostQuoteParams( quote.request.from.toLowerCase() !== (transaction.txParams.from as Hex).toLowerCase(); + const { maxFeePerGas, maxPriorityFeePerGas } = quote.original.client; + let prependedParams: TransactionParams; if (hasAccountOverride) { @@ -471,6 +476,13 @@ async function prependPostQuoteParams( } as TransactionParams; } + // Ensure the prepended tx carries the same fee caps as the relay steps so + // it isn't submitted with undefined maxFeePerGas in a non-7702 batch. + prependedParams.maxFeePerGas = maxFeePerGas ? toHex(maxFeePerGas) : undefined; + prependedParams.maxPriorityFeePerGas = maxPriorityFeePerGas + ? toHex(maxPriorityFeePerGas) + : undefined; + log('Prepending post-quote original tx', { hasAccountOverride }); return [prependedParams, ...relayParams];