From 1bb77c440c068aa0249706d79c212d06a14d8b8e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 6 Jul 2026 14:01:25 +0100 Subject: [PATCH 1/6] feat(transaction-pay-controller): add isSubsidized flag to delegation callback and signed metamask envelope for Relay executes - Add optional isSubsidized flag to GetDelegationTransactionCallback - Send signed metamask envelope (executeVersion: 2) for Relay executes; refuse without HMAC signature - Derive isSubsidized from quote.fees.subsidized.amountUsd - Reconcile server requestId after JIT re-quote via mutable clone of frozen quote.original - Extract submitViaRelayExecute into relay-submit-execute.ts - Tolerate undefined maxFeePerGas/maxPriorityFeePerGas in normalizeParams --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../relay/relay-submit-execute.test.ts | 391 ++++++++++++++++++ .../strategy/relay/relay-submit-execute.ts | 173 ++++++++ .../src/strategy/relay/relay-submit.test.ts | 366 ++-------------- .../src/strategy/relay/relay-submit.ts | 119 ++---- .../src/strategy/relay/types.ts | 12 + .../transaction-pay-controller/src/types.ts | 3 + 7 files changed, 633 insertions(+), 435 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ffcac29a485..6a6547bdf58 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an optional `isSubsidized` flag to `GetDelegationTransactionCallback` and send a signed `metamask` envelope with `executeVersion: 2` for Relay executes ([#9298](https://github.com/MetaMask/core/pull/9298)) + ### Changed - Refactor vault deposit utilities into shared `utils/` modules (`chomp`, `ma-vault-deposit`, `relay-post-ma-vault`) to prepare for the Relay Money Account deposit path ([#9303](https://github.com/MetaMask/core/pull/9303)) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts new file mode 100644 index 00000000000..73dd44ea875 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts @@ -0,0 +1,391 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import { getMessengerMock } from '../../tests/messenger-mock'; +import type { TransactionPayQuote } from '../../types'; +import type { FeatureFlags } from '../../utils/feature-flags'; +import { + getFeatureFlags, + getRelayPollingInterval, + getRelayPollingTimeout, +} from '../../utils/feature-flags'; +import { submitViaRelayExecute } from './relay-submit-execute'; +import type { RelayQuote } from './types'; + +jest.mock('../../utils/feature-flags'); + +const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; +const REQUEST_ID_MOCK = '0x1234567890abcdef'; + +const FROM_MOCK = '0xabcde' as Hex; +const CHAIN_ID_MOCK = '0x1' as Hex; + +const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; +const DELEGATION_MANAGER_MOCK = '0xdelegationmanager' as Hex; + +const DELEGATION_RESULT_MOCK = { + data: DELEGATION_DATA_MOCK, + to: DELEGATION_MANAGER_MOCK, + value: '0', + authorizationList: [ + { + address: '0xdelegateAddr' as Hex, + chainId: '0x1' as Hex, + nonce: '0x0' as Hex, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: '0x0' as Hex, + }, + ], +}; + +const ORIGINAL_QUOTE_MOCK = { + details: { + currencyIn: { + currency: { + chainId: 1, + }, + }, + currencyOut: { + currency: { + chainId: 2, + }, + }, + }, + metamask: { + gasLimits: [21000, 21000], + is7702: false, + }, + request: {}, + steps: [ + { + id: 'swap', + kind: 'transaction', + requestId: REQUEST_ID_MOCK, + items: [ + { + data: { + chainId: 1, + data: '0x1234' as Hex, + from: FROM_MOCK, + gas: '21000', + maxFeePerGas: '25000000000', + maxPriorityFeePerGas: '1000000000', + to: '0xfedcb' as Hex, + value: '1234', + }, + status: 'complete', + }, + ], + }, + ], +} as RelayQuote; + +const EXECUTE_RESPONSE_MOCK = { + requestId: REQUEST_ID_MOCK, +}; + +const FEATURE_FLAGS_MOCK = { + relayExecuteUrl: 'https://proxy.test/relay/execute', + relayFallbackGas: { max: 123 }, +} as FeatureFlags; + +describe('Relay Submit Execute', () => { + const getFeatureFlagsMock = jest.mocked(getFeatureFlags); + const getRelayPollingIntervalMock = jest.mocked(getRelayPollingInterval); + const getRelayPollingTimeoutMock = jest.mocked(getRelayPollingTimeout); + + const { + getDelegationTransactionMock, + findNetworkClientIdByChainIdMock, + messenger, + } = getMessengerMock(); + + let successfulFetchMock: jest.SpyInstance; + let quote: TransactionPayQuote; + let transaction: TransactionMeta; + let allParams: { to?: Hex; data?: Hex; value?: Hex }[]; + + beforeEach(() => { + jest.resetAllMocks(); + + successfulFetchMock = jest.spyOn(global, 'fetch'); + + getRelayPollingIntervalMock.mockReturnValue(1); + getRelayPollingTimeoutMock.mockReturnValue(undefined); + getFeatureFlagsMock.mockReturnValue(FEATURE_FLAGS_MOCK); + + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => EXECUTE_RESPONSE_MOCK, + } as Response); + + quote = { + fees: { + sourceNetwork: {}, + }, + original: cloneDeep(ORIGINAL_QUOTE_MOCK), + request: { + from: FROM_MOCK, + sourceChainId: CHAIN_ID_MOCK, + }, + sourceAmount: { + raw: '1000000', + human: '1', + fiat: '1', + usd: '1', + }, + } as TransactionPayQuote; + + transaction = { + id: '123-456', + chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + txParams: { + from: FROM_MOCK, + }, + } as TransactionMeta; + + allParams = [ + { + to: '0xfedcb' as Hex, + data: '0x1234' as Hex, + value: '0x4d2' as Hex, + }, + ]; + }); + + afterEach(() => { + successfulFetchMock.mockRestore(); + }); + + describe('submitViaRelayExecute', () => { + beforeEach(() => { + quote.original.metamask.isExecute = true; + quote.original.metamask.signature = 'normal-execute-sig-mock'; + }); + + it('calls getDelegationTransaction with source calls as nestedTransactions', async () => { + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + nestedTransactions: [ + { + data: '0x1234', + to: '0xfedcb', + value: '0x4d2', + }, + ], + }), + isSubsidized: false, + }); + }); + + it('submits to /execute with delegation data and metamask envelope', async () => { + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + const fetchCall = successfulFetchMock.mock.calls[0]; + const body = JSON.parse( + (fetchCall[1] as RequestInit).body as string, + ) as Record; + + expect(fetchCall[0]).toBe(FEATURE_FLAGS_MOCK.relayExecuteUrl); + expect(body.executionKind).toBe('rawCalls'); + expect(body.requestId).toBe(REQUEST_ID_MOCK); + expect(body.metamask).toStrictEqual({ + isSubsidized: false, + quoteRequest: ORIGINAL_QUOTE_MOCK.request, + signature: 'normal-execute-sig-mock', + }); + }); + + it('throws when metamask.signature is missing', async () => { + quote.original.metamask.signature = undefined as never; + + await expect( + submitViaRelayExecute(quote, transaction, messenger, allParams), + ).rejects.toThrow( + 'Execute: Missing metamask.signature — cannot submit to /relay/execute without the HMAC token', + ); + }); + + it('throws when the quote step has no requestId', async () => { + quote.original.steps[0].requestId = undefined as never; + + await expect( + submitViaRelayExecute(quote, transaction, messenger, allParams), + ).rejects.toThrow('Execute: Missing requestId in quote step'); + }); + + it('strips marker from requestId', async () => { + const markedRequestId = `${REQUEST_ID_MOCK}#mmmarkerdata`; + quote.original.steps[0].requestId = markedRequestId; + + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + const fetchCall = successfulFetchMock.mock.calls[0]; + const body = JSON.parse( + (fetchCall[1] as RequestInit).body as string, + ) as Record; + + expect(body.requestId).toBe(REQUEST_ID_MOCK); + expect(body.requestId).not.toContain('#mm'); + }); + + it('omits authorizationList when delegation has none', async () => { + getDelegationTransactionMock.mockResolvedValue({ + ...DELEGATION_RESULT_MOCK, + authorizationList: undefined, + }); + + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + const fetchCall = successfulFetchMock.mock.calls[0]; + const body = JSON.parse( + (fetchCall[1] as RequestInit).body as string, + ) as Record; + const data = body.data as Record; + + expect(data.authorizationList).toBeUndefined(); + }); + + it('uses fallback values for missing data and value in source params', async () => { + const paramsWithoutDataOrValue = [ + { + to: '0xfedcb' as Hex, + data: undefined, + value: undefined, + }, + ]; + + await submitViaRelayExecute( + quote, + transaction, + messenger, + paramsWithoutDataOrValue, + ); + + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + nestedTransactions: [ + { + data: '0x', + to: '0xfedcb', + value: '0x0', + }, + ], + }), + isSubsidized: false, + }); + }); + + it('wraps /execute submission failures with the Execute prefix', async () => { + successfulFetchMock.mockReset(); + successfulFetchMock.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ + message: 'failed to decode param in array[0] invalid JSON input', + }), + } as Response); + + await expect( + submitViaRelayExecute(quote, transaction, messenger, allParams), + ).rejects.toThrow( + 'Execute: 422 - failed to decode param in array[0] invalid JSON input', + ); + }); + }); + + describe('submitViaRelayExecute with subsidized execute', () => { + const SUBSIDY_SIGNATURE_MOCK = 'hmac-v1-token-mock'; + + beforeEach(() => { + quote.original.metamask.isExecute = true; + quote.original.metamask.signature = SUBSIDY_SIGNATURE_MOCK; + quote.original.fees = { + relayer: { amountUsd: '0' }, + subsidized: { + amount: '1000000', + amountFormatted: '1.00', + amountUsd: '1.00', + currency: { + address: '0xtoken' as Hex, + chainId: 137, + decimals: 6, + }, + minimumAmount: '900000', + }, + }; + + getDelegationTransactionMock.mockResolvedValue({ + data: '0xaabbccdd11223344' as Hex, + to: '0xdelegationmgr0000000000000000000000' as Hex, + value: '0x0' as Hex, + }); + + allParams = [ + { + to: '0xfedcb' as Hex, + data: '0xa9059cbb000000000000000000000000abcdef1234567890abcdef1234567890abcdef120000000000000000000000000000000000000000000000000000000000989680' as Hex, + value: '0x4d2' as Hex, + }, + ]; + }); + + it('calls getDelegationTransaction with isSubsidized flag', async () => { + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + nestedTransactions: [ + { + data: '0xa9059cbb000000000000000000000000abcdef1234567890abcdef1234567890abcdef120000000000000000000000000000000000000000000000000000000000989680', + to: '0xfedcb', + value: '0x4d2', + }, + ], + }), + isSubsidized: true, + }); + }); + + it('posts to relayExecuteUrl with metamask envelope containing isSubsidized, quoteRequest, and signature', async () => { + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + const fetchCall = successfulFetchMock.mock.calls[0]; + const body = JSON.parse( + (fetchCall[1] as RequestInit).body as string, + ) as Record; + + expect(fetchCall[0]).toBe(FEATURE_FLAGS_MOCK.relayExecuteUrl); + expect(body.metamask).toStrictEqual({ + isSubsidized: true, + quoteRequest: ORIGINAL_QUOTE_MOCK.request, + signature: SUBSIDY_SIGNATURE_MOCK, + }); + }); + + it('throws when quote is missing metamask.signature', async () => { + quote.original.metamask.signature = undefined as never; + + await expect( + submitViaRelayExecute(quote, transaction, messenger, allParams), + ).rejects.toThrow( + 'Execute: Missing metamask.signature — cannot submit to /relay/execute without the HMAC token', + ); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts new file mode 100644 index 00000000000..f972ed91db2 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -0,0 +1,173 @@ +import type { TransactionParams } 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 { projectLogger } from '../../logger'; +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { prefixError } from '../../utils/error-prefix'; +import { getNetworkClientId } from '../../utils/provider'; +import { FALLBACK_HASH } from './constants'; +import { submitRelayExecute } from './relay-api'; +import type { RelayExecuteRequest, RelayQuote } from './types'; + +const log = createModuleLogger(projectLogger, 'relay-strategy'); +const RELAY_EXECUTE_ERROR_PREFIX = 'Execute: '; + +export async function submitViaRelayExecute( + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, + allParams: TransactionParams[], +): Promise { + const isSubsidized = isSubsidizedRelayQuote(quote.original); + const requestId = getRelayExecuteRequestId(quote.original); + const metamask = getRelayExecuteMetamask(quote.original, isSubsidized); + + const { from, sourceChainId } = quote.request; + const networkClientId = getNetworkClientId(messenger, sourceChainId); + + const executionTransaction: TransactionMeta = { + ...transaction, + chainId: sourceChainId, + networkClientId, + nestedTransactions: allParams.map((param) => ({ + data: (param.data ?? '0x') as Hex, + to: param.to as Hex, + value: (param.value ?? '0x0') as Hex, + })), + txParams: { + ...transaction.txParams, + from, + }, + } as TransactionMeta; + + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction: executionTransaction, isSubsidized }, + ); + + log('Delegation result for execute', delegation); + + const executeBody: RelayExecuteRequest = { + executionKind: 'rawCalls', + data: { + chainId: Number(quote.request.sourceChainId), + to: delegation.to, + data: delegation.data, + value: new BigNumber(delegation.value).toFixed(), + ...mapAuthorizationList(delegation.authorizationList), + }, + executionOptions: { + subsidizeFees: false, + }, + requestId, + metamask, + }; + + log('Submitting to Relay execute', { executeBody, from: quote.request.from }); + + let result; + try { + result = await submitRelayExecute(messenger, executeBody); + } catch (error) { + throw prefixError(error, RELAY_EXECUTE_ERROR_PREFIX); + } + + log('Relay execute response', result); + + // Server may return a different requestId (e.g. after JIT quote for subsidized). + // Replace the original quote requestId so waitForRelayCompletion polls the correct request. + replaceFirstStepRequestId(quote.original, result.requestId); + + return FALLBACK_HASH; +} + +function isSubsidizedRelayQuote(quote: RelayQuote): boolean { + return Number(quote.fees?.subsidized?.amountUsd ?? '0') > 0; +} + +function stripRelayExecuteMarker(requestId: string): string { + return requestId.includes('#mm') + ? requestId.slice(0, requestId.indexOf('#mm')) + : requestId; +} + +function getRelayExecuteRequestId(quote: RelayQuote): string { + const requestId = quote.steps?.[0]?.requestId; + if (!requestId) { + throw new Error('Relay: Execute: Missing requestId in quote step'); + } + return stripRelayExecuteMarker(requestId); +} + +function getRelayExecuteMetamask( + quote: RelayQuote, + isSubsidized: boolean, +): { + isSubsidized: boolean; + quoteRequest: RelayQuote['request']; + signature: string; +} { + const signature = quote.metamask?.signature; + if (!signature) { + throw new Error( + 'Relay: Execute: Missing metamask.signature — cannot submit to /relay/execute without the HMAC token', + ); + } + return { + isSubsidized, + quoteRequest: quote.request, + signature, + }; +} + +function mapAuthorizationList( + authorizationList: AuthorizationList | undefined, +): { + authorizationList?: { + chainId: number; + address: Hex; + nonce: number; + yParity: number; + r: Hex; + s: Hex; + }[]; +} { + if (!authorizationList?.length) { + return {}; + } + return { + authorizationList: authorizationList.map((auth) => ({ + chainId: Number(auth.chainId), + address: auth.address, + nonce: Number(auth.nonce), + yParity: Number(auth.yParity), + r: auth.r as Hex, + s: auth.s as Hex, + })), + }; +} + +function replaceFirstStepRequestId(quote: RelayQuote, requestId: string): void { + /* istanbul ignore next: requestId is read from steps[0] earlier, so steps is non-empty here; defensive guard. */ + const steps = quote.steps ?? []; + /* istanbul ignore if: requestId is read from steps[0] earlier, so steps is non-empty here; defensive guard. */ + if (steps.length === 0) { + throw new Error( + 'Relay: Execute: Cannot update requestId — quote has no steps', + ); + } + + const existingStep = steps[0]; + const remainingSteps = steps.slice(1); + + quote.steps = [{ ...existingStep, requestId }, ...remainingSteps]; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index fe35d3917b6..395c4a75bb1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -29,6 +29,7 @@ import { } from '../../utils/transaction'; import { RELAY_STATUS_URL } from './constants'; import { submitRelayQuotes } from './relay-submit'; +import { submitViaRelayExecute } from './relay-submit-execute'; import type { RelayQuote } from './types'; jest.mock('../../utils/token'); @@ -36,6 +37,7 @@ jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); jest.mock('./hyperliquid-withdraw'); jest.mock('./polymarket/withdraw'); +jest.mock('./relay-submit-execute'); const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; @@ -157,6 +159,8 @@ describe('Relay Submit Utils', () => { waitForTransactionConfirmed, ); + const submitViaRelayExecuteMock = jest.mocked(submitViaRelayExecute); + beforeEach(() => { jest.resetAllMocks(); @@ -621,6 +625,25 @@ describe('Relay Submit Utils', () => { ); }); + it('adds transaction if gas fee params missing', async () => { + request.quotes[0].original.steps[0].items[0].data.maxFeePerGas = + undefined as never; + + request.quotes[0].original.steps[0].items[0].data.maxPriorityFeePerGas = + undefined as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }), + expect.anything(), + ); + }); + it('throws if step kind is unsupported', async () => { request.quotes[0].original.steps[0].kind = 'unsupported' as never; @@ -1674,352 +1697,15 @@ describe('Relay Submit Utils', () => { }); describe('EIP-7702 execute path', () => { - const DELEGATION_MANAGER_MOCK = '0xdelegationManager' as Hex; - const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; - - const DELEGATION_RESULT_MOCK = { - authorizationList: [ - { - address: '0xdelegateAddr' as Hex, - chainId: '0x1' as Hex, - nonce: '0x0' as Hex, - r: '0xr' as Hex, - s: '0xs' as Hex, - yParity: '0x0' as Hex, - }, - ], - data: DELEGATION_DATA_MOCK, - to: DELEGATION_MANAGER_MOCK, - value: '0x0' as Hex, - }; - - const EXECUTE_RESPONSE_MOCK = { - message: 'Transaction submitted', - requestId: REQUEST_ID_MOCK, - }; - - const FEATURE_FLAGS_MOCK = { - relayExecuteUrl: 'https://api.relay.link/execute', - relayFallbackGas: { max: 123 }, - } as FeatureFlags; - beforeEach(() => { request.quotes[0].original.metamask.isExecute = true; - getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); - getFeatureFlagsMock.mockReturnValue(FEATURE_FLAGS_MOCK); - - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => EXECUTE_RESPONSE_MOCK, - } as Response) - .mockResolvedValue({ - ok: true, - json: async () => STATUS_RESPONSE_MOCK, - } as Response); - }); - - it('calls getDelegationTransaction with source calls as nestedTransactions', async () => { - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - chainId: CHAIN_ID_MOCK, - networkClientId: NETWORK_CLIENT_ID_MOCK, - nestedTransactions: [ - { - data: '0x1234', - to: '0xfedcb', - value: '0x4d2', - }, - ], - }), - }); + submitViaRelayExecuteMock.mockResolvedValue(undefined); }); - it('resolves networkClientId for source chain instead of inheriting from original transaction', async () => { + it('delegates to submitViaRelayExecute when isExecute is true', async () => { await submitRelayQuotes(request); - expect(findNetworkClientIdByChainIdMock).toHaveBeenCalledWith( - CHAIN_ID_MOCK, - ); - }); - - it('passes txParams with from overridden by quote request from', async () => { - const ACCOUNT_OVERRIDE_MOCK = '0xaccountOverride' as Hex; - - request.quotes[0].request.from = ACCOUNT_OVERRIDE_MOCK; - request.transaction = { - ...request.transaction, - txParams: { - from: FROM_MOCK, - data: '0xorigdata' as Hex, - value: '0x100' as Hex, - }, - } as TransactionMeta; - - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - txParams: { - from: ACCOUNT_OVERRIDE_MOCK, - data: '0xorigdata', - value: '0x100', - }, - }), - }); - }); - - it('submits to /execute with delegation data', async () => { - await submitRelayQuotes(request); - - expect(successfulFetchMock).toHaveBeenCalledWith( - FEATURE_FLAGS_MOCK.relayExecuteUrl, - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - executionKind: 'rawCalls', - data: { - chainId: 1, - to: DELEGATION_MANAGER_MOCK, - data: DELEGATION_DATA_MOCK, - value: '0', - authorizationList: [ - { - chainId: 1, - address: '0xdelegateAddr', - nonce: 0, - yParity: 0, - r: '0xr', - s: '0xs', - }, - ], - }, - executionOptions: { - subsidizeFees: false, - }, - requestId: REQUEST_ID_MOCK, - }), - }), - ); - }); - - it('wraps /execute submission failures with the Relay execute prefix (Relay submit prefix is applied at RelayStrategy.execute)', async () => { - successfulFetchMock.mockReset(); - successfulFetchMock.mockResolvedValueOnce({ - ok: false, - status: 422, - json: async () => ({ - message: 'failed to decode param in array[0] invalid JSON input', - }), - } as Response); - - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Relay: Execute: 422 - failed to decode param in array[0] invalid JSON input', - ); - }); - - it('wraps non-Error throws from /execute with the Relay execute prefix', async () => { - successfulFetchMock.mockReset(); - successfulFetchMock.mockRejectedValueOnce('network down'); - - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Relay: Execute: network down', - ); - }); - - it('omits authorizationList when delegation has none', async () => { - getDelegationTransactionMock.mockResolvedValue({ - ...DELEGATION_RESULT_MOCK, - authorizationList: undefined, - }); - - await submitRelayQuotes(request); - - const fetchCall = successfulFetchMock.mock.calls[0]; - const body = JSON.parse( - (fetchCall[1] as RequestInit).body as string, - ) as Record; - const data = body.data as Record; - - expect(data.authorizationList).toBeUndefined(); - }); - - it('uses fallback values for missing data and value in source params', async () => { - const quoteWithoutDataOrValue = { - ...request.quotes[0], - original: { - ...ORIGINAL_QUOTE_MOCK, - metamask: { - ...ORIGINAL_QUOTE_MOCK.metamask, - isExecute: true, - }, - steps: [ - { - ...ORIGINAL_QUOTE_MOCK.steps[0], - items: [ - { - ...ORIGINAL_QUOTE_MOCK.steps[0].items[0], - data: { - ...ORIGINAL_QUOTE_MOCK.steps[0].items[0].data, - data: undefined, - value: undefined, - }, - }, - ], - }, - ], - }, - } as TransactionPayQuote; - - request = { - ...request, - quotes: [quoteWithoutDataOrValue], - }; - - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - nestedTransactions: [ - { - data: '0x', - to: '0xfedcb', - value: '0x0', - }, - ], - }), - }); - }); - - it('does not call addTransaction or addTransactionBatch', async () => { - await submitRelayQuotes(request); - - expect(addTransactionMock).not.toHaveBeenCalled(); - expect(addTransactionBatchMock).not.toHaveBeenCalled(); - }); - - it('still validates source balance', async () => { - getLiveTokenBalanceMock.mockResolvedValue('500000'); - - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Insufficient source token balance for relay deposit', - ); - - expect(getDelegationTransactionMock).not.toHaveBeenCalled(); - }); - - it('polls relay status after execute', async () => { - await submitRelayQuotes(request); - - expect(successfulFetchMock).toHaveBeenCalledWith( - `${RELAY_STATUS_URL}?requestId=${REQUEST_ID_MOCK}`, - { method: 'GET' }, - ); - }); - - it('returns target hash from relay status', async () => { - const result = await submitRelayQuotes(request); - expect(result.transactionHash).toBe(TRANSACTION_HASH_MOCK); - }); - - it('populates sourceHash on transaction metamaskPay from inTxHashes', async () => { - await submitRelayQuotes(request); - - const updateCall = updateTransactionMock.mock.calls.find( - ([{ note }]) => note === 'Add source hash from Relay status', - ); - - expect(updateCall).toBeDefined(); - - const tx = {} as TransactionMeta; - updateCall?.[1](tx); - - expect(tx.metamaskPay?.sourceHash).toBe(SOURCE_HASH_MOCK); - }); - - it('includes original transaction in nestedTransactions for post-quote flow', async () => { - request.quotes[0].request.isPostQuote = true; - request.transaction = { - id: ORIGINAL_TRANSACTION_ID_MOCK, - txParams: { - from: FROM_MOCK, - to: '0xrecipient' as Hex, - data: '0xorigdata' as Hex, - value: '0x100' as Hex, - }, - type: TransactionType.simpleSend, - } as TransactionMeta; - - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - nestedTransactions: [ - { - data: '0xorigdata', - to: '0xrecipient', - value: '0x100', - }, - { - data: '0x1234', - to: '0xfedcb', - value: '0x4d2', - }, - ], - }), - }); - }); - - it('uses fallback values when original transaction has no data or value in post-quote flow', async () => { - request.quotes[0].request.isPostQuote = true; - request.transaction = { - id: ORIGINAL_TRANSACTION_ID_MOCK, - txParams: { - from: FROM_MOCK, - to: '0xrecipient' as Hex, - }, - type: TransactionType.simpleSend, - } as TransactionMeta; - - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).toHaveBeenCalledWith({ - transaction: expect.objectContaining({ - nestedTransactions: [ - { - data: '0x', - to: '0xrecipient', - value: '0x0', - }, - { - data: '0x1234', - to: '0xfedcb', - value: '0x4d2', - }, - ], - }), - }); - }); - - it('uses TransactionController path when isExecute is not set', async () => { - request.quotes[0].original.metamask.isExecute = undefined; - - successfulFetchMock.mockReset(); - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => STATUS_RESPONSE_MOCK, - } as Response); - - await submitRelayQuotes(request); - - expect(getDelegationTransactionMock).not.toHaveBeenCalled(); - expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(submitViaRelayExecuteMock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 5bb5b1cd436..0e9ec20b608 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -44,10 +44,9 @@ import { sweepPolymarketDepositWallet, submitPolymarketWithdraw, } from './polymarket/withdraw'; -import { getRelayStatus, submitRelayExecute } from './relay-api'; +import { getRelayStatus } from './relay-api'; +import { submitViaRelayExecute } from './relay-submit-execute'; import type { - RelayCompletionOutcome, - RelayExecuteRequest, RelayQuote, RelayStatus, RelayStatusResponse, @@ -56,7 +55,6 @@ import type { const log = createModuleLogger(projectLogger, 'relay-strategy'); const RELAY_ERROR_PREFIX = 'Relay: '; -const RELAY_EXECUTE_ERROR_PREFIX = 'Execute: '; /** * Submits Relay quotes. @@ -133,6 +131,9 @@ async function executeSingleQuote( let polymarketPreSubmitUsdceBalance = 0n; + // Shallow clone so the server-returned requestId can be written back (state is frozen by Immer). + const mutableOriginal: RelayQuote = { ...quote.original }; + if (quote.request.isHyperliquidSource) { await submitHyperliquidWithdraw(quote, quote.request.from, messenger); } else if (isPolymarket) { @@ -141,10 +142,14 @@ async function executeSingleQuote( polymarketPreSubmitUsdceBalance = preSubmitUsdceBalance; setRelaySourceHash(transaction, messenger, sourceHash); } else { - await submitTransactions(quote, transaction, messenger); + await submitTransactions( + { ...quote, original: mutableOriginal }, + transaction, + messenger, + ); } - const completion = await waitForRelayCompletion(quote.original, messenger, { + const completion = await waitForRelayCompletion(mutableOriginal, messenger, { onSourceHash: (hash) => { log('Source hash received', hash); setRelaySourceHash(transaction, messenger, hash); @@ -197,6 +202,11 @@ function setRelaySourceHash( ); } +type RelayCompletionOutcome = { + status: RelayStatus | 'timeout'; + targetHash?: Hex; +}; + async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, @@ -297,8 +307,14 @@ function normalizeParams( data: params.data, from: params.from, gas: toHex(params.gas ?? featureFlags.relayFallbackGas.max), - maxFeePerGas: toHex(params.maxFeePerGas), - maxPriorityFeePerGas: toHex(params.maxPriorityFeePerGas), + maxFeePerGas: + params.maxFeePerGas === undefined + ? undefined + : toHex(params.maxFeePerGas), + maxPriorityFeePerGas: + params.maxPriorityFeePerGas === undefined + ? undefined + : toHex(params.maxPriorityFeePerGas), to: params.to, value: toHex(params.value ?? '0'), }; @@ -513,93 +529,6 @@ async function buildDelegatedOriginalParams( }; } -/** - * Submit source transactions via Relay's /execute endpoint. - * - * Combines all source calls (approve + deposit, and optionally the - * original transaction for post-quote flows) into a single EIP-7702 - * delegation transaction using getDelegationTransaction, then submits - * it to Relay's /execute endpoint for gasless execution. - * - * @param quote - Relay quote. - * @param transaction - Original transaction meta. - * @param messenger - Controller messenger. - * @param allParams - All source transaction params to combine. - * @returns Fallback hash (actual hash comes from relay status polling). - */ -async function submitViaRelayExecute( - quote: TransactionPayQuote, - transaction: TransactionMeta, - messenger: TransactionPayControllerMessenger, - allParams: TransactionParams[], -): Promise { - const { from, sourceChainId } = quote.request; - const { requestId } = quote.original.steps[0]; - - const networkClientId = getNetworkClientId(messenger, sourceChainId); - - const sourceCallTransaction = { - ...transaction, - chainId: sourceChainId, - networkClientId, - nestedTransactions: allParams.map((params) => ({ - data: (params.data ?? '0x') as Hex, - to: params.to as Hex, - value: (params.value ?? '0x0') as Hex, - })), - txParams: { - ...transaction.txParams, - from, - }, - } as TransactionMeta; - - const delegation = await messenger.call( - 'TransactionPayController:getDelegationTransaction', - { transaction: sourceCallTransaction }, - ); - - log('Delegation result for source calls', delegation); - - const executeBody: RelayExecuteRequest = { - executionKind: 'rawCalls', - data: { - chainId: Number(sourceChainId), - to: delegation.to, - data: delegation.data, - value: new BigNumber(delegation.value).toFixed(), - ...(delegation.authorizationList?.length - ? { - authorizationList: delegation.authorizationList.map((auth) => ({ - chainId: Number(auth.chainId), - address: auth.address, - nonce: Number(auth.nonce), - yParity: Number(auth.yParity), - r: auth.r as Hex, - s: auth.s as Hex, - })), - } - : {}), - }, - executionOptions: { - subsidizeFees: false, - }, - requestId, - }; - - log('Submitting via Relay execute', { executeBody, from }); - - let result; - try { - result = await submitRelayExecute(messenger, executeBody); - } catch (error) { - throw prefixError(error, RELAY_EXECUTE_ERROR_PREFIX); - } - - log('Relay execute response', result); - - return FALLBACK_HASH; -} - /** * Submit source transactions via the TransactionController. * diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index aa99907dd7c..ccb8356225c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -28,6 +28,9 @@ export type RelayQuoteRequest = { useDepositAddress?: boolean; strict?: boolean; user: Hex; + metamask?: { + executeVersion?: number; + }; }; export type RelayQuote = { @@ -157,6 +160,8 @@ export type RelayHyperliquidDepositStep = { type RelayQuoteMetamaskBase = { isExecute?: boolean; isMaxGasStation?: boolean; + isSubsidized?: boolean; + signature?: string; }; export type RelayQuoteMetamask = RelayQuoteMetamaskBase & { @@ -164,6 +169,12 @@ export type RelayQuoteMetamask = RelayQuoteMetamaskBase & { is7702: boolean; }; +export type RelayExecuteMetamask = { + isSubsidized: boolean; + quoteRequest: RelayQuoteRequest; + signature: string; +}; + export type RelayExecuteRequest = { executionKind: 'rawCalls'; data: { @@ -185,6 +196,7 @@ export type RelayExecuteRequest = { subsidizeFees: boolean; }; requestId?: string; + metamask: RelayExecuteMetamask; }; export type RelayExecuteResponse = { diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 35e41c2bfd7..5ff5103e618 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -783,8 +783,11 @@ export type UpdateFiatPaymentRequest = { /** Callback to convert a transaction to a redeem delegation. */ export type GetDelegationTransactionCallback = ({ transaction, + isSubsidized, }: { transaction: TransactionMeta; + /** Optional flag to indicate the delegation should use subsidized caveats. */ + isSubsidized?: boolean; }) => Promise<{ authorizationList?: AuthorizationList; data: Hex; From 780a48bce9cc3fce30441052789b05c58717a394 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 6 Jul 2026 15:14:47 +0100 Subject: [PATCH 2/6] refactor: remove jsdoc on isSubsidized flag --- packages/transaction-pay-controller/src/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 5ff5103e618..c1904e38f0d 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -786,7 +786,6 @@ export type GetDelegationTransactionCallback = ({ isSubsidized, }: { transaction: TransactionMeta; - /** Optional flag to indicate the delegation should use subsidized caveats. */ isSubsidized?: boolean; }) => Promise<{ authorizationList?: AuthorizationList; From b369cbb993e6f8ae4462821a966712be9cb2000f Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 6 Jul 2026 15:28:18 +0100 Subject: [PATCH 3/6] fix: wire executeVersion 2 into quote request, share RelayCompletionOutcome, derive execute error prefix - Set metamask.executeVersion: 2 on Relay execute quote requests so intents-api activates the v2 subsidized flow (was a dead type field) - Restore shared RelayCompletionOutcome import from ./types; remove duplicate local definition in relay-submit.ts - Use RELAY_EXECUTE_ERROR_PREFIX constant in execute throws instead of hardcoding the 'Relay: Execute: ' literal; outer wrapper composes the 'Relay: ' prefix --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- .../src/strategy/relay/relay-quotes.test.ts | 2 ++ .../src/strategy/relay/relay-quotes.ts | 5 ++++- .../src/strategy/relay/relay-submit-execute.ts | 8 +++++--- .../src/strategy/relay/relay-submit.ts | 6 +----- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6a6547bdf58..f98a4d2b7f6 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 an optional `isSubsidized` flag to `GetDelegationTransactionCallback` and send a signed `metamask` envelope with `executeVersion: 2` for Relay executes ([#9298](https://github.com/MetaMask/core/pull/9298)) +- Add an optional `isSubsidized` flag to `GetDelegationTransactionCallback`, send `metamask.executeVersion: 2` on Relay execute quote requests, and include a signed `metamask` envelope on Relay executes ([#9298](https://github.com/MetaMask/core/pull/9298)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 32b86c08e83..cd6ffa21974 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -300,6 +300,7 @@ describe('Relay Quotes Utils', () => { ); expect(body.originGasOverhead).toBeUndefined(); + expect(body.metamask).toBeUndefined(); }); it('includes originGasOverhead when relay execute is enabled on EIP-7702 chain', async () => { @@ -322,6 +323,7 @@ describe('Relay Quotes Utils', () => { ); expect(body.originGasOverhead).toBe(DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD); + expect(body.metamask).toStrictEqual({ executeVersion: 2 }); }); it('omits originGasOverhead when relay execute is enabled but chain does not support EIP-7702', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index d4fefe38aff..acd5b446ebd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -308,7 +308,10 @@ async function getSingleQuote( originChainId: Number(sourceChainId), originCurrency: sourceTokenAddress, ...(useExecute - ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } + ? { + originGasOverhead: getRelayOriginGasOverhead(messenger), + metamask: { executeVersion: 2 }, + } : {}), recipient: request.recipient ?? from, slippageTolerance, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts index f972ed91db2..fec7c2d59e5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -103,7 +103,9 @@ function stripRelayExecuteMarker(requestId: string): string { function getRelayExecuteRequestId(quote: RelayQuote): string { const requestId = quote.steps?.[0]?.requestId; if (!requestId) { - throw new Error('Relay: Execute: Missing requestId in quote step'); + throw new Error( + `${RELAY_EXECUTE_ERROR_PREFIX}Missing requestId in quote step`, + ); } return stripRelayExecuteMarker(requestId); } @@ -119,7 +121,7 @@ function getRelayExecuteMetamask( const signature = quote.metamask?.signature; if (!signature) { throw new Error( - 'Relay: Execute: Missing metamask.signature — cannot submit to /relay/execute without the HMAC token', + `${RELAY_EXECUTE_ERROR_PREFIX}Missing metamask.signature — cannot submit to /relay/execute without the HMAC token`, ); } return { @@ -162,7 +164,7 @@ function replaceFirstStepRequestId(quote: RelayQuote, requestId: string): void { /* istanbul ignore if: requestId is read from steps[0] earlier, so steps is non-empty here; defensive guard. */ if (steps.length === 0) { throw new Error( - 'Relay: Execute: Cannot update requestId — quote has no steps', + `${RELAY_EXECUTE_ERROR_PREFIX}Cannot update requestId — quote has no steps`, ); } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 0e9ec20b608..5f7da9bbec5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -47,6 +47,7 @@ import { import { getRelayStatus } from './relay-api'; import { submitViaRelayExecute } from './relay-submit-execute'; import type { + RelayCompletionOutcome, RelayQuote, RelayStatus, RelayStatusResponse, @@ -202,11 +203,6 @@ function setRelaySourceHash( ); } -type RelayCompletionOutcome = { - status: RelayStatus | 'timeout'; - targetHash?: Hex; -}; - async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, From 6c6e9e9c6a474b3afaf30228f5a3627dec0ecc6a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Mon, 6 Jul 2026 15:32:36 +0100 Subject: [PATCH 4/6] refactor: apply Execute error prefix once via outer try/catch - Wrap submitViaRelayExecute body in a single try/catch that applies RELAY_EXECUTE_ERROR_PREFIX via prefixError, instead of prefixing each throw individually - Plain error messages in the inner helpers; remove the redundant inner try/catch around submitRelayExecute --- .../strategy/relay/relay-submit-execute.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts index fec7c2d59e5..498f809fcf8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -26,6 +26,24 @@ export async function submitViaRelayExecute( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, allParams: TransactionParams[], +): Promise { + try { + return await submitViaRelayExecuteInternal( + quote, + transaction, + messenger, + allParams, + ); + } catch (error) { + throw prefixError(error, RELAY_EXECUTE_ERROR_PREFIX); + } +} + +async function submitViaRelayExecuteInternal( + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, + allParams: TransactionParams[], ): Promise { const isSubsidized = isSubsidizedRelayQuote(quote.original); const requestId = getRelayExecuteRequestId(quote.original); @@ -74,12 +92,7 @@ export async function submitViaRelayExecute( log('Submitting to Relay execute', { executeBody, from: quote.request.from }); - let result; - try { - result = await submitRelayExecute(messenger, executeBody); - } catch (error) { - throw prefixError(error, RELAY_EXECUTE_ERROR_PREFIX); - } + const result = await submitRelayExecute(messenger, executeBody); log('Relay execute response', result); @@ -103,9 +116,7 @@ function stripRelayExecuteMarker(requestId: string): string { function getRelayExecuteRequestId(quote: RelayQuote): string { const requestId = quote.steps?.[0]?.requestId; if (!requestId) { - throw new Error( - `${RELAY_EXECUTE_ERROR_PREFIX}Missing requestId in quote step`, - ); + throw new Error('Missing requestId in quote step'); } return stripRelayExecuteMarker(requestId); } @@ -121,7 +132,7 @@ function getRelayExecuteMetamask( const signature = quote.metamask?.signature; if (!signature) { throw new Error( - `${RELAY_EXECUTE_ERROR_PREFIX}Missing metamask.signature — cannot submit to /relay/execute without the HMAC token`, + 'Missing metamask.signature — cannot submit to /relay/execute without the HMAC token', ); } return { @@ -163,9 +174,7 @@ function replaceFirstStepRequestId(quote: RelayQuote, requestId: string): void { const steps = quote.steps ?? []; /* istanbul ignore if: requestId is read from steps[0] earlier, so steps is non-empty here; defensive guard. */ if (steps.length === 0) { - throw new Error( - `${RELAY_EXECUTE_ERROR_PREFIX}Cannot update requestId — quote has no steps`, - ); + throw new Error('Cannot update requestId — quote has no steps'); } const existingStep = steps[0]; From e2984e1aaf02c1bf927f60691b8d29da567e487e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 7 Jul 2026 12:59:23 +0100 Subject: [PATCH 5/6] fix(transaction-pay-controller): regenerate execute txParams from current quote Build the delegation transaction's txParams (to, value, data) from the current quote's nested transactions before requesting the delegation transaction on Relay executes, instead of spreading potentially stale txParams from a previous quote. Single nested transaction is used directly; multiple are wrapped into an atomic ERC-7821 batch. Also export generateEIP7702BatchTransaction from @metamask/transaction-controller to build the ERC-7821 execute(mode, calls) batch. --- packages/transaction-controller/CHANGELOG.md | 4 ++ packages/transaction-controller/src/index.ts | 5 +- .../transaction-pay-controller/CHANGELOG.md | 6 +- .../relay/relay-submit-execute.test.ts | 68 +++++++++++++++++++ .../strategy/relay/relay-submit-execute.ts | 26 +++++-- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 352f7da1d49..cf391879f41 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `generateEIP7702BatchTransaction` utility for building an ERC-7821 `execute(mode, calls)` batch transaction from a list of nested transactions ([#9298](https://github.com/MetaMask/core/pull/9298)) + ## [68.3.0] ### Added diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 6c4b08d21fe..9dbc08c9841 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -129,7 +129,10 @@ export { WalletDevice, } from './types'; export { mergeGasFeeEstimates } from './utils/gas-flow'; -export { decodeAuthorizationSignature } from './utils/eip7702'; +export { + decodeAuthorizationSignature, + generateEIP7702BatchTransaction, +} from './utils/eip7702'; export { isEIP1559Transaction, normalizeTransactionParams, diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f98a4d2b7f6..4d8cdaf83de 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 an optional `isSubsidized` flag to `GetDelegationTransactionCallback`, send `metamask.executeVersion: 2` on Relay execute quote requests, and include a signed `metamask` envelope on Relay executes ([#9298](https://github.com/MetaMask/core/pull/9298)) +- **BREAKING:** Add an optional `isSubsidized` flag to `GetDelegationTransactionCallback`, send `metamask.executeVersion: 2` on Relay execute quote requests, and include a signed `metamask` envelope on Relay executes ([#9298](https://github.com/MetaMask/core/pull/9298)) ### Changed @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^10.0.1` to `^10.1.0` ([#9411](https://github.com/MetaMask/core/pull/9411)) - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) +### Fixed + +- Regenerate `txParams` (`to`, `value`, `data`) from the current quote's nested transactions before requesting the delegation transaction on Relay executes, so delegation caveats are built from the transactions actually being redeemed instead of stale `txParams` from a previous quote ([#9298](https://github.com/MetaMask/core/pull/9298)) + ## [23.17.4] ### Fixed diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts index 73dd44ea875..e36509046db 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts @@ -1,3 +1,4 @@ +import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; @@ -190,6 +191,73 @@ describe('Relay Submit Execute', () => { }); }); + it('regenerates txParams from the single nested transaction', async () => { + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + txParams: expect.objectContaining({ + from: FROM_MOCK, + to: '0xfedcb', + data: '0x1234', + value: '0x4d2', + }), + }), + isSubsidized: false, + }); + }); + + it('regenerates txParams as an EIP-7702 batch for multiple nested transactions', async () => { + const batchFrom = '0x1111111111111111111111111111111111111111' as Hex; + quote.request.from = batchFrom; + + const multiParams = [ + { + to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + data: '0x1111' as Hex, + value: '0x1' as Hex, + }, + { + to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + data: '0x2222' as Hex, + value: '0x2' as Hex, + }, + ]; + + const expectedBatch = generateEIP7702BatchTransaction( + batchFrom, + multiParams, + ); + + await submitViaRelayExecute(quote, transaction, messenger, multiParams); + + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + txParams: expect.objectContaining({ + from: batchFrom, + to: expectedBatch.to, + data: expectedBatch.data, + value: '0x0', + }), + }), + isSubsidized: false, + }); + }); + + it('does not use stale txParams.data from the incoming transaction', async () => { + transaction.txParams.data = '0xstaledata' as Hex; + transaction.txParams.to = '0xstaleto' as Hex; + + await submitViaRelayExecute(quote, transaction, messenger, allParams); + + const call = getDelegationTransactionMock.mock.calls[0][0] as { + transaction: TransactionMeta; + }; + + expect(call.transaction.txParams.data).toBe('0x1234'); + expect(call.transaction.txParams.to).toBe('0xfedcb'); + }); + it('submits to /execute with delegation data and metamask envelope', async () => { await submitViaRelayExecute(quote, transaction, messenger, allParams); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts index 498f809fcf8..e2f2144a151 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -1,3 +1,4 @@ +import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; import type { AuthorizationList, @@ -52,18 +53,33 @@ async function submitViaRelayExecuteInternal( const { from, sourceChainId } = quote.request; const networkClientId = getNetworkClientId(messenger, sourceChainId); + const nestedTransactions = allParams.map((param) => ({ + data: (param.data ?? '0x') as Hex, + to: param.to as Hex, + value: (param.value ?? '0x0') as Hex, + })); + + // Regenerate `txParams` so it matches the current quote's nested transactions. + // The original `transaction.txParams` may be stale (from a previous quote), and + // downstream delegation caveats are built from `txParams.data`, so it must reflect + // the transactions being redeemed. A single nested transaction is used directly; + // multiple are wrapped into an atomic EIP-7702 (ERC-7821) batch. + const batchParams = + nestedTransactions.length === 1 + ? nestedTransactions[0] + : generateEIP7702BatchTransaction(from, nestedTransactions); + const executionTransaction: TransactionMeta = { ...transaction, chainId: sourceChainId, networkClientId, - nestedTransactions: allParams.map((param) => ({ - data: (param.data ?? '0x') as Hex, - to: param.to as Hex, - value: (param.value ?? '0x0') as Hex, - })), + nestedTransactions, txParams: { ...transaction.txParams, from, + to: batchParams.to, + value: batchParams.value ?? '0x0', + data: batchParams.data, }, } as TransactionMeta; From fd072cbd75d311ff8a6b556d9c8d7b37a38e9c1c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 7 Jul 2026 13:02:01 +0100 Subject: [PATCH 6/6] docs: remove txParams Fixed changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4d8cdaf83de..7fc5ff287b2 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -19,10 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^10.0.1` to `^10.1.0` ([#9411](https://github.com/MetaMask/core/pull/9411)) - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) -### Fixed - -- Regenerate `txParams` (`to`, `value`, `data`) from the current quote's nested transactions before requesting the delegation transaction on Relay executes, so delegation caveats are built from the transactions actually being redeemed instead of stale `txParams` from a previous quote ([#9298](https://github.com/MetaMask/core/pull/9298)) - ## [23.17.4] ### Fixed