Skip to content

Commit ddcc878

Browse files
Add Across Perps withdraw support
1 parent 53ec63c commit ddcc878

12 files changed

Lines changed: 299 additions & 18 deletions

File tree

packages/transaction-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+
- Add `perpsAcrossWithdraw` to the `TransactionType` enum ([#8841](https://github.com/MetaMask/core/pull/8841))
13+
1014
### Changed
1115

1216
- Bump `@metamask/core-backend` from `^6.2.2` to `^6.3.0` ([#8813](https://github.com/MetaMask/core/pull/8813))

packages/transaction-controller/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,11 @@ export enum TransactionType {
814814
*/
815815
perpsAcrossDeposit = 'perpsAcrossDeposit',
816816

817+
/**
818+
* Withdraw funds for Across quote via Perps.
819+
*/
820+
perpsAcrossWithdraw = 'perpsAcrossWithdraw',
821+
817822
/**
818823
* Deposit funds to be available for trading via Perps.
819824
*/

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+
- Add Across quote and submit support for HyperLiquid-source Perps withdraw flows ([#8841](https://github.com/MetaMask/core/pull/8841))
13+
1014
### Changed
1115

1216
- Bump `@metamask/gas-fee-controller` from `^26.2.1` to `^26.2.2` ([#8834](https://github.com/MetaMask/core/pull/8834))

packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,33 @@ describe('AcrossStrategy', () => {
249249
).toBe(true);
250250
});
251251

252+
it('supports post-quote perps withdraw requests with HyperLiquid source', () => {
253+
const strategy = new AcrossStrategy();
254+
expect(
255+
strategy.supports({
256+
...baseRequest,
257+
transaction: {
258+
...TRANSACTION_META_MOCK,
259+
type: TransactionType.perpsWithdraw,
260+
} as TransactionMeta,
261+
requests: [
262+
{
263+
from: '0xabc' as Hex,
264+
isHyperliquidSource: true,
265+
isPostQuote: true,
266+
sourceBalanceRaw: '100',
267+
sourceChainId: CHAIN_ID_ARBITRUM,
268+
sourceTokenAddress: ARBITRUM_USDC_ADDRESS,
269+
sourceTokenAmount: '100',
270+
targetAmountMinimum: '0',
271+
targetChainId: CHAIN_ID_ARBITRUM,
272+
targetTokenAddress: ARBITRUM_USDC_ADDRESS,
273+
},
274+
],
275+
}),
276+
).toBe(true);
277+
});
278+
252279
it('does not support post-quote requests outside predict withdraw', () => {
253280
const strategy = new AcrossStrategy();
254281
expect(

packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { getAcrossDestination } from './across-actions';
1313
import { getAcrossQuotes } from './across-quotes';
1414
import { submitAcrossQuotes } from './across-submit';
1515
import { hasUnsupportedTransactionAuthorizationList } from './authorization-list';
16-
import { isSupportedAcrossPerpsDepositRequest } from './perps';
16+
import {
17+
isSupportedAcrossPerpsDepositRequest,
18+
isSupportedAcrossPerpsWithdrawRequest,
19+
} from './perps';
1720
import { isAcrossQuoteRequest } from './requests';
1821
import type { AcrossQuote } from './types';
1922

@@ -46,7 +49,10 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
4649
// Across doesn't support same-chain swaps (e.g. mUSD conversions).
4750
const hasSameChainRequest = actionableRequests.some(
4851
(singleRequest) =>
49-
singleRequest.sourceChainId === singleRequest.targetChainId,
52+
!isSupportedAcrossPerpsWithdrawRequest(
53+
singleRequest,
54+
request.transaction,
55+
) && singleRequest.sourceChainId === singleRequest.targetChainId,
5056
);
5157

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

6672
return actionableRequests.every((singleRequest) => {
6773
if (singleRequest.isPostQuote) {
68-
return isPredictWithdrawTransaction(request.transaction);
74+
return (
75+
isPredictWithdrawTransaction(request.transaction) ||
76+
isSupportedAcrossPerpsWithdrawRequest(
77+
singleRequest,
78+
request.transaction,
79+
)
80+
);
6981
}
7082

7183
try {

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,74 @@ describe('Across Quotes', () => {
15561556
expect(getRequestBody().actions).toStrictEqual([]);
15571557
});
15581558

1559+
it('converts post-quote perps withdraws from HyperLiquid source to Across HyperCore source quotes', async () => {
1560+
successfulFetchMock.mockResolvedValue({
1561+
json: async () => ({
1562+
...QUOTE_MOCK,
1563+
inputAmount: '10000000000',
1564+
inputToken: {
1565+
address: ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
1566+
chainId: parseInt(CHAIN_ID_HYPERCORE, 16),
1567+
decimals: 8,
1568+
symbol: 'USDC',
1569+
},
1570+
outputToken: {
1571+
address: ARBITRUM_USDC_ADDRESS,
1572+
chainId: parseInt(CHAIN_ID_ARBITRUM, 16),
1573+
decimals: 6,
1574+
symbol: 'USDC',
1575+
},
1576+
}),
1577+
} as Response);
1578+
1579+
const result = await getAcrossQuotes({
1580+
accountSupports7702: true,
1581+
messenger,
1582+
requests: [
1583+
{
1584+
...QUOTE_REQUEST_MOCK,
1585+
isHyperliquidSource: true,
1586+
isPostQuote: true,
1587+
sourceBalanceRaw: '100000000',
1588+
sourceChainId: CHAIN_ID_ARBITRUM,
1589+
sourceTokenAddress: ARBITRUM_USDC_ADDRESS,
1590+
sourceTokenAmount: '100000000',
1591+
targetAmountMinimum: '0',
1592+
targetChainId: CHAIN_ID_ARBITRUM,
1593+
targetTokenAddress: ARBITRUM_USDC_ADDRESS,
1594+
},
1595+
],
1596+
transaction: {
1597+
...TRANSACTION_META_MOCK,
1598+
type: TransactionType.perpsWithdraw,
1599+
} as TransactionMeta,
1600+
});
1601+
1602+
const [url] = successfulFetchMock.mock.calls[0];
1603+
const params = new URL(url as string).searchParams;
1604+
1605+
expect(params.get('amount')).toBe('10000000000');
1606+
expect(params.get('tradeType')).toBe('exactInput');
1607+
expect(params.get('originChainId')).toBe(
1608+
String(parseInt(CHAIN_ID_HYPERCORE, 16)),
1609+
);
1610+
expect(params.get('inputToken')).toBe(
1611+
ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
1612+
);
1613+
expect(params.get('recipient')).toBe(FROM_MOCK);
1614+
expect(getRequestBody().actions).toStrictEqual([]);
1615+
expect(result[0].request.sourceBalanceRaw).toBe('10000000000');
1616+
expect(result[0].request.sourceChainId).toBe(CHAIN_ID_HYPERCORE);
1617+
expect(result[0].request.sourceTokenAddress).toBe(
1618+
ACROSS_HYPERCORE_USDC_PERPS_ADDRESS,
1619+
);
1620+
expect(getTokenFiatRateMock).toHaveBeenCalledWith(
1621+
expect.anything(),
1622+
ARBITRUM_USDC_ADDRESS,
1623+
CHAIN_ID_ARBITRUM,
1624+
);
1625+
});
1626+
15591627
it('uses transfer recipient for token transfer transactions', async () => {
15601628
const transferData = buildTransferData(TRANSFER_RECIPIENT);
15611629

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type { Hex } from '@metamask/utils';
44
import { createModuleLogger } from '@metamask/utils';
55
import { BigNumber } from 'bignumber.js';
66

7-
import { TransactionPayStrategy } from '../../constants';
7+
import {
8+
ARBITRUM_USDC_ADDRESS,
9+
CHAIN_ID_ARBITRUM,
10+
TransactionPayStrategy,
11+
} from '../../constants';
812
import { projectLogger } from '../../logger';
913
import type {
1014
Amount,
@@ -98,7 +102,7 @@ async function getSingleQuote(
98102
fullRequest: PayStrategyGetQuotesRequest,
99103
): Promise<TransactionPayQuote<AcrossQuote>> {
100104
const { messenger, signal, transaction } = fullRequest;
101-
const normalizedRequest = normalizeAcrossRequest(request, transaction.type);
105+
const normalizedRequest = normalizeAcrossRequest(request, transaction);
102106
const {
103107
from,
104108
isMaxAmount,
@@ -350,6 +354,7 @@ async function normalizeQuote(
350354
const { usdToFiatRate, sourceFiatRate, targetFiatRate } = getFiatRates(
351355
messenger,
352356
quote,
357+
request,
353358
);
354359

355360
const dustUsd = calculateDustUsd(quote, request, targetFiatRate);
@@ -434,15 +439,26 @@ async function normalizeQuote(
434439
function getFiatRates(
435440
messenger: TransactionPayControllerMessenger,
436441
quote: AcrossSwapApprovalResponse,
442+
request: QuoteRequest,
437443
): {
438444
sourceFiatRate: FiatRates;
439445
targetFiatRate: FiatRates;
440446
usdToFiatRate: BigNumber;
441447
} {
448+
// HyperLiquid source requests are normalized to HyperCore USDC-PERPS, which
449+
// may not have a local fiat-rate entry. Use Arbitrum USDC as the 1:1 price
450+
// anchor, matching the Relay HyperLiquid-source flow.
451+
const sourceChainId = request.isHyperliquidSource
452+
? CHAIN_ID_ARBITRUM
453+
: toHex(quote.inputToken.chainId);
454+
const sourceTokenAddress = request.isHyperliquidSource
455+
? ARBITRUM_USDC_ADDRESS
456+
: quote.inputToken.address;
457+
442458
const sourceFiatRate = getTokenFiatRate(
443459
messenger,
444-
quote.inputToken.address,
445-
toHex(quote.inputToken.chainId),
460+
sourceTokenAddress,
461+
sourceChainId,
446462
);
447463

448464
if (!sourceFiatRate) {
@@ -678,7 +694,9 @@ async function calculateSourceNetworkCost(
678694
totalGasLimit: gasEstimates.totalGasLimit,
679695
};
680696

681-
const finalResult = request.isPostQuote
697+
const shouldIncludeOriginalPostQuoteGas =
698+
request.isPostQuote === true && request.isHyperliquidSource !== true;
699+
const finalResult = shouldIncludeOriginalPostQuoteGas
682700
? combinePostQuoteGas(result, transaction, swapTx, messenger)
683701
: result;
684702

@@ -733,7 +751,7 @@ async function calculateSourceNetworkCost(
733751
},
734752
totalGasEstimate: finalResult.totalGasEstimate,
735753
totalItemCount: Math.max(
736-
orderedTransactions.length + (request.isPostQuote ? 1 : 0),
754+
orderedTransactions.length + (shouldIncludeOriginalPostQuoteGas ? 1 : 0),
737755
finalResult.gasLimits.length,
738756
),
739757
});

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,56 @@ describe('Across Submit', () => {
713713
);
714714
});
715715

716+
it('does not prepend the original transaction and uses perps withdraw type for post-quote HyperLiquid source withdraws', async () => {
717+
const postQuote = {
718+
...QUOTE_MOCK,
719+
original: {
720+
...QUOTE_MOCK.original,
721+
metamask: {
722+
gasLimits: [{ estimate: 22000, max: 22000 }],
723+
is7702: false,
724+
},
725+
quote: {
726+
...QUOTE_MOCK.original.quote,
727+
approvalTxns: [],
728+
},
729+
},
730+
request: {
731+
...QUOTE_MOCK.request,
732+
isHyperliquidSource: true,
733+
isPostQuote: true,
734+
},
735+
} as TransactionPayQuote<AcrossQuote>;
736+
737+
await submitAcrossQuotes({
738+
messenger,
739+
quotes: [postQuote],
740+
transaction: {
741+
...TRANSACTION_META_MOCK,
742+
type: TransactionType.perpsWithdraw,
743+
txParams: {
744+
from: FROM_MOCK,
745+
to: '0x000000000000000000000000000000000000dEaD' as Hex,
746+
data: '0x12345678' as Hex,
747+
value: '0x1' as Hex,
748+
},
749+
} as TransactionMeta,
750+
isSmartTransaction: jest.fn(),
751+
});
752+
753+
expect(addTransactionBatchMock).not.toHaveBeenCalled();
754+
expect(addTransactionMock).toHaveBeenCalledWith(
755+
expect.objectContaining({
756+
data: QUOTE_MOCK.original.quote.swapTx.data,
757+
gas: toHex(22000),
758+
to: QUOTE_MOCK.original.quote.swapTx.to,
759+
}),
760+
expect.objectContaining({
761+
type: TransactionType.perpsAcrossWithdraw,
762+
}),
763+
);
764+
});
765+
716766
it('keeps Across gas limits aligned when post-quote original gas is absent', async () => {
717767
const postQuote = {
718768
...QUOTE_MOCK,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
collectTransactionIds,
2626
getTransaction,
2727
updateTransaction,
28+
isPerpsWithdrawTransaction,
2829
isPredictWithdrawTransaction,
2930
waitForTransactionConfirmed,
3031
} from '../../utils/transaction';
@@ -132,6 +133,7 @@ async function submitTransactions(
132133
});
133134
const shouldPrependOriginalTransaction =
134135
quote.request.isPostQuote === true &&
136+
quote.request.isHyperliquidSource !== true &&
135137
parentTransaction.txParams.to !== undefined;
136138
const hasPrependedOriginalGasLimit =
137139
shouldPrependOriginalTransaction &&
@@ -567,6 +569,10 @@ function hasOriginalTransactionGas(transaction: TransactionMeta): boolean {
567569
* @returns Across-specific transaction type for known flows, or the original type.
568570
*/
569571
function getAcrossDepositType(transaction: TransactionMeta): TransactionType {
572+
if (isPerpsWithdrawTransaction(transaction)) {
573+
return TransactionType.perpsAcrossWithdraw;
574+
}
575+
570576
if (isPredictWithdrawTransaction(transaction)) {
571577
return TransactionType.predictAcrossWithdraw;
572578
}

0 commit comments

Comments
 (0)