Skip to content
Merged
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1734,11 +1734,6 @@
"count": 2
}
},
"packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts": {
"import-x/no-relative-packages": {
"count": 1
}
},
"packages/transaction-pay-controller/src/utils/source-amounts.ts": {
"import-x/no-relative-packages": {
"count": 1
Expand Down
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add HyperLiquid source quote support for Relay strategy ([#8285](https://github.com/MetaMask/core/pull/8285))

### Changed

- Bump `@metamask/assets-controller` from `^3.1.0` to `^3.1.1` ([#8298](https://github.com/MetaMask/core/pull/8298))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ describe('TransactionPayController', () => {
).toBe(true);
});

it('updates isHyperliquidSource in state', () => {
const controller = createController();

controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => {
config.isHyperliquidSource = true;
});

expect(
controller.state.transactionData[TRANSACTION_ID_MOCK]
.isHyperliquidSource,
).toBe(true);
});

it('triggers source amounts and quotes update when only isPostQuote changes', () => {
const controller = createController();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,15 @@ export class TransactionPayController extends BaseController<
const config = {
isMaxAmount: transactionData.isMaxAmount,
isPostQuote: transactionData.isPostQuote,
isHyperliquidSource: transactionData.isHyperliquidSource,
refundTo: transactionData.refundTo,
};

callback(config);

transactionData.isMaxAmount = config.isMaxAmount;
transactionData.isPostQuote = config.isPostQuote;
transactionData.isHyperliquidSource = config.isHyperliquidSource;
transactionData.refundTo = config.refundTo;
});
}
Expand Down
7 changes: 6 additions & 1 deletion packages/transaction-pay-controller/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const ARBITRUM_USDC_ADDRESS =
export const POLYGON_USDCE_ADDRESS =
'0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex;

export const HYPERCORE_USDC_ADDRESS = '0x00000000000000000000000000000000';

export const HYPERCORE_USDC_DECIMALS = 8;
export const USDC_DECIMALS = 6;

export const STABLECOINS: Record<Hex, Hex[]> = {
// Mainnet
'0x1': [
Expand All @@ -29,7 +34,7 @@ export const STABLECOINS: Record<Hex, Hex[]> = {
'0xa219439258ca9da29e9cc4ce5596924745e12b93', // USDT
],
[CHAIN_ID_POLYGON]: [POLYGON_USDCE_ADDRESS.toLowerCase() as Hex],
[CHAIN_ID_HYPERCORE]: ['0x00000000000000000000000000000000'], // USDC
[CHAIN_ID_HYPERCORE]: [HYPERCORE_USDC_ADDRESS], // USDC
};

export enum TransactionPayStrategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getGasStationEligibility,
getGasStationCostInSourceTokenRaw,
} from './gas-station';
import type { RelayQuote } from './types';
import type { RelayQuote, RelayTransactionStep } from './types';
import { projectLogger } from '../../logger';
import type {
PayStrategyGetQuotesRequest,
Expand Down Expand Up @@ -307,7 +307,10 @@ async function getGasCostFromQuoteOrGasStation(
};
}

const firstStepData = quote.original.steps[0]?.items[0]?.data;
const firstTxStep = quote.original.steps.find(
(step): step is RelayTransactionStep => step.kind === 'transaction',
);
const firstStepData = firstTxStep?.items[0]?.data;

if (!firstStepData) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { Hex } from '@metamask/utils';
import { cloneDeep } from 'lodash';

import { getRelayQuotes } from './relay-quotes';
import type { RelayQuote } from './types';
import type { RelayQuote, RelayTransactionStep } from './types';
import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller';
import {
ARBITRUM_USDC_ADDRESS,
Expand Down Expand Up @@ -87,6 +87,31 @@ const QUOTE_REQUEST_MOCK: QuoteRequest = {
targetTokenAddress: '0x1234567890123456789012345678901234567890',
};

const STEP_MOCK: RelayTransactionStep = {
id: 'swap',
requestId: '0x1',
kind: 'transaction',
items: [
{
check: {
endpoint: '/test',
method: 'GET',
},
data: {
chainId: 1,
data: '0x123' as Hex,
from: FROM_MOCK,
gas: '21000',
maxFeePerGas: '1000000000',
maxPriorityFeePerGas: '2000000000',
to: '0x2' as Hex,
value: '300000',
},
status: 'complete',
},
],
};

const QUOTE_MOCK = {
details: {
currencyIn: {
Expand Down Expand Up @@ -117,32 +142,8 @@ const QUOTE_MOCK = {
gasLimits: [21000],
is7702: false,
},
steps: [
{
id: 'swap',
items: [
{
check: {
endpoint: '/test',
method: 'GET',
},
data: {
chainId: 1,
data: '0x123' as Hex,
from: FROM_MOCK,
gas: '21000',
maxFeePerGas: '1000000000',
maxPriorityFeePerGas: '2000000000',
to: '0x2' as Hex,
value: '300000',
},
status: 'complete',
},
],
kind: 'transaction',
},
],
} as RelayQuote;
steps: [STEP_MOCK],
} as RelayQuote & { steps: RelayTransactionStep[] };

const DELEGATION_RESULT_MOCK = {
authorizationList: [
Expand Down Expand Up @@ -897,13 +898,13 @@ describe('Relay Quotes Utils', () => {
...QUOTE_MOCK,
steps: [
{
...QUOTE_MOCK.steps[0],
...STEP_MOCK,
items: [
QUOTE_MOCK.steps[0].items[0],
STEP_MOCK.items[0],
{
...QUOTE_MOCK.steps[0].items[0],
...STEP_MOCK.items[0],
data: {
...QUOTE_MOCK.steps[0].items[0].data,
...STEP_MOCK.items[0].data,
gas: '30000',
},
},
Expand Down Expand Up @@ -956,13 +957,13 @@ describe('Relay Quotes Utils', () => {
...QUOTE_MOCK,
steps: [
{
...QUOTE_MOCK.steps[0],
...STEP_MOCK,
items: [
QUOTE_MOCK.steps[0].items[0],
STEP_MOCK.items[0],
{
...QUOTE_MOCK.steps[0].items[0],
...STEP_MOCK.items[0],
data: {
...QUOTE_MOCK.steps[0].items[0].data,
...STEP_MOCK.items[0].data,
gas: '30000',
},
},
Expand Down Expand Up @@ -1876,6 +1877,7 @@ describe('Relay Quotes Utils', () => {
},
},
],
kind: 'transaction',
} as never);

successfulFetchMock.mockResolvedValue({
Expand Down Expand Up @@ -2283,6 +2285,89 @@ describe('Relay Quotes Utils', () => {
});
});

describe('HyperLiquid source (isHyperliquidSource)', () => {
const HL_REQUEST: QuoteRequest = {
...QUOTE_REQUEST_MOCK,
isHyperliquidSource: true,
isPostQuote: true,
sourceChainId: CHAIN_ID_ARBITRUM,
sourceTokenAddress: ARBITRUM_USDC_ADDRESS,
sourceTokenAmount: '100000000',
};

it('overrides source chain and token to HyperCore', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [HL_REQUEST],
transaction: TRANSACTION_META_MOCK,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.originChainId).toBe(parseInt(CHAIN_ID_HYPERCORE, 16));
expect(body.originCurrency).toBe('0x00000000000000000000000000000000');
});

it('shifts source amount by 2 decimals (8→6)', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [HL_REQUEST],
transaction: TRANSACTION_META_MOCK,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.amount).toBe('10000000000');
});

it('zeroes source network fees (gasless)', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

const result = await getRelayQuotes({
messenger,
requests: [HL_REQUEST],
transaction: TRANSACTION_META_MOCK,
});

const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' };

expect(result[0].fees.sourceNetwork.estimate).toStrictEqual(zeroAmount);
expect(result[0].fees.sourceNetwork.max).toStrictEqual(zeroAmount);
});

it('uses Arbitrum USDC fiat rate for source', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [HL_REQUEST],
transaction: TRANSACTION_META_MOCK,
});

expect(getTokenFiatRateMock).toHaveBeenCalledWith(
expect.anything(),
ARBITRUM_USDC_ADDRESS,
CHAIN_ID_ARBITRUM,
);
});
});

it('includes target network fee in quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
Expand Down Expand Up @@ -2848,7 +2933,7 @@ describe('Relay Quotes Utils', () => {
quoteMock.steps[0].items = [
{
...quoteMock.steps[0].items[0],
data: {},
data: {} as RelayTransactionStep['items'][0]['data'],
},
];

Expand Down
Loading
Loading