Skip to content

Commit 5470e31

Browse files
feat: zero network fees in relay execute flow (#8181)
## Explanation When using the Relay execute flow (EIP-7702 + execute enabled), network fees are subsidized by the relayer for cross-chain transactions. This change zeroes out the displayed source network fees in the quote normalization step so users see the correct zero cost. relayer. ## References - Builds on top of #8133 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes fee calculation and submit-path selection for Relay transactions based on `quote.metamask.isExecute`, which could impact gas estimation/fee display and which submission mechanism is used. Risk is moderate due to user-visible fee outputs and execution-path branching, but scope is limited to Relay strategy. > > **Overview** > **Relay execute quotes now drive fee display and submission behavior via `metamask.isExecute`.** When a Relay quote indicates execute flow, quote normalization zeroes `fees.sourceNetwork` and clears `metamask.gasLimits` since the relayer subsidizes origin gas. > > `relay-submit` now chooses the `/execute` submission path solely based on `quote.original.metamask.isExecute` (removing EIP-7702/feature-flag gating in this decision), with corresponding test updates. Types and changelog are updated to include the optional `metamask.isExecute` flag. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 91323fc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a618f17 commit 5470e31

File tree

6 files changed

+102
-35
lines changed

6 files changed

+102
-35
lines changed

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Zero out source network fees in Relay strategy when quote indicates execute flow ([#8181](https://github.com/MetaMask/core/pull/8181))
13+
1014
## [16.5.0]
1115

1216
### Added

packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2163,6 +2163,81 @@ describe('Relay Quotes Utils', () => {
21632163
});
21642164
});
21652165

2166+
describe('zeroes source network fees for execute flow', () => {
2167+
const ZERO_AMOUNT = { fiat: '0', human: '0', raw: '0', usd: '0' };
2168+
2169+
it('sets source network fees to zero when quote has isExecute', async () => {
2170+
const quoteMock = cloneDeep(QUOTE_MOCK);
2171+
quoteMock.metamask.isExecute = true;
2172+
2173+
successfulFetchMock.mockResolvedValue({
2174+
json: async () => quoteMock,
2175+
} as never);
2176+
2177+
const result = await getRelayQuotes({
2178+
messenger,
2179+
requests: [QUOTE_REQUEST_MOCK],
2180+
transaction: TRANSACTION_META_MOCK,
2181+
});
2182+
2183+
expect(result[0].fees.sourceNetwork).toStrictEqual({
2184+
estimate: ZERO_AMOUNT,
2185+
max: ZERO_AMOUNT,
2186+
});
2187+
});
2188+
2189+
it('preserves isExecute from quote response on normalized quote', async () => {
2190+
const quoteMock = cloneDeep(QUOTE_MOCK);
2191+
quoteMock.metamask.isExecute = true;
2192+
2193+
successfulFetchMock.mockResolvedValue({
2194+
json: async () => quoteMock,
2195+
} as never);
2196+
2197+
const result = await getRelayQuotes({
2198+
messenger,
2199+
requests: [QUOTE_REQUEST_MOCK],
2200+
transaction: TRANSACTION_META_MOCK,
2201+
});
2202+
2203+
expect(result[0].original.metamask.isExecute).toBe(true);
2204+
});
2205+
2206+
it('does not zero source network fees when quote does not have isExecute', async () => {
2207+
successfulFetchMock.mockResolvedValue({
2208+
json: async () => QUOTE_MOCK,
2209+
} as never);
2210+
2211+
const result = await getRelayQuotes({
2212+
messenger,
2213+
requests: [QUOTE_REQUEST_MOCK],
2214+
transaction: TRANSACTION_META_MOCK,
2215+
});
2216+
2217+
expect(result[0].fees.sourceNetwork).not.toStrictEqual({
2218+
estimate: ZERO_AMOUNT,
2219+
max: ZERO_AMOUNT,
2220+
});
2221+
});
2222+
2223+
it('returns empty gas limits when quote has isExecute', async () => {
2224+
const quoteMock = cloneDeep(QUOTE_MOCK);
2225+
quoteMock.metamask.isExecute = true;
2226+
2227+
successfulFetchMock.mockResolvedValue({
2228+
json: async () => quoteMock,
2229+
} as never);
2230+
2231+
const result = await getRelayQuotes({
2232+
messenger,
2233+
requests: [QUOTE_REQUEST_MOCK],
2234+
transaction: TRANSACTION_META_MOCK,
2235+
});
2236+
2237+
expect(result[0].original.metamask.gasLimits).toStrictEqual([]);
2238+
});
2239+
});
2240+
21662241
it('includes target network fee in quote', async () => {
21672242
successfulFetchMock.mockResolvedValue({
21682243
json: async () => QUOTE_MOCK,

packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ async function normalizeQuote(
473473
const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate);
474474

475475
const metamask = {
476+
...quote.metamask,
476477
gasLimits,
477478
};
478479

@@ -566,6 +567,9 @@ function getFiatRates(
566567
* transaction's params so that gas estimation and gas-fee-token logic handle
567568
* both transactions together.
568569
*
570+
* When the execute flow is active (indicated by `quote.metamask.isExecute`),
571+
* network fees are zeroed because the relayer covers them.
572+
*
569573
* @param quote - Relay quote.
570574
* @param messenger - Controller messenger.
571575
* @param request - Quote request.
@@ -585,6 +589,14 @@ async function calculateSourceNetworkCost(
585589
> {
586590
const { from, sourceChainId, sourceTokenAddress } = request;
587591

592+
if (quote.metamask?.isExecute) {
593+
log('Zeroing network fees for execute flow');
594+
595+
const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' };
596+
597+
return { estimate: zeroAmount, max: zeroAmount, gasLimits: [] };
598+
}
599+
588600
const relayParams = quote.steps
589601
.flatMap((step) => step.items)
590602
.map((item) => item.data);

packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ import type {
1414
TransactionPayQuote,
1515
} from '../../types';
1616
import type { FeatureFlags } from '../../utils/feature-flags';
17-
import {
18-
isEIP7702Chain,
19-
isRelayExecuteEnabled,
20-
getFeatureFlags,
21-
} from '../../utils/feature-flags';
17+
import { getFeatureFlags } from '../../utils/feature-flags';
2218
import { getLiveTokenBalance, normalizeTokenAddress } from '../../utils/token';
2319
import {
2420
collectTransactionIds,
@@ -135,9 +131,6 @@ describe('Relay Submit Utils', () => {
135131
const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance);
136132
const normalizeTokenAddressMock = jest.mocked(normalizeTokenAddress);
137133

138-
const isEIP7702ChainMock = jest.mocked(isEIP7702Chain);
139-
const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled);
140-
141134
const {
142135
addTransactionMock,
143136
addTransactionBatchMock,
@@ -155,9 +148,6 @@ describe('Relay Submit Utils', () => {
155148
beforeEach(() => {
156149
jest.resetAllMocks();
157150

158-
isEIP7702ChainMock.mockReturnValue(false);
159-
isRelayExecuteEnabledMock.mockReturnValue(false);
160-
161151
getLiveTokenBalanceMock.mockResolvedValue('9999999999');
162152
normalizeTokenAddressMock.mockImplementation(
163153
(tokenAddress) => tokenAddress,
@@ -1022,8 +1012,7 @@ describe('Relay Submit Utils', () => {
10221012
} as FeatureFlags;
10231013

10241014
beforeEach(() => {
1025-
isEIP7702ChainMock.mockReturnValue(true);
1026-
isRelayExecuteEnabledMock.mockReturnValue(true);
1015+
request.quotes[0].original.metamask.isExecute = true;
10271016
getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK);
10281017
getFeatureFlagsMock.mockReturnValue(FEATURE_FLAGS_MOCK);
10291018

@@ -1113,6 +1102,10 @@ describe('Relay Submit Utils', () => {
11131102
...request.quotes[0],
11141103
original: {
11151104
...ORIGINAL_QUOTE_MOCK,
1105+
metamask: {
1106+
...ORIGINAL_QUOTE_MOCK.metamask,
1107+
isExecute: true,
1108+
},
11161109
steps: [
11171110
{
11181111
...ORIGINAL_QUOTE_MOCK.steps[0],
@@ -1261,17 +1254,8 @@ describe('Relay Submit Utils', () => {
12611254
});
12621255
});
12631256

1264-
it('uses TransactionController path when chain is not EIP-7702', async () => {
1265-
isEIP7702ChainMock.mockReturnValue(false);
1266-
1267-
await submitRelayQuotes(request);
1268-
1269-
expect(getDelegationTransactionMock).not.toHaveBeenCalled();
1270-
expect(addTransactionMock).toHaveBeenCalledTimes(1);
1271-
});
1272-
1273-
it('uses TransactionController path when executeEnabled is false', async () => {
1274-
isRelayExecuteEnabledMock.mockReturnValue(false);
1257+
it('uses TransactionController path when isExecute is not set', async () => {
1258+
request.quotes[0].original.metamask.isExecute = undefined;
12751259

12761260
await submitRelayQuotes(request);
12771261

packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@ import type {
2222
TransactionPayControllerMessenger,
2323
TransactionPayQuote,
2424
} from '../../types';
25-
import {
26-
getFeatureFlags,
27-
isEIP7702Chain,
28-
isRelayExecuteEnabled,
29-
} from '../../utils/feature-flags';
25+
import { getFeatureFlags } from '../../utils/feature-flags';
3026
import {
3127
getLiveTokenBalance,
3228
normalizeTokenAddress,
@@ -320,12 +316,7 @@ async function submitTransactions(
320316
]
321317
: normalizedParams;
322318

323-
const { sourceChainId } = quote.request;
324-
325-
if (
326-
isRelayExecuteEnabled(messenger) &&
327-
isEIP7702Chain(messenger, sourceChainId)
328-
) {
319+
if (quote.original.metamask.isExecute) {
329320
return await submitViaRelayExecute(
330321
quote,
331322
transaction,

packages/transaction-pay-controller/src/strategy/relay/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export type RelayQuote = {
7474
};
7575
metamask: {
7676
gasLimits: number[];
77+
isExecute?: boolean;
7778
isMaxGasStation?: boolean;
7879
};
7980
request: RelayQuoteRequest;

0 commit comments

Comments
 (0)