Skip to content

Commit 5ffc19a

Browse files
feat: multiple transactions for relay quote (#7089)
## Explanation Support Relay quotes requiring multiple transactions, such as a token approval and deposit. Use fallback gas limit if missing in transaction data. ## References Related to [#6184](MetaMask/MetaMask-planning#6184) ## 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/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds multi-transaction support for Relay quotes with batch submission, fallback gas handling, and shared transaction ID collection utility. > > - **Relay strategy**: > - Support multiple transactions per quote: submit via `TransactionController:addTransactionBatch`, collect required IDs with `collectTransactionIds`, wait for all confirmations, and return last tx `hash`. > - Poll Relay status from the last step; set `isIntentComplete` when `skipTransaction`. > - Normalize params with fallback `gas` using `RELAY_FALLBACK_GAS_LIMIT`. > - Quote fees: compute source network fee using total gas across items or fallback when missing. > - **Utils**: > - Add `utils/transaction#collectTransactionIds` and use it in Bridge/Relay; remove in-file implementation from Bridge. > - **Types/Mocks**: > - Make `RelayQuote.steps[].items[].data.gas` optional; extend messenger to handle `TransactionController:addTransactionBatch`. > - **Changelog**: > - Note support for Relay quotes with multiple transactions. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7c30789. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4fe6a2f commit 5ffc19a

File tree

13 files changed

+412
-124
lines changed

13 files changed

+412
-124
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+
### Added
11+
12+
- Support Relay quotes with multiple transactions ([#7089](https://github.com/MetaMask/core/pull/7089))
13+
1014
## [3.1.0]
1115

1216
### Added

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ import {
1616
waitForTransactionConfirmed,
1717
} from '../../utils/transaction';
1818

19-
jest.mock('../../utils/transaction');
2019
jest.mock('./bridge-quotes');
2120

21+
jest.mock('../../utils/transaction', () => ({
22+
...jest.requireActual('../../utils/transaction'),
23+
updateTransaction: jest.fn(),
24+
waitForTransactionConfirmed: jest.fn(),
25+
}));
26+
2227
const FROM_MOCK = '0x123';
2328
const CHAIN_ID_MOCK = toHex(123);
2429
const TRANSACTION_ID_MOCK = '123-456';

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

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
TransactionPayQuote,
1717
} from '../../types';
1818
import {
19+
collectTransactionIds,
1920
updateTransaction,
2021
waitForTransactionConfirmed,
2122
} from '../../utils/transaction';
@@ -222,44 +223,3 @@ async function waitForBridgeCompletion(
222223
);
223224
});
224225
}
225-
226-
/**
227-
* Collect all new transactions until `end` is called.
228-
*
229-
* @param chainId - The chain ID to filter transactions by.
230-
* @param from - The address to filter transactions by.
231-
* @param messenger - The controller messenger.
232-
* @param onTransaction - Callback called with each matching transaction ID.
233-
* @returns An object with an `end` method to stop collecting transactions.
234-
*/
235-
function collectTransactionIds(
236-
chainId: Hex,
237-
from: Hex,
238-
messenger: TransactionPayControllerMessenger,
239-
onTransaction: (transactionId: string) => void,
240-
): { end: () => void } {
241-
const listener = (tx: TransactionMeta) => {
242-
if (
243-
tx.chainId !== chainId ||
244-
tx.txParams.from.toLowerCase() !== from.toLowerCase()
245-
) {
246-
return;
247-
}
248-
249-
onTransaction(tx.id);
250-
};
251-
252-
messenger.subscribe(
253-
'TransactionController:unapprovedTransactionAdded',
254-
listener,
255-
);
256-
257-
const end = () => {
258-
messenger.unsubscribe(
259-
'TransactionController:unapprovedTransactionAdded',
260-
listener,
261-
);
262-
};
263-
264-
return { end };
265-
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export const CHAIN_ID_ARBITRUM = '0xa4b1';
44
export const CHAIN_ID_POLYGON = '0x89';
55
export const RELAY_URL_BASE = 'https://api.relay.link';
66
export const RELAY_URL_QUOTE = `${RELAY_URL_BASE}/quote`;
7+
export const RELAY_FALLBACK_GAS_LIMIT = 900000;

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { successfulFetch } from '@metamask/controller-utils';
22
import type { TransactionMeta } from '@metamask/transaction-controller';
3+
import { cloneDeep } from 'lodash';
34

45
import {
56
ARBITRUM_USDC_ADDRESS,
@@ -85,11 +86,13 @@ const TRANSACTION_META_MOCK = {} as TransactionMeta;
8586
describe('Relay Quotes Utils', () => {
8687
const successfulFetchMock = jest.mocked(successfulFetch);
8788
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
89+
const calculateGasCostMock = jest.mocked(calculateGasCost);
90+
const getNativeTokenMock = jest.mocked(getNativeToken);
91+
8892
const calculateTransactionGasCostMock = jest.mocked(
8993
calculateTransactionGasCost,
9094
);
91-
const calculateGasCostMock = jest.mocked(calculateGasCost);
92-
const getNativeTokenMock = jest.mocked(getNativeToken);
95+
9396
const { messenger, getRemoteFeatureFlagControllerStateMock } =
9497
getMessengerMock();
9598

@@ -271,6 +274,48 @@ describe('Relay Quotes Utils', () => {
271274
});
272275
});
273276

277+
it('includes source network fee in quote using fallback if gas missing', async () => {
278+
const quoteMock = cloneDeep(QUOTE_MOCK);
279+
delete quoteMock.steps[0].items[0].data.gas;
280+
281+
successfulFetchMock.mockResolvedValue({
282+
json: async () => quoteMock,
283+
} as never);
284+
285+
await getRelayQuotes({
286+
messenger,
287+
requests: [QUOTE_REQUEST_MOCK],
288+
transaction: TRANSACTION_META_MOCK,
289+
});
290+
291+
expect(calculateGasCostMock).toHaveBeenCalledWith(
292+
expect.objectContaining({ gas: 900000 }),
293+
);
294+
});
295+
296+
it('includes source network fee using gas total from multiple transactions', async () => {
297+
const quoteMock = cloneDeep(QUOTE_MOCK);
298+
quoteMock.steps[0].items.push({
299+
data: {
300+
gas: '480000',
301+
},
302+
} as never);
303+
304+
successfulFetchMock.mockResolvedValue({
305+
json: async () => quoteMock,
306+
} as never);
307+
308+
await getRelayQuotes({
309+
messenger,
310+
requests: [QUOTE_REQUEST_MOCK],
311+
transaction: TRANSACTION_META_MOCK,
312+
});
313+
314+
expect(calculateGasCostMock).toHaveBeenCalledWith(
315+
expect.objectContaining({ gas: 501000 }),
316+
);
317+
});
318+
274319
it('includes target network fee in quote', async () => {
275320
successfulFetchMock.mockResolvedValue({
276321
json: async () => QUOTE_MOCK,

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

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ARBITRUM_USDC_ADDRESS,
77
CHAIN_ID_ARBITRUM,
88
CHAIN_ID_POLYGON,
9+
RELAY_FALLBACK_GAS_LIMIT,
910
RELAY_URL_QUOTE,
1011
} from './constants';
1112
import type { RelayQuote } from './types';
@@ -309,27 +310,40 @@ function calculateSourceNetworkCost(
309310
quote: RelayQuote,
310311
messenger: TransactionPayControllerMessenger,
311312
) {
312-
const allParams = quote.steps.flatMap((s) => s.items.map((i) => i.data));
313-
314-
const result = allParams.reduce(
315-
(total, params) => {
316-
const gasCost = calculateGasCost({
317-
...params,
318-
maxFeePerGas: undefined,
319-
maxPriorityFeePerGas: undefined,
320-
messenger,
321-
});
322-
323-
return {
324-
usd: new BigNumber(total.usd).plus(gasCost.usd),
325-
fiat: new BigNumber(total.fiat).plus(gasCost.fiat),
326-
};
327-
},
328-
{ usd: new BigNumber(0), fiat: new BigNumber(0) },
329-
);
313+
const allParams = quote.steps[0].items.map((i) => i.data);
314+
const totalGasLimit = calculateSourceNetworkGasLimit(allParams);
330315

331-
return {
332-
usd: result.usd.toString(10),
333-
fiat: result.fiat.toString(10),
334-
};
316+
return calculateGasCost({
317+
chainId: allParams[0].chainId,
318+
gas: totalGasLimit,
319+
messenger,
320+
});
321+
}
322+
323+
/**
324+
* Calculate the total gas limit for the source network transactions.
325+
*
326+
* @param params - Array of transaction parameters.
327+
* @returns - Total gas limit.
328+
*/
329+
function calculateSourceNetworkGasLimit(
330+
params: RelayQuote['steps'][0]['items'][0]['data'][],
331+
): number {
332+
const allParamsHasGas = params.every((p) => p.gas !== undefined);
333+
334+
if (allParamsHasGas) {
335+
return params.reduce(
336+
(total, p) => total + new BigNumber(p.gas as string).toNumber(),
337+
0,
338+
);
339+
}
340+
341+
// In future, call `TransactionController:estimateGas`
342+
// or `TransactionController:estimateGasBatch` based on params length.
343+
344+
return params.reduce(
345+
(total, p) =>
346+
total + new BigNumber(p.gas ?? RELAY_FALLBACK_GAS_LIMIT).toNumber(),
347+
0,
348+
);
335349
}

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { ORIGIN_METAMASK, successfulFetch } from '@metamask/controller-utils';
2-
import type { TransactionMeta } from '@metamask/transaction-controller';
2+
import {
3+
TransactionType,
4+
type TransactionMeta,
5+
} from '@metamask/transaction-controller';
36
import type { Hex } from '@metamask/utils';
47
import { cloneDeep } from 'lodash';
58

@@ -13,6 +16,8 @@ import type {
1316
TransactionPayQuote,
1417
} from '../../types';
1518
import {
19+
collectTransactionIds,
20+
getTransaction,
1621
updateTransaction,
1722
waitForTransactionConfirmed,
1823
} from '../../utils/transaction';
@@ -28,9 +33,11 @@ const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock';
2833
const TRANSACTION_HASH_MOCK = '0x1234';
2934
const ENDPOINT_MOCK = '/test123';
3035
const ORIGINAL_TRANSACTION_ID_MOCK = '456-789';
36+
const FROM_MOCK = '0xabcde' as Hex;
3137

3238
const TRANSACTION_META_MOCK = {
3339
id: '123-456',
40+
hash: TRANSACTION_HASH_MOCK,
3441
} as TransactionMeta;
3542

3643
const ORIGINAL_QUOTE_MOCK = {
@@ -46,7 +53,7 @@ const ORIGINAL_QUOTE_MOCK = {
4653
data: {
4754
chainId: 1,
4855
data: '0x1234' as Hex,
49-
from: '0xabcde' as Hex,
56+
from: FROM_MOCK,
5057
gas: '21000',
5158
maxFeePerGas: '25000000000',
5259
maxPriorityFeePerGas: '1000000000',
@@ -76,9 +83,15 @@ const REQUEST_MOCK: PayStrategyExecuteRequest<RelayQuote> = {
7683
describe('Relay Submit Utils', () => {
7784
const updateTransactionMock = jest.mocked(updateTransaction);
7885
const successfulFetchMock = jest.mocked(successfulFetch);
86+
const getTransactionMock = jest.mocked(getTransaction);
87+
const collectTransactionIdsMock = jest.mocked(collectTransactionIds);
7988

80-
const { addTransactionMock, findNetworkClientIdByChainIdMock, messenger } =
81-
getMessengerMock();
89+
const {
90+
addTransactionMock,
91+
addTransactionBatchMock,
92+
findNetworkClientIdByChainIdMock,
93+
messenger,
94+
} = getMessengerMock();
8295

8396
let request: PayStrategyExecuteRequest<RelayQuote>;
8497

@@ -97,6 +110,14 @@ describe('Relay Submit Utils', () => {
97110
});
98111

99112
waitForTransactionConfirmedMock.mockResolvedValue();
113+
getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK);
114+
115+
collectTransactionIdsMock.mockImplementation(
116+
(_chainId, _from, _messenger, fn) => {
117+
fn(TRANSACTION_META_MOCK.id);
118+
return { end: jest.fn() };
119+
},
120+
);
100121

101122
successfulFetchMock.mockResolvedValue({
102123
json: async () => ({ status: 'success' }),
@@ -129,15 +150,54 @@ describe('Relay Submit Utils', () => {
129150
);
130151
});
131152

153+
it('adds batch transaction if multiple params', async () => {
154+
request.quotes[0].original.steps[0].items.push({
155+
...request.quotes[0].original.steps[0].items[0],
156+
});
157+
158+
await submitRelayQuotes(request);
159+
160+
expect(addTransactionBatchMock).toHaveBeenCalledTimes(1);
161+
expect(addTransactionBatchMock).toHaveBeenCalledWith({
162+
from: FROM_MOCK,
163+
networkClientId: NETWORK_CLIENT_ID_MOCK,
164+
origin: ORIGIN_METAMASK,
165+
requireApproval: false,
166+
transactions: [
167+
{
168+
params: {
169+
data: '0x1234',
170+
gas: '0x5208',
171+
to: '0xfedcb',
172+
value: '0x4d2',
173+
},
174+
type: TransactionType.tokenMethodApprove,
175+
},
176+
{
177+
params: {
178+
data: '0x1234',
179+
gas: '0x5208',
180+
to: '0xfedcb',
181+
value: '0x4d2',
182+
},
183+
},
184+
],
185+
});
186+
});
187+
132188
it('adds transaction if params missing', async () => {
133189
request.quotes[0].original.steps[0].items[0].data.value =
134190
undefined as never;
135191

192+
request.quotes[0].original.steps[0].items[0].data.gas =
193+
undefined as never;
194+
136195
await submitRelayQuotes(request);
137196

138197
expect(addTransactionMock).toHaveBeenCalledTimes(1);
139198
expect(addTransactionMock).toHaveBeenCalledWith(
140199
expect.objectContaining({
200+
gas: '0xdbba0',
141201
value: '0x0',
142202
}),
143203
expect.anything(),

0 commit comments

Comments
 (0)