Skip to content

Commit a30e120

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

12 files changed

Lines changed: 346 additions & 20 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: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TransactionType } from '@metamask/transaction-controller';
22

3+
import { createModuleLogger, projectLogger } from '../../logger';
34
import type {
45
PayStrategy,
56
PayStrategyCheckQuoteSupportRequest,
@@ -13,21 +14,30 @@ import { getAcrossDestination } from './across-actions';
1314
import { getAcrossQuotes } from './across-quotes';
1415
import { submitAcrossQuotes } from './across-submit';
1516
import { hasUnsupportedTransactionAuthorizationList } from './authorization-list';
16-
import { isSupportedAcrossPerpsDepositRequest } from './perps';
17+
import {
18+
isSupportedAcrossPerpsDepositRequest,
19+
isSupportedAcrossPerpsWithdrawRequest,
20+
} from './perps';
1721
import { isAcrossQuoteRequest } from './requests';
1822
import type { AcrossQuote } from './types';
1923

24+
const log = createModuleLogger(projectLogger, 'across-strategy');
25+
2026
export class AcrossStrategy implements PayStrategy<AcrossQuote> {
2127
supports(request: PayStrategyGetQuotesRequest): boolean {
2228
const config = getPayStrategiesConfig(request.messenger);
2329

2430
if (!config.across.enabled) {
31+
log('Across strategy not selected: disabled');
2532
return false;
2633
}
2734

2835
const actionableRequests = request.requests.filter(isAcrossQuoteRequest);
2936

3037
if (actionableRequests.length === 0) {
38+
log('Across strategy not selected: no actionable Across requests', {
39+
requestCount: request.requests.length,
40+
});
3141
return false;
3242
}
3343

@@ -40,16 +50,26 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
4050
);
4151

4252
if (!supportsPerpsDeposit) {
53+
log('Across strategy not selected: unsupported Perps deposit request', {
54+
actionableRequests,
55+
});
4356
return false;
4457
}
4558
} else {
4659
// Across doesn't support same-chain swaps (e.g. mUSD conversions).
4760
const hasSameChainRequest = actionableRequests.some(
4861
(singleRequest) =>
49-
singleRequest.sourceChainId === singleRequest.targetChainId,
62+
!isSupportedAcrossPerpsWithdrawRequest(
63+
singleRequest,
64+
request.transaction,
65+
) && singleRequest.sourceChainId === singleRequest.targetChainId,
5066
);
5167

5268
if (hasSameChainRequest) {
69+
log('Across strategy not selected: same-chain request unsupported', {
70+
actionableRequests,
71+
transactionType: request.transaction?.type,
72+
});
5373
return false;
5474
}
5575
}
@@ -60,18 +80,44 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
6080
actionableRequests,
6181
)
6282
) {
83+
log(
84+
'Across strategy not selected: unsupported transaction authorization list',
85+
{
86+
transactionId: request.transaction?.id,
87+
transactionType: request.transaction?.type,
88+
},
89+
);
6390
return false;
6491
}
6592

6693
return actionableRequests.every((singleRequest) => {
6794
if (singleRequest.isPostQuote) {
68-
return isPredictWithdrawTransaction(request.transaction);
95+
const isSupported =
96+
isPredictWithdrawTransaction(request.transaction) ||
97+
isSupportedAcrossPerpsWithdrawRequest(
98+
singleRequest,
99+
request.transaction,
100+
);
101+
102+
if (!isSupported) {
103+
log('Across strategy not selected: unsupported post-quote request', {
104+
singleRequest,
105+
transactionType: request.transaction?.type,
106+
});
107+
}
108+
109+
return isSupported;
69110
}
70111

71112
try {
72113
getAcrossDestination(request.transaction, singleRequest);
73114
return true;
74-
} catch {
115+
} catch (error) {
116+
log('Across strategy not selected: failed to resolve destination', {
117+
error,
118+
singleRequest,
119+
transactionType: request.transaction?.type,
120+
});
75121
return false;
76122
}
77123
});

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: 30 additions & 7 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,
@@ -334,9 +338,14 @@ async function requestAcrossApproval(
334338
signal,
335339
};
336340

341+
log('Calling Across approval endpoint', { body, url });
342+
337343
const response = await successfulFetch(url, options);
344+
const approval = (await response.json()) as AcrossSwapApprovalResponse;
338345

339-
return (await response.json()) as AcrossSwapApprovalResponse;
346+
log('Received Across approval response', approval);
347+
348+
return approval;
340349
}
341350

342351
async function normalizeQuote(
@@ -350,6 +359,7 @@ async function normalizeQuote(
350359
const { usdToFiatRate, sourceFiatRate, targetFiatRate } = getFiatRates(
351360
messenger,
352361
quote,
362+
request,
353363
);
354364

355365
const dustUsd = calculateDustUsd(quote, request, targetFiatRate);
@@ -434,15 +444,26 @@ async function normalizeQuote(
434444
function getFiatRates(
435445
messenger: TransactionPayControllerMessenger,
436446
quote: AcrossSwapApprovalResponse,
447+
request: QuoteRequest,
437448
): {
438449
sourceFiatRate: FiatRates;
439450
targetFiatRate: FiatRates;
440451
usdToFiatRate: BigNumber;
441452
} {
453+
// HyperLiquid source requests are normalized to HyperCore USDC-PERPS, which
454+
// may not have a local fiat-rate entry. Use Arbitrum USDC as the 1:1 price
455+
// anchor, matching the Relay HyperLiquid-source flow.
456+
const sourceChainId = request.isHyperliquidSource
457+
? CHAIN_ID_ARBITRUM
458+
: toHex(quote.inputToken.chainId);
459+
const sourceTokenAddress = request.isHyperliquidSource
460+
? ARBITRUM_USDC_ADDRESS
461+
: quote.inputToken.address;
462+
442463
const sourceFiatRate = getTokenFiatRate(
443464
messenger,
444-
quote.inputToken.address,
445-
toHex(quote.inputToken.chainId),
465+
sourceTokenAddress,
466+
sourceChainId,
446467
);
447468

448469
if (!sourceFiatRate) {
@@ -678,7 +699,9 @@ async function calculateSourceNetworkCost(
678699
totalGasLimit: gasEstimates.totalGasLimit,
679700
};
680701

681-
const finalResult = request.isPostQuote
702+
const shouldIncludeOriginalPostQuoteGas =
703+
request.isPostQuote === true && request.isHyperliquidSource !== true;
704+
const finalResult = shouldIncludeOriginalPostQuoteGas
682705
? combinePostQuoteGas(result, transaction, swapTx, messenger)
683706
: result;
684707

@@ -733,7 +756,7 @@ async function calculateSourceNetworkCost(
733756
},
734757
totalGasEstimate: finalResult.totalGasEstimate,
735758
totalItemCount: Math.max(
736-
orderedTransactions.length + (request.isPostQuote ? 1 : 0),
759+
orderedTransactions.length + (shouldIncludeOriginalPostQuoteGas ? 1 : 0),
737760
finalResult.gasLimits.length,
738761
),
739762
});

0 commit comments

Comments
 (0)