Skip to content

Commit 88d6222

Browse files
feat: relay transactions (#7122)
## Explanation Support transactions in Relay quotes via delegation and EIP-7702. ## References Related to [#5945](MetaMask/MetaMask-planning#5945) ## 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 EIP-7702 delegation support to include target transactions in Relay quotes and submission, requiring a new getDelegationTransaction option, updating polling/fees, and normalizing Hyperliquid deposits. > > - **Breaking**: > - Require `getDelegationTransaction` in `TransactionPayController` options; expose `TransactionPayController:getDelegationTransaction` action and related types/exports. > - **Relay strategy**: > - Include transactions in Relay quote requests: add `authorizationList`, set `tradeType` to `EXACT_OUTPUT`, and send `txs` (token transfer + delegation). > - Add `CHAIN_ID_HYPERCORE` and normalize Arbitrum USDC deposits to direct Hyperliquid deposits; skip delegation when no tx params or Hypercore. > - Remove `skipTransaction` handling; always set target network fee to `0` in quotes. > - Submission: poll Relay status (configurable `RELAY_POLLING_INTERVAL`); skip polling for same-chain; handle `fallback` status; return target `transactionHash` (or `0x0` fallback); clear original tx nonce and mark intent complete; track required tx IDs. > - **Totals**: > - Change token filtering to ignore `skipIfBalance` balance sufficiency when summing amounts. > - **Misc**: > - Update tests and changelog; minor constants/util refactors. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1d4c565. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d405c7d commit 88d6222

File tree

13 files changed

+317
-104
lines changed

13 files changed

+317
-104
lines changed

packages/transaction-pay-controller/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- **BREAKING:** Include transactions in Relay quotes via EIP-7702 and delegation ([#7122](https://github.com/MetaMask/core/pull/7122))
13+
- Requires new `getDelegationTransaction` constructor option.
14+
1015
### Fixed
1116

1217
- Read Relay provider fees directly from response ([#7098](https://github.com/MetaMask/core/pull/7098))

packages/transaction-pay-controller/src/TransactionPayController.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('TransactionPayController', () => {
3737
*/
3838
function createController() {
3939
return new TransactionPayController({
40+
getDelegationTransaction: jest.fn(),
4041
messenger,
4142
});
4243
}
@@ -85,6 +86,7 @@ describe('TransactionPayController', () => {
8586

8687
it('returns callback value if provided', async () => {
8788
new TransactionPayController({
89+
getDelegationTransaction: jest.fn(),
8890
getStrategy: async () => TransactionPayStrategy.Test,
8991
messenger,
9092
});

packages/transaction-pay-controller/src/TransactionPayController.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { updatePaymentToken } from './actions/update-payment-token';
88
import { CONTROLLER_NAME, TransactionPayStrategy } from './constants';
99
import { QuoteRefresher } from './helpers/QuoteRefresher';
1010
import type {
11+
GetDelegationTransactionCallback,
1112
TransactionData,
1213
TransactionPayControllerMessenger,
1314
TransactionPayControllerOptions,
@@ -36,11 +37,14 @@ export class TransactionPayController extends BaseController<
3637
TransactionPayControllerState,
3738
TransactionPayControllerMessenger
3839
> {
40+
readonly #getDelegationTransaction: GetDelegationTransactionCallback;
41+
3942
readonly #getStrategy?: (
4043
transaction: TransactionMeta,
4144
) => Promise<TransactionPayStrategy>;
4245

4346
constructor({
47+
getDelegationTransaction,
4448
getStrategy,
4549
messenger,
4650
state,
@@ -52,6 +56,7 @@ export class TransactionPayController extends BaseController<
5256
state: { ...getDefaultState(), ...state },
5357
});
5458

59+
this.#getDelegationTransaction = getDelegationTransaction;
5560
this.#getStrategy = getStrategy;
5661

5762
this.#registerActionHandlers();
@@ -127,6 +132,11 @@ export class TransactionPayController extends BaseController<
127132
}
128133

129134
#registerActionHandlers() {
135+
this.messenger.registerActionHandler(
136+
'TransactionPayController:getDelegationTransaction',
137+
this.#getDelegationTransaction.bind(this),
138+
);
139+
130140
this.messenger.registerActionHandler(
131141
'TransactionPayController:getStrategy',
132142
this.#getStrategy ?? (async () => TransactionPayStrategy.Relay),

packages/transaction-pay-controller/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type {
22
TransactionPayControllerActions,
33
TransactionPayControllerEvents,
4+
TransactionPayControllerGetDelegationTransactionAction,
45
TransactionPayControllerGetStateAction,
56
TransactionPayControllerGetStrategyAction,
67
TransactionPayControllerMessenger,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const ARBITRUM_USDC_ADDRESS =
22
'0xaf88d065e77c8cC2239327C5EDb3A432268e5831';
33
export const CHAIN_ID_ARBITRUM = '0xa4b1';
44
export const CHAIN_ID_POLYGON = '0x89';
5+
export const CHAIN_ID_HYPERCORE = '0x539';
56
export const RELAY_URL_BASE = 'https://api.relay.link';
67
export const RELAY_URL_QUOTE = `${RELAY_URL_BASE}/quote`;
78
export const RELAY_FALLBACK_GAS_LIMIT = 900000;
9+
export const RELAY_POLLING_INTERVAL = 1000; // 1 Second

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

Lines changed: 79 additions & 11 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 type { Hex } from '@metamask/utils';
34
import { cloneDeep } from 'lodash';
45

56
import {
@@ -12,7 +13,10 @@ import { getRelayQuotes } from './relay-quotes';
1213
import type { RelayQuote } from './types';
1314
import { NATIVE_TOKEN_ADDRESS } from '../../constants';
1415
import { getMessengerMock } from '../../tests/messenger-mock';
15-
import type { QuoteRequest } from '../../types';
16+
import type {
17+
GetDelegationTransactionCallback,
18+
QuoteRequest,
19+
} from '../../types';
1620
import { calculateGasCost, calculateTransactionGasCost } from '../../utils/gas';
1721
import { getNativeToken, getTokenFiatRate } from '../../utils/token';
1822

@@ -25,14 +29,14 @@ jest.mock('@metamask/controller-utils', () => ({
2529
}));
2630

2731
const QUOTE_REQUEST_MOCK: QuoteRequest = {
28-
from: '0x123',
32+
from: '0x1234567890123456789012345678901234567891',
2933
sourceBalanceRaw: '10000000000000000000',
3034
sourceChainId: '0x1',
3135
sourceTokenAddress: '0xabc',
3236
sourceTokenAmount: '1000000000000000000',
3337
targetAmountMinimum: '123',
3438
targetChainId: '0x2',
35-
targetTokenAddress: '0xdef',
39+
targetTokenAddress: '0x1234567890123456789012345678901234567890',
3640
};
3741

3842
const QUOTE_MOCK = {
@@ -62,12 +66,12 @@ const QUOTE_MOCK = {
6266
},
6367
data: {
6468
chainId: 1,
65-
data: '0x123',
66-
from: '0x1',
69+
data: '0x123' as Hex,
70+
from: '0x1' as Hex,
6771
gas: '21000',
6872
maxFeePerGas: '1000000000',
6973
maxPriorityFeePerGas: '2000000000',
70-
to: '0x2',
74+
to: '0x2' as Hex,
7175
value: '300000',
7276
},
7377
status: 'complete',
@@ -78,7 +82,20 @@ const QUOTE_MOCK = {
7882
],
7983
} as RelayQuote;
8084

81-
const TRANSACTION_META_MOCK = {} as TransactionMeta;
85+
const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta;
86+
87+
const DELEGATION_RESULT_MOCK = {
88+
authorizationList: [
89+
{
90+
chainId: '0x1' as Hex,
91+
nonce: '0x2' as Hex,
92+
yParity: '0x1' as Hex,
93+
},
94+
],
95+
data: '0x111' as Hex,
96+
to: '0x222' as Hex,
97+
value: '0x333' as Hex,
98+
} as Awaited<ReturnType<GetDelegationTransactionCallback>>;
8299

83100
describe('Relay Quotes Utils', () => {
84101
const successfulFetchMock = jest.mocked(successfulFetch);
@@ -90,8 +107,11 @@ describe('Relay Quotes Utils', () => {
90107
calculateTransactionGasCost,
91108
);
92109

93-
const { messenger, getRemoteFeatureFlagControllerStateMock } =
94-
getMessengerMock();
110+
const {
111+
messenger,
112+
getDelegationTransactionMock,
113+
getRemoteFeatureFlagControllerStateMock,
114+
} = getMessengerMock();
95115

96116
beforeEach(() => {
97117
jest.resetAllMocks();
@@ -115,6 +135,8 @@ describe('Relay Quotes Utils', () => {
115135
cacheTimestamp: 0,
116136
remoteFeatureFlags: {},
117137
});
138+
139+
getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK);
118140
});
119141

120142
describe('getRelayQuotes', () => {
@@ -164,6 +186,52 @@ describe('Relay Quotes Utils', () => {
164186
);
165187
});
166188

189+
it('includes transactions in request', async () => {
190+
successfulFetchMock.mockResolvedValue({
191+
json: async () => QUOTE_MOCK,
192+
} as never);
193+
194+
await getRelayQuotes({
195+
messenger,
196+
requests: [QUOTE_REQUEST_MOCK],
197+
transaction: {
198+
...TRANSACTION_META_MOCK,
199+
txParams: {
200+
data: '0xabc' as Hex,
201+
},
202+
} as TransactionMeta,
203+
});
204+
205+
const body = JSON.parse(
206+
successfulFetchMock.mock.calls[0][1]?.body as string,
207+
);
208+
209+
expect(body).toStrictEqual(
210+
expect.objectContaining({
211+
authorizationList: [
212+
{
213+
chainId: 1,
214+
nonce: 2,
215+
yParity: 1,
216+
},
217+
],
218+
tradeType: 'EXACT_OUTPUT',
219+
txs: [
220+
{
221+
to: QUOTE_REQUEST_MOCK.targetTokenAddress,
222+
data: '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567891000000000000000000000000000000000000000000000000000000000000007b',
223+
value: '0x0',
224+
},
225+
{
226+
to: DELEGATION_RESULT_MOCK.to,
227+
data: DELEGATION_RESULT_MOCK.data,
228+
value: DELEGATION_RESULT_MOCK.value,
229+
},
230+
],
231+
}),
232+
);
233+
});
234+
167235
it('sends request to url from feature flag', async () => {
168236
successfulFetchMock.mockResolvedValue({
169237
json: async () => QUOTE_MOCK,
@@ -325,8 +393,8 @@ describe('Relay Quotes Utils', () => {
325393
});
326394

327395
expect(result[0].fees.targetNetwork).toStrictEqual({
328-
usd: '1.23',
329-
fiat: '2.34',
396+
usd: '0',
397+
fiat: '0',
330398
});
331399
});
332400

0 commit comments

Comments
 (0)