Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof fetchRelayQuote>[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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<RelayQuote> {
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<RelayExecuteResponse> {
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<RelayStatusResponse> {
const url = `${RELAY_STATUS_URL}?requestId=${requestId}`;

const response = await successfulFetch(url, { method: 'GET' });

return (await response.json()) as RelayStatusResponse;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
import {
DEFAULT_RELAY_QUOTE_URL,
DEFAULT_SLIPPAGE,
getEIP7702SupportedChains,
isEIP7702Chain,
getGasBuffer,
getSlippage,
} from '../../utils/feature-flags';
Expand All @@ -46,7 +46,7 @@ jest.mock('../../utils/token', () => ({
jest.mock('../../utils/gas');
jest.mock('../../utils/feature-flags', () => ({
...jest.requireActual('../../utils/feature-flags'),
getEIP7702SupportedChains: jest.fn(),
isEIP7702Chain: jest.fn(),
getGasBuffer: jest.fn(),
getSlippage: jest.fn(),
}));
Expand Down Expand Up @@ -160,7 +160,7 @@ 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 getGasBufferMock = jest.mocked(getGasBuffer);
const getSlippageMock = jest.mocked(getSlippage);

Expand Down Expand Up @@ -200,9 +200,7 @@ describe('Relay Quotes Utils', () => {
...getDefaultRemoteFeatureFlagControllerState(),
});

getEIP7702SupportedChainsMock.mockReturnValue([
QUOTE_REQUEST_MOCK.sourceChainId,
]);
isEIP7702ChainMock.mockReturnValue(true);
getGasBufferMock.mockReturnValue(1.0);
getSlippageMock.mockReturnValue(DEFAULT_SLIPPAGE);
getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK);
Expand Down Expand Up @@ -256,6 +254,7 @@ describe('Relay Quotes Utils', () => {
destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress,
originChainId: 1,
originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress,
originGasOverhead: '300000',
recipient: QUOTE_REQUEST_MOCK.from,
tradeType: 'EXPECTED_OUTPUT',
user: QUOTE_REQUEST_MOCK.from,
Expand Down Expand Up @@ -366,6 +365,7 @@ describe('Relay Quotes Utils', () => {
destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress,
originChainId: 1,
originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress,
originGasOverhead: '300000',
recipient: QUOTE_REQUEST_MOCK.from,
slippageTolerance: '50',
tradeType: 'EXPECTED_OUTPUT',
Expand Down Expand Up @@ -610,6 +610,8 @@ describe('Relay Quotes Utils', () => {

const relayQuoteUrl = 'https://test.com/quote';

isEIP7702ChainMock.mockReturnValue(false);

getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
Expand Down Expand Up @@ -1615,9 +1617,7 @@ describe('Relay Quotes Utils', () => {
} as never);

getTokenBalanceMock.mockReturnValue('1724999999999999');
getEIP7702SupportedChainsMock.mockReturnValue([
QUOTE_REQUEST_MOCK.sourceChainId,
]);
isEIP7702ChainMock.mockReturnValue(true);

const result = await getRelayQuotes({
messenger,
Expand Down Expand Up @@ -1897,7 +1897,7 @@ describe('Relay Quotes Utils', () => {
'0x0000000000000000000000000000000000001010',
);

getEIP7702SupportedChainsMock.mockReturnValue([CHAIN_ID_POLYGON]);
isEIP7702ChainMock.mockReturnValue(true);

const polygonToHyperliquidRequest: QuoteRequest = {
...QUOTE_REQUEST_MOCK,
Expand Down
Loading
Loading