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 ffcac29a485..7fc5ff287b2 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 + +- **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 - 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-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.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts new file mode 100644 index 00000000000..e36509046db --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts @@ -0,0 +1,459 @@ +import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; +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('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); + + 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..e2f2144a151 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.ts @@ -0,0 +1,200 @@ +import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; +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 { + 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); + const metamask = getRelayExecuteMetamask(quote.original, isSubsidized); + + 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, + txParams: { + ...transaction.txParams, + from, + to: batchParams.to, + value: batchParams.value ?? '0x0', + data: batchParams.data, + }, + } 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 }); + + const result = await submitRelayExecute(messenger, executeBody); + + 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('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( + '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('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..5f7da9bbec5 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,10 @@ 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 +56,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 +132,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 +143,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); @@ -297,8 +303,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 +525,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..c1904e38f0d 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -783,8 +783,10 @@ export type UpdateFiatPaymentRequest = { /** Callback to convert a transaction to a redeem delegation. */ export type GetDelegationTransactionCallback = ({ transaction, + isSubsidized, }: { transaction: TransactionMeta; + isSubsidized?: boolean; }) => Promise<{ authorizationList?: AuthorizationList; data: Hex;