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
4 changes: 4 additions & 0 deletions packages/transaction-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 `perpsAcrossWithdraw` to the `TransactionType` enum ([#8841](https://github.com/MetaMask/core/pull/8841))

### Changed

- Bump `@metamask/core-backend` from `^6.2.2` to `^6.3.0` ([#8813](https://github.com/MetaMask/core/pull/8813))
Expand Down
5 changes: 5 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,11 @@ export enum TransactionType {
*/
perpsAcrossDeposit = 'perpsAcrossDeposit',

/**
* Withdraw funds for Across quote via Perps.
*/
perpsAcrossWithdraw = 'perpsAcrossWithdraw',

/**
* Deposit funds to be available for trading via Perps.
*/
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 Across quote and submit support for HyperLiquid-source Perps withdraw flows ([#8841](https://github.com/MetaMask/core/pull/8841))

### Changed

- Bump `@metamask/gas-fee-controller` from `^26.2.1` to `^26.2.2` ([#8834](https://github.com/MetaMask/core/pull/8834))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,33 @@ describe('AcrossStrategy', () => {
).toBe(true);
});

it('supports post-quote perps withdraw requests with HyperLiquid source', () => {
const strategy = new AcrossStrategy();
expect(
strategy.supports({
...baseRequest,
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.perpsWithdraw,
} as TransactionMeta,
requests: [
{
from: '0xabc' as Hex,
isHyperliquidSource: true,
isPostQuote: true,
sourceBalanceRaw: '100',
sourceChainId: CHAIN_ID_ARBITRUM,
sourceTokenAddress: ARBITRUM_USDC_ADDRESS,
sourceTokenAmount: '100',
targetAmountMinimum: '0',
targetChainId: CHAIN_ID_ARBITRUM,
targetTokenAddress: ARBITRUM_USDC_ADDRESS,
},
],
}),
).toBe(true);
});

it('does not support post-quote requests outside predict withdraw', () => {
const strategy = new AcrossStrategy();
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import { getAcrossDestination } from './across-actions';
import { getAcrossQuotes } from './across-quotes';
import { submitAcrossQuotes } from './across-submit';
import { hasUnsupportedTransactionAuthorizationList } from './authorization-list';
import { isSupportedAcrossPerpsDepositRequest } from './perps';
import {
isSupportedAcrossPerpsDepositRequest,
isSupportedAcrossPerpsWithdrawRequest,
} from './perps';
import { isAcrossQuoteRequest } from './requests';
import type { AcrossQuote } from './types';

Expand Down Expand Up @@ -46,7 +49,10 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
// Across doesn't support same-chain swaps (e.g. mUSD conversions).
const hasSameChainRequest = actionableRequests.some(
(singleRequest) =>
singleRequest.sourceChainId === singleRequest.targetChainId,
!isSupportedAcrossPerpsWithdrawRequest(
singleRequest,
request.transaction,
) && singleRequest.sourceChainId === singleRequest.targetChainId,
);

if (hasSameChainRequest) {
Expand All @@ -65,7 +71,13 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {

return actionableRequests.every((singleRequest) => {
if (singleRequest.isPostQuote) {
return isPredictWithdrawTransaction(request.transaction);
return (
isPredictWithdrawTransaction(request.transaction) ||
isSupportedAcrossPerpsWithdrawRequest(
singleRequest,
request.transaction,
)
);
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,74 @@ describe('Across Quotes', () => {
expect(getRequestBody().actions).toStrictEqual([]);
});

it('converts post-quote perps withdraws from HyperLiquid source to Across HyperCore source quotes', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
inputAmount: '10000000000',
inputToken: {
address: ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
chainId: parseInt(CHAIN_ID_HYPERCORE, 16),
decimals: 8,
symbol: 'USDC',
},
outputToken: {
address: ARBITRUM_USDC_ADDRESS,
chainId: parseInt(CHAIN_ID_ARBITRUM, 16),
decimals: 6,
symbol: 'USDC',
},
}),
} as Response);

const result = await getAcrossQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isHyperliquidSource: true,
isPostQuote: true,
sourceBalanceRaw: '100000000',
sourceChainId: CHAIN_ID_ARBITRUM,
sourceTokenAddress: ARBITRUM_USDC_ADDRESS,
sourceTokenAmount: '100000000',
targetAmountMinimum: '0',
targetChainId: CHAIN_ID_ARBITRUM,
targetTokenAddress: ARBITRUM_USDC_ADDRESS,
},
],
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.perpsWithdraw,
} as TransactionMeta,
});

const [url] = successfulFetchMock.mock.calls[0];
const params = new URL(url as string).searchParams;

expect(params.get('amount')).toBe('10000000000');
expect(params.get('tradeType')).toBe('exactInput');
expect(params.get('originChainId')).toBe(
String(parseInt(CHAIN_ID_HYPERCORE, 16)),
);
expect(params.get('inputToken')).toBe(
ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
);
expect(params.get('recipient')).toBe(FROM_MOCK);
expect(getRequestBody().actions).toStrictEqual([]);
expect(result[0].request.sourceBalanceRaw).toBe('10000000000');
expect(result[0].request.sourceChainId).toBe(CHAIN_ID_HYPERCORE);
expect(result[0].request.sourceTokenAddress).toBe(
ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
);
expect(getTokenFiatRateMock).toHaveBeenCalledWith(
expect.anything(),
ARBITRUM_USDC_ADDRESS,
CHAIN_ID_ARBITRUM,
);
});

it('uses transfer recipient for token transfer transactions', async () => {
const transferData = buildTransferData(TRANSFER_RECIPIENT);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { Hex } from '@metamask/utils';
import { createModuleLogger } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

import { TransactionPayStrategy } from '../../constants';
import {
ARBITRUM_USDC_ADDRESS,
CHAIN_ID_ARBITRUM,
TransactionPayStrategy,
} from '../../constants';
import { projectLogger } from '../../logger';
import type {
Amount,
Expand Down Expand Up @@ -98,7 +102,7 @@ async function getSingleQuote(
fullRequest: PayStrategyGetQuotesRequest,
): Promise<TransactionPayQuote<AcrossQuote>> {
const { messenger, signal, transaction } = fullRequest;
const normalizedRequest = normalizeAcrossRequest(request, transaction.type);
const normalizedRequest = normalizeAcrossRequest(request, transaction);
const {
from,
isMaxAmount,
Expand Down Expand Up @@ -350,6 +354,7 @@ async function normalizeQuote(
const { usdToFiatRate, sourceFiatRate, targetFiatRate } = getFiatRates(
messenger,
quote,
request,
);

const dustUsd = calculateDustUsd(quote, request, targetFiatRate);
Expand Down Expand Up @@ -434,15 +439,26 @@ async function normalizeQuote(
function getFiatRates(
messenger: TransactionPayControllerMessenger,
quote: AcrossSwapApprovalResponse,
request: QuoteRequest,
): {
sourceFiatRate: FiatRates;
targetFiatRate: FiatRates;
usdToFiatRate: BigNumber;
} {
// HyperLiquid source requests are normalized to HyperCore USDC-PERPS, which
// may not have a local fiat-rate entry. Use Arbitrum USDC as the 1:1 price
// anchor, matching the Relay HyperLiquid-source flow.
const sourceChainId = request.isHyperliquidSource
? CHAIN_ID_ARBITRUM
: toHex(quote.inputToken.chainId);
const sourceTokenAddress = request.isHyperliquidSource
? ARBITRUM_USDC_ADDRESS
: quote.inputToken.address;

const sourceFiatRate = getTokenFiatRate(
messenger,
quote.inputToken.address,
toHex(quote.inputToken.chainId),
sourceTokenAddress,
sourceChainId,
);

if (!sourceFiatRate) {
Expand Down Expand Up @@ -678,7 +694,9 @@ async function calculateSourceNetworkCost(
totalGasLimit: gasEstimates.totalGasLimit,
};

const finalResult = request.isPostQuote
const shouldIncludeOriginalPostQuoteGas =
request.isPostQuote === true && request.isHyperliquidSource !== true;
const finalResult = shouldIncludeOriginalPostQuoteGas
? combinePostQuoteGas(result, transaction, swapTx, messenger)
: result;

Expand Down Expand Up @@ -733,7 +751,7 @@ async function calculateSourceNetworkCost(
},
totalGasEstimate: finalResult.totalGasEstimate,
totalItemCount: Math.max(
orderedTransactions.length + (request.isPostQuote ? 1 : 0),
orderedTransactions.length + (shouldIncludeOriginalPostQuoteGas ? 1 : 0),
finalResult.gasLimits.length,
),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,56 @@ describe('Across Submit', () => {
);
});

it('does not prepend the original transaction and uses perps withdraw type for post-quote HyperLiquid source withdraws', async () => {
const postQuote = {
...QUOTE_MOCK,
original: {
...QUOTE_MOCK.original,
metamask: {
gasLimits: [{ estimate: 22000, max: 22000 }],
is7702: false,
},
quote: {
...QUOTE_MOCK.original.quote,
approvalTxns: [],
},
},
request: {
...QUOTE_MOCK.request,
isHyperliquidSource: true,
isPostQuote: true,
},
} as TransactionPayQuote<AcrossQuote>;

await submitAcrossQuotes({
messenger,
quotes: [postQuote],
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.perpsWithdraw,
txParams: {
from: FROM_MOCK,
to: '0x000000000000000000000000000000000000dEaD' as Hex,
data: '0x12345678' as Hex,
value: '0x1' as Hex,
},
} as TransactionMeta,
isSmartTransaction: jest.fn(),
});

expect(addTransactionBatchMock).not.toHaveBeenCalled();
expect(addTransactionMock).toHaveBeenCalledWith(
expect.objectContaining({
data: QUOTE_MOCK.original.quote.swapTx.data,
gas: toHex(22000),
to: QUOTE_MOCK.original.quote.swapTx.to,
}),
expect.objectContaining({
type: TransactionType.perpsAcrossWithdraw,
}),
);
});

it('keeps Across gas limits aligned when post-quote original gas is absent', async () => {
const postQuote = {
...QUOTE_MOCK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
collectTransactionIds,
getTransaction,
updateTransaction,
isPerpsWithdrawTransaction,
isPredictWithdrawTransaction,
waitForTransactionConfirmed,
} from '../../utils/transaction';
Expand Down Expand Up @@ -132,6 +133,7 @@ async function submitTransactions(
});
const shouldPrependOriginalTransaction =
quote.request.isPostQuote === true &&
quote.request.isHyperliquidSource !== true &&
parentTransaction.txParams.to !== undefined;
const hasPrependedOriginalGasLimit =
shouldPrependOriginalTransaction &&
Expand Down Expand Up @@ -567,6 +569,10 @@ function hasOriginalTransactionGas(transaction: TransactionMeta): boolean {
* @returns Across-specific transaction type for known flows, or the original type.
*/
function getAcrossDepositType(transaction: TransactionMeta): TransactionType {
if (isPerpsWithdrawTransaction(transaction)) {
return TransactionType.perpsAcrossWithdraw;
}

if (isPredictWithdrawTransaction(transaction)) {
return TransactionType.predictAcrossWithdraw;
}
Expand Down
Loading
Loading