diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 988e9ac5eba..84136edb686 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `sourceHash` field to `MetamaskPayMetadata` for tracking source chain transaction hashes when no local transaction exists ([#8133](https://github.com/MetaMask/core/pull/8133)) - Add `perpsAcrossDeposit` and `predictAcrossDeposit` transaction types for Across MetaMask Pay submissions ([#7886](https://github.com/MetaMask/core/pull/7886)) ## [62.20.0] diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index b0d5c229c67..d93a9eecef3 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2125,6 +2125,9 @@ export type MetamaskPayMetadata = { /** Total network fee in fiat currency, including the original and bridge transactions. */ networkFeeFiat?: string; + /** Source chain transaction hash if no local transaction. */ + sourceHash?: Hex; + /** Total amount of target token provided in fiat currency. */ targetFiat?: string; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 47e0384795d..93a5372c9c2 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 + +- Support gasless Relay deposits via `execute` endpoint ([#8133](https://github.com/MetaMask/core/pull/8133)) + ## [16.4.0] ### Added diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index 1e673068efd..535cd5b905f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -1,6 +1,8 @@ import { TransactionType } from '@metamask/transaction-controller'; export const RELAY_URL_BASE = 'https://api.relay.link'; +export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`; +export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; export const RELAY_STATUS_URL = `${RELAY_URL_BASE}/intents/status/v3`; export const RELAY_POLLING_INTERVAL = 1000; // 1 Second export const TOKEN_TRANSFER_FOUR_BYTE = '0xa9059cbb'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts b/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts index d0e25895404..0aca93332a3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/gas-station.ts @@ -10,10 +10,7 @@ import type { QuoteRequest, TransactionPayControllerMessenger, } from '../../types'; -import { - getEIP7702SupportedChains, - getFeatureFlags, -} from '../../utils/feature-flags'; +import { getFeatureFlags, isEIP7702Chain } from '../../utils/feature-flags'; import { calculateGasFeeTokenCost } from '../../utils/gas'; const log = createModuleLogger(projectLogger, 'relay-gas-station'); @@ -41,11 +38,7 @@ export function getGasStationEligibility( sourceChainId: QuoteRequest['sourceChainId'], ): GasStationEligibility { const { relayDisabledGasStationChains } = getFeatureFlags(messenger); - const supportedChains = getEIP7702SupportedChains(messenger); - const chainSupportsGasStation = supportedChains.some( - (supportedChainId) => - supportedChainId.toLowerCase() === sourceChainId.toLowerCase(), - ); + const chainSupportsGasStation = isEIP7702Chain(messenger, sourceChainId); const isDisabledChain = relayDisabledGasStationChains.includes(sourceChainId); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts new file mode 100644 index 00000000000..bcb7941448a --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts @@ -0,0 +1,167 @@ +import { successfulFetch } from '@metamask/controller-utils'; + +import { RELAY_STATUS_URL } from './constants'; +import { + fetchRelayQuote, + getRelayStatus, + submitRelayExecute, +} from './relay-api'; +import type { RelayQuoteRequest } from './types'; +import type { FeatureFlags } from '../../utils/feature-flags'; +import { getFeatureFlags } from '../../utils/feature-flags'; + +jest.mock('../../utils/feature-flags'); + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); + +const successfulFetchMock = jest.mocked(successfulFetch); +const getFeatureFlagsMock = jest.mocked(getFeatureFlags); + +const QUOTE_URL_MOCK = 'https://proxy.test/relay/quote'; +const EXECUTE_URL_MOCK = 'https://proxy.test/relay/execute'; + +const MESSENGER_MOCK = {} as Parameters[0]; + +describe('relay-api', () => { + beforeEach(() => { + jest.resetAllMocks(); + + getFeatureFlagsMock.mockReturnValue({ + relayQuoteUrl: QUOTE_URL_MOCK, + relayExecuteUrl: EXECUTE_URL_MOCK, + } as FeatureFlags); + }); + + describe('fetchRelayQuote', () => { + const QUOTE_REQUEST_MOCK: RelayQuoteRequest = { + amount: '1000000', + destinationChainId: 1, + destinationCurrency: '0xaaa', + originChainId: 137, + originCurrency: '0xbbb', + recipient: '0xccc', + tradeType: 'EXPECTED_OUTPUT', + user: '0xccc', + }; + + const QUOTE_RESPONSE_MOCK = { + details: { currencyIn: {}, currencyOut: {} }, + steps: [], + }; + + it('posts to the quote URL from feature flags', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_RESPONSE_MOCK, + } as Response); + + await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); + + expect(successfulFetchMock).toHaveBeenCalledWith(QUOTE_URL_MOCK, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(QUOTE_REQUEST_MOCK), + }); + }); + + it('attaches the request body to the returned quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ ...QUOTE_RESPONSE_MOCK }), + } as Response); + + const quote = await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); + + expect(quote.request).toStrictEqual(QUOTE_REQUEST_MOCK); + }); + + it('returns the parsed quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_RESPONSE_MOCK, + } as Response); + + const quote = await fetchRelayQuote(MESSENGER_MOCK, QUOTE_REQUEST_MOCK); + + expect(quote.details).toStrictEqual(QUOTE_RESPONSE_MOCK.details); + }); + }); + + describe('submitRelayExecute', () => { + const EXECUTE_REQUEST_MOCK = { + executionKind: 'rawCalls' as const, + data: { + chainId: 1, + to: '0xaaa' as `0x${string}`, + data: '0xbbb' as `0x${string}`, + value: '0', + }, + executionOptions: { subsidizeFees: false }, + requestId: '0xreq', + }; + + const EXECUTE_RESPONSE_MOCK = { + message: 'Transaction submitted', + requestId: '0xreq', + }; + + it('posts to the execute URL from feature flags', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => EXECUTE_RESPONSE_MOCK, + } as Response); + + await submitRelayExecute(MESSENGER_MOCK, EXECUTE_REQUEST_MOCK); + + expect(successfulFetchMock).toHaveBeenCalledWith(EXECUTE_URL_MOCK, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(EXECUTE_REQUEST_MOCK), + }); + }); + + it('returns the parsed response', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => EXECUTE_RESPONSE_MOCK, + } as Response); + + const result = await submitRelayExecute( + MESSENGER_MOCK, + EXECUTE_REQUEST_MOCK, + ); + + expect(result).toStrictEqual(EXECUTE_RESPONSE_MOCK); + }); + }); + + describe('getRelayStatus', () => { + const REQUEST_ID_MOCK = '0xabc123'; + + const STATUS_RESPONSE_MOCK = { + status: 'success', + txHashes: [{ txHash: '0xhash', chainId: 1 }], + }; + + it('fetches the status URL with the request ID', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => STATUS_RESPONSE_MOCK, + } as Response); + + await getRelayStatus(REQUEST_ID_MOCK); + + expect(successfulFetchMock).toHaveBeenCalledWith( + `${RELAY_STATUS_URL}?requestId=${REQUEST_ID_MOCK}`, + { method: 'GET' }, + ); + }); + + it('returns the parsed status', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => STATUS_RESPONSE_MOCK, + } as Response); + + const result = await getRelayStatus(REQUEST_ID_MOCK); + + expect(result).toStrictEqual(STATUS_RESPONSE_MOCK); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts new file mode 100644 index 00000000000..dd1b4916565 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts @@ -0,0 +1,75 @@ +import { successfulFetch } from '@metamask/controller-utils'; + +import { RELAY_STATUS_URL } from './constants'; +import type { + RelayExecuteRequest, + RelayExecuteResponse, + RelayQuote, + RelayQuoteRequest, + RelayStatusResponse, +} from './types'; +import type { TransactionPayControllerMessenger } from '../../types'; +import { getFeatureFlags } from '../../utils/feature-flags'; + +/** + * Fetch a quote from the Relay API. + * + * @param messenger - Controller messenger. + * @param body - Quote request parameters. + * @returns The Relay quote with the request attached. + */ +export async function fetchRelayQuote( + messenger: TransactionPayControllerMessenger, + body: RelayQuoteRequest, +): Promise { + const { relayQuoteUrl } = getFeatureFlags(messenger); + + const response = await successfulFetch(relayQuoteUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const quote = (await response.json()) as RelayQuote; + quote.request = body; + + return quote; +} + +/** + * Submit a gasless transaction via the Relay /execute endpoint. + * + * @param messenger - Controller messenger. + * @param body - Execute request parameters. + * @returns The execute response containing the request ID. + */ +export async function submitRelayExecute( + messenger: TransactionPayControllerMessenger, + body: RelayExecuteRequest, +): Promise { + const { relayExecuteUrl } = getFeatureFlags(messenger); + + const response = await successfulFetch(relayExecuteUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return (await response.json()) as RelayExecuteResponse; +} + +/** + * Poll the Relay status endpoint for a given request ID. + * + * @param requestId - The Relay request ID to check. + * @returns The current status of the request. + */ +export async function getRelayStatus( + requestId: string, +): Promise { + const url = `${RELAY_STATUS_URL}?requestId=${requestId}`; + + const response = await successfulFetch(url, { method: 'GET' }); + + return (await response.json()) as RelayStatusResponse; +} 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 3688d0532e1..755d8bcae90 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 @@ -22,9 +22,11 @@ import type { QuoteRequest, } from '../../types'; import { + DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, - getEIP7702SupportedChains, + isEIP7702Chain, + isRelayExecuteEnabled, getGasBuffer, getSlippage, } from '../../utils/feature-flags'; @@ -46,7 +48,8 @@ jest.mock('../../utils/token', () => ({ jest.mock('../../utils/gas'); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), - getEIP7702SupportedChains: jest.fn(), + isEIP7702Chain: jest.fn(), + isRelayExecuteEnabled: jest.fn(), getGasBuffer: jest.fn(), getSlippage: jest.fn(), })); @@ -160,7 +163,8 @@ describe('Relay Quotes Utils', () => { const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost); const getNativeTokenMock = jest.mocked(getNativeToken); const getTokenBalanceMock = jest.mocked(getTokenBalance); - const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); + const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); + const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); const getGasBufferMock = jest.mocked(getGasBuffer); const getSlippageMock = jest.mocked(getSlippage); @@ -200,9 +204,8 @@ describe('Relay Quotes Utils', () => { ...getDefaultRemoteFeatureFlagControllerState(), }); - getEIP7702SupportedChainsMock.mockReturnValue([ - QUOTE_REQUEST_MOCK.sourceChainId, - ]); + isEIP7702ChainMock.mockReturnValue(true); + isRelayExecuteEnabledMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); getSlippageMock.mockReturnValue(DEFAULT_SLIPPAGE); getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); @@ -261,6 +264,49 @@ describe('Relay Quotes Utils', () => { user: QUOTE_REQUEST_MOCK.from, }), ); + + expect(body.originGasOverhead).toBeUndefined(); + }); + + it('includes originGasOverhead when relay execute is enabled on EIP-7702 chain', async () => { + isRelayExecuteEnabledMock.mockReturnValue(true); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.originGasOverhead).toBe(DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD); + }); + + it('omits originGasOverhead when relay execute is enabled but chain does not support EIP-7702', async () => { + isRelayExecuteEnabledMock.mockReturnValue(true); + isEIP7702ChainMock.mockReturnValue(false); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.originGasOverhead).toBeUndefined(); }); it('sends request with EXACT_INPUT trade type when isMaxAmount is true', async () => { @@ -610,6 +656,8 @@ describe('Relay Quotes Utils', () => { const relayQuoteUrl = 'https://test.com/quote'; + isEIP7702ChainMock.mockReturnValue(false); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { @@ -1615,9 +1663,7 @@ describe('Relay Quotes Utils', () => { } as never); getTokenBalanceMock.mockReturnValue('1724999999999999'); - getEIP7702SupportedChainsMock.mockReturnValue([ - QUOTE_REQUEST_MOCK.sourceChainId, - ]); + isEIP7702ChainMock.mockReturnValue(true); const result = await getRelayQuotes({ messenger, @@ -1897,7 +1943,7 @@ describe('Relay Quotes Utils', () => { '0x0000000000000000000000000000000000001010', ); - getEIP7702SupportedChainsMock.mockReturnValue([CHAIN_ID_POLYGON]); + isEIP7702ChainMock.mockReturnValue(true); const polygonToHyperliquidRequest: QuoteRequest = { ...QUOTE_REQUEST_MOCK, 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 039f44339f5..45a4b1be564 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -1,7 +1,7 @@ /* eslint-disable require-atomic-updates */ import { Interface } from '@ethersproject/abi'; -import { successfulFetch, toHex } from '@metamask/controller-utils'; +import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -11,6 +11,7 @@ import { getGasStationEligibility, getGasStationCostInSourceTokenRaw, } from './gas-station'; +import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; import type { RelayQuote, RelayQuoteRequest } from './types'; import { TransactionPayStrategy } from '../..'; @@ -37,8 +38,11 @@ import type { } from '../../types'; import { getFiatValueFromUsd } from '../../utils/amounts'; import { + isEIP7702Chain, + isRelayExecuteEnabled, getFeatureFlags, getGasBuffer, + getRelayOriginGasOverhead, getSlippage, } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; @@ -143,12 +147,19 @@ async function getSingleQuote( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const useExactInput = isMaxAmount || request.isPostQuote; + const useExecute = + isRelayExecuteEnabled(messenger) && + isEIP7702Chain(messenger, sourceChainId); + const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, destinationChainId: Number(targetChainId), destinationCurrency: targetTokenAddress, originChainId: Number(sourceChainId), originCurrency: sourceTokenAddress, + ...(useExecute + ? { originGasOverhead: getRelayOriginGasOverhead(messenger) } + : {}), recipient: from, slippageTolerance, tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', @@ -166,18 +177,9 @@ async function getSingleQuote( body.refundTo = request.refundTo; } - const url = getFeatureFlags(messenger).relayQuoteUrl; - - log('Request body', { body, url }); - - const response = await successfulFetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + log('Request body', body); - const quote = (await response.json()) as RelayQuote; - quote.request = body; + const quote = await fetchRelayQuote(messenger, body); log('Fetched relay quote', quote); 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 498653846bd..3375e178db7 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 @@ -14,7 +14,11 @@ import type { TransactionPayQuote, } from '../../types'; import type { FeatureFlags } from '../../utils/feature-flags'; -import { getFeatureFlags } from '../../utils/feature-flags'; +import { + isEIP7702Chain, + isRelayExecuteEnabled, + getFeatureFlags, +} from '../../utils/feature-flags'; import { getLiveTokenBalance, normalizeTokenAddress } from '../../utils/token'; import { collectTransactionIds, @@ -34,6 +38,7 @@ jest.mock('@metamask/controller-utils', () => ({ const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; +const SOURCE_HASH_MOCK = '0xsourcehash'; const REQUEST_ID_MOCK = '0x1234567890abcdef'; const ORIGINAL_TRANSACTION_ID_MOCK = '456-789'; const FROM_MOCK = '0xabcde' as Hex; @@ -88,6 +93,7 @@ const ORIGINAL_QUOTE_MOCK = { const STATUS_RESPONSE_MOCK = { status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], txHashes: [TRANSACTION_HASH_MOCK], }; @@ -129,9 +135,13 @@ describe('Relay Submit Utils', () => { const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); const normalizeTokenAddressMock = jest.mocked(normalizeTokenAddress); + const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); + const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); + const { addTransactionMock, addTransactionBatchMock, + getDelegationTransactionMock, findNetworkClientIdByChainIdMock, messenger, } = getMessengerMock(); @@ -145,6 +155,9 @@ describe('Relay Submit Utils', () => { beforeEach(() => { jest.resetAllMocks(); + isEIP7702ChainMock.mockReturnValue(false); + isRelayExecuteEnabledMock.mockReturnValue(false); + getLiveTokenBalanceMock.mockResolvedValue('9999999999'); normalizeTokenAddressMock.mockImplementation( (tokenAddress) => tokenAddress, @@ -597,13 +610,15 @@ describe('Relay Submit Utils', () => { const txDraft = { txParams: { nonce: '0x1' } } as TransactionMeta; updateTransactionMock.mock.calls.map((call) => call[1](txDraft)); - expect(txDraft).toStrictEqual({ - isIntentComplete: true, - requiredTransactionIds: [TRANSACTION_META_MOCK.id], - txParams: { - nonce: undefined, - }, - }); + expect(txDraft).toStrictEqual( + expect.objectContaining({ + isIntentComplete: true, + requiredTransactionIds: [TRANSACTION_META_MOCK.id], + txParams: { + nonce: undefined, + }, + }), + ); }); it('returns target hash', async () => { @@ -967,5 +982,294 @@ describe('Relay Submit Utils', () => { 'Cannot validate payment token balance - RPC timeout', ); }); + + 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(() => { + isEIP7702ChainMock.mockReturnValue(true); + isRelayExecuteEnabledMock.mockReturnValue(true); + getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); + getFeatureFlagsMock.mockReturnValue(FEATURE_FLAGS_MOCK); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => EXECUTE_RESPONSE_MOCK, + } as Response) + .mockResolvedValue({ + 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, + nestedTransactions: [ + { + data: '0x1234', + to: '0xfedcb', + value: '0x4d2', + }, + ], + }), + }); + }); + + 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('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, + 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 chain is not EIP-7702', async () => { + isEIP7702ChainMock.mockReturnValue(false); + + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('uses TransactionController path when executeEnabled is false', async () => { + isRelayExecuteEnabledMock.mockReturnValue(false); + + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionMock).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 1c6ded7f12f..7aade65aa79 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -1,8 +1,4 @@ -import { - ORIGIN_METAMASK, - successfulFetch, - toHex, -} from '@metamask/controller-utils'; +import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; import type { @@ -13,19 +9,24 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { - RELAY_DEPOSIT_TYPES, - RELAY_POLLING_INTERVAL, - RELAY_STATUS_URL, -} from './constants'; -import type { RelayQuote, RelayStatusResponse } from './types'; +import { RELAY_DEPOSIT_TYPES, RELAY_POLLING_INTERVAL } from './constants'; +import { getRelayStatus, submitRelayExecute } from './relay-api'; +import type { + RelayExecuteRequest, + RelayQuote, + RelayStatusResponse, +} from './types'; import { projectLogger } from '../../logger'; import type { PayStrategyExecuteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; -import { getFeatureFlags } from '../../utils/feature-flags'; +import { + getFeatureFlags, + isEIP7702Chain, + isRelayExecuteEnabled, +} from '../../utils/feature-flags'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -96,7 +97,24 @@ async function executeSingleQuote( await submitTransactions(quote, transaction, messenger); - const targetHash = await waitForRelayCompletion(quote.original); + const targetHash = await waitForRelayCompletion( + quote.original, + (sourceHash) => { + log('Source hash received', sourceHash); + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Add source hash from Relay status', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.sourceHash = sourceHash; + }, + ); + }, + ); log('Relay request completed', targetHash); @@ -118,9 +136,13 @@ async function executeSingleQuote( * Wait for a Relay request to complete. * * @param quote - Relay quote associated with the request. + * @param onSourceHash - Called with the source tx hash as soon as it appears. * @returns A promise that resolves when the Relay request is complete. */ -async function waitForRelayCompletion(quote: RelayQuote): Promise { +async function waitForRelayCompletion( + quote: RelayQuote, + onSourceHash?: (hash: Hex) => void, +): Promise { const isSameChain = quote.details.currencyIn.currency.chainId === quote.details.currencyOut.currency.chainId; @@ -134,17 +156,22 @@ async function waitForRelayCompletion(quote: RelayQuote): Promise { } const { requestId } = quote.steps[0]; - const url = `${RELAY_STATUS_URL}?requestId=${requestId}`; + let sourceHashEmitted = false; while (true) { - const response = await successfulFetch(url, { method: 'GET' }); - const status = (await response.json()) as RelayStatusResponse; + const status: RelayStatusResponse = await getRelayStatus(requestId); log('Polled status', status.status, status); + if (!sourceHashEmitted && status.inTxHashes?.length) { + sourceHashEmitted = true; + onSourceHash?.(status.inTxHashes[0] as Hex); + } + if (status.status === 'success') { - const targetHash = status.txHashes?.slice(-1)[0] as Hex; - return targetHash ?? FALLBACK_HASH; + const targetHash = + (status.txHashes?.slice(-1)[0] as Hex) ?? FALLBACK_HASH; + return targetHash; } if (['failure', 'refund', 'refunded'].includes(status.status)) { @@ -239,6 +266,13 @@ async function validateSourceBalance( /** * Submit transactions for a relay quote. * + * On EIP-7702 supported chains, combines the source calls via + * getDelegationTransaction and submits through Relay's /execute endpoint + * (gasless — Relay's relayer pays origin gas). + * + * On other chains, adds the transactions directly via the + * TransactionController and waits for on-chain confirmation. + * * @param quote - Relay quote. * @param transaction - Original transaction meta. * @param messenger - Controller messenger. @@ -281,8 +315,127 @@ async function submitTransactions( ] : normalizedParams; + const { sourceChainId } = quote.request; + + if ( + isRelayExecuteEnabled(messenger) && + isEIP7702Chain(messenger, sourceChainId) + ) { + return await submitViaRelayExecute( + quote, + transaction, + messenger, + allParams, + ); + } + + return await submitViaTransactionController( + quote, + transaction, + messenger, + normalizedParams, + allParams, + ); +} + +/** + * 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 sourceCallTransaction = { + ...transaction, + chainId: sourceChainId, + nestedTransactions: allParams.map((params) => ({ + data: (params.data ?? '0x') as Hex, + to: params.to as Hex, + value: (params.value ?? '0x0') as Hex, + })), + } 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 }); + + const result = await submitRelayExecute(messenger, executeBody); + + log('Relay execute response', result); + + return FALLBACK_HASH; +} + +/** + * Submit source transactions via the TransactionController. + * + * Uses addTransaction for single params or addTransactionBatch for + * multiple params. Waits for all transactions to be confirmed on-chain. + * + * @param quote - Relay quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @param normalizedParams - Normalized relay-only params (without prepended original tx). + * @param allParams - All params including any prepended original tx for post-quote flows. + * @returns Hash of the last submitted transaction. + */ +async function submitViaTransactionController( + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, + normalizedParams: TransactionParams[], + allParams: TransactionParams[], +): Promise { const transactionIds: string[] = []; const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { isPostQuote } = quote.request; const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index de687b939a9..0b5a412da32 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -14,6 +14,7 @@ export type RelayQuoteRequest = { destinationCurrency: Hex; originChainId: number; originCurrency: Hex; + originGasOverhead?: string; recipient: Hex; refundTo?: Hex; slippageTolerance?: string; @@ -100,6 +101,34 @@ export type RelayQuote = { }[]; }; +export type RelayExecuteRequest = { + executionKind: 'rawCalls'; + data: { + chainId: number; + to: Hex; + data: Hex; + value: string; + authorizationList?: { + chainId: number; + address: Hex; + nonce: number; + yParity: number; + r: Hex; + s: Hex; + }[]; + }; + executionOptions: { + referrer?: string; + subsidizeFees: boolean; + }; + requestId?: string; +}; + +export type RelayExecuteResponse = { + message: string; + requestId: string; +}; + export type RelayStatus = | 'waiting' | 'pending' diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index adbf43018cf..8711713267c 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -5,11 +5,15 @@ import { DEFAULT_FALLBACK_GAS_ESTIMATE, DEFAULT_FALLBACK_GAS_MAX, DEFAULT_GAS_BUFFER, + DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, DEFAULT_SLIPPAGE, DEFAULT_STRATEGY_ORDER, - getEIP7702SupportedChains, getFallbackGas, + DEFAULT_RELAY_EXECUTE_URL, + getRelayOriginGasOverhead, + isEIP7702Chain, + isRelayExecuteEnabled, getFeatureFlags, getGasBuffer, getPayStrategiesConfig, @@ -51,6 +55,7 @@ describe('Feature Flags Utils', () => { expect(featureFlags).toStrictEqual({ relayDisabledGasStationChains: [], + relayExecuteUrl: DEFAULT_RELAY_EXECUTE_URL, relayFallbackGas: { estimate: DEFAULT_FALLBACK_GAS_ESTIMATE, max: DEFAULT_FALLBACK_GAS_MAX, @@ -81,6 +86,7 @@ describe('Feature Flags Utils', () => { expect(featureFlags).toStrictEqual({ relayDisabledGasStationChains: RELAY_GAS_STATION_DISABLED_CHAINS_MOCK, + relayExecuteUrl: DEFAULT_RELAY_EXECUTE_URL, relayFallbackGas: { estimate: GAS_FALLBACK_ESTIMATE_MOCK, max: GAS_FALLBACK_MAX_MOCK, @@ -385,31 +391,38 @@ describe('Feature Flags Utils', () => { }); }); - describe('getEIP7702SupportedChains', () => { - it('returns empty array when no feature flags are set', () => { - const supportedChains = getEIP7702SupportedChains(messenger); - - expect(supportedChains).toStrictEqual([]); + describe('isEIP7702Chain', () => { + it('returns false when no feature flags are set', () => { + expect(isEIP7702Chain(messenger, CHAIN_ID_MOCK)).toBe(false); }); - it('returns supported chains from feature flags', () => { - const expectedChains = [CHAIN_ID_MOCK, CHAIN_ID_DIFFERENT_MOCK]; - + it('returns true for a supported chain', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_eip_7702: { - supportedChains: expectedChains, + supportedChains: [CHAIN_ID_MOCK, CHAIN_ID_DIFFERENT_MOCK], }, }, }); - const supportedChains = getEIP7702SupportedChains(messenger); + expect(isEIP7702Chain(messenger, CHAIN_ID_MOCK)).toBe(true); + }); - expect(supportedChains).toStrictEqual(expectedChains); + it('returns false for an unsupported chain', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_eip_7702: { + supportedChains: [CHAIN_ID_DIFFERENT_MOCK], + }, + }, + }); + + expect(isEIP7702Chain(messenger, CHAIN_ID_MOCK)).toBe(false); }); - it('returns empty array when confirmations_eip_7702 exists but supportedChains is undefined', () => { + it('returns false when supportedChains is undefined', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { @@ -417,9 +430,72 @@ describe('Feature Flags Utils', () => { }, }); - const supportedChains = getEIP7702SupportedChains(messenger); + expect(isEIP7702Chain(messenger, CHAIN_ID_MOCK)).toBe(false); + }); + }); + + describe('isRelayExecuteEnabled', () => { + it('returns false when no feature flags are set', () => { + expect(isRelayExecuteEnabled(messenger)).toBe(false); + }); + + it('returns true when executeEnabled is true', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { + executeEnabled: true, + }, + }, + }, + }, + }); + + expect(isRelayExecuteEnabled(messenger)).toBe(true); + }); + + it('returns false when executeEnabled is false', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { + executeEnabled: false, + }, + }, + }, + }, + }); + + expect(isRelayExecuteEnabled(messenger)).toBe(false); + }); + }); + + describe('getRelayOriginGasOverhead', () => { + it('returns default when no feature flags are set', () => { + expect(getRelayOriginGasOverhead(messenger)).toBe( + DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, + ); + }); + + it('returns configured value when set', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { + originGasOverhead: '500000', + }, + }, + }, + }, + }); - expect(supportedChains).toStrictEqual([]); + expect(getRelayOriginGasOverhead(messenger)).toBe('500000'); }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 238fa9fc3a2..1307150cad0 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -5,7 +5,10 @@ import { uniq } from 'lodash'; import type { TransactionPayControllerMessenger } from '..'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; -import { RELAY_URL_BASE } from '../strategy/relay/constants'; +import { + RELAY_EXECUTE_URL, + RELAY_QUOTE_URL, +} from '../strategy/relay/constants'; const log = createModuleLogger(projectLogger, 'feature-flags'); @@ -14,7 +17,9 @@ type StrategyOrder = [TransactionPayStrategy, ...TransactionPayStrategy[]]; export const DEFAULT_GAS_BUFFER = 1.0; export const DEFAULT_FALLBACK_GAS_ESTIMATE = 900000; export const DEFAULT_FALLBACK_GAS_MAX = 1500000; -export const DEFAULT_RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; +export const DEFAULT_RELAY_EXECUTE_URL = RELAY_EXECUTE_URL; +export const DEFAULT_RELAY_QUOTE_URL = RELAY_QUOTE_URL; +export const DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD = '300000'; export const DEFAULT_SLIPPAGE = 0.005; export const DEFAULT_ACROSS_API_BASE = 'https://app.across.to/api'; export const DEFAULT_STRATEGY_ORDER: StrategyOrder = [ @@ -34,6 +39,7 @@ type FeatureFlagsRaw = { >; }; relayDisabledGasStationChains?: Hex[]; + relayExecuteUrl?: string; relayFallbackGas?: { estimate?: number; max?: number; @@ -47,6 +53,7 @@ type FeatureFlagsRaw = { export type FeatureFlags = { relayDisabledGasStationChains: Hex[]; + relayExecuteUrl: string; relayFallbackGas: { estimate: number; max: number; @@ -77,6 +84,8 @@ export type PayStrategiesConfigRaw = { across?: AcrossConfigRaw; relay?: { enabled?: boolean; + executeEnabled?: boolean; + originGasOverhead?: string; }; }; @@ -131,6 +140,9 @@ export function getFeatureFlags( const max = featureFlags.relayFallbackGas?.max ?? DEFAULT_FALLBACK_GAS_MAX; + const relayExecuteUrl = + featureFlags.relayExecuteUrl ?? DEFAULT_RELAY_EXECUTE_URL; + const relayQuoteUrl = featureFlags.relayQuoteUrl ?? DEFAULT_RELAY_QUOTE_URL; const relayDisabledGasStationChains = @@ -140,6 +152,7 @@ export function getFeatureFlags( const result: FeatureFlags = { relayDisabledGasStationChains, + relayExecuteUrl, relayFallbackGas: { estimate, max, @@ -188,6 +201,36 @@ export function getPayStrategiesConfig( }; } +/** + * Whether the Relay /execute gasless flow is enabled. + * + * @param messenger - Controller messenger. + * @returns True if the execute flow is enabled. + */ +export function isRelayExecuteEnabled( + messenger: TransactionPayControllerMessenger, +): boolean { + const featureFlags = getFeatureFlagsRaw(messenger); + return featureFlags.payStrategies?.relay?.executeEnabled ?? false; +} + +/** + * Get the origin gas overhead to include in Relay quote requests + * for EIP-7702 chains. + * + * @param messenger - Controller messenger. + * @returns Origin gas overhead as a decimal string. + */ +export function getRelayOriginGasOverhead( + messenger: TransactionPayControllerMessenger, +): string { + const featureFlags = getFeatureFlagsRaw(messenger); + return ( + featureFlags.payStrategies?.relay?.originGasOverhead ?? + DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD + ); +} + /** * Get fallback gas limits for quote/submit flows. * @@ -278,20 +321,26 @@ function getCaseInsensitive( } /** - * Retrieves the supported EIP-7702 chains from feature flags. + * Checks if a chain supports EIP-7702. * * @param messenger - Controller messenger. - * @returns Array of chain IDs that support EIP-7702. + * @param chainId - Chain ID to check. + * @returns Whether the chain supports EIP-7702. */ -export function getEIP7702SupportedChains( +export function isEIP7702Chain( messenger: TransactionPayControllerMessenger, -): Hex[] { + chainId: Hex, +): boolean { const state = messenger.call('RemoteFeatureFlagController:getState'); const eip7702Flags = state.remoteFeatureFlags.confirmations_eip_7702 as | { supportedChains?: Hex[] } | undefined; - return eip7702Flags?.supportedChains ?? []; + const supportedChains = eip7702Flags?.supportedChains ?? []; + + return supportedChains.some( + (supported) => supported.toLowerCase() === chainId.toLowerCase(), + ); } /**