From ddcc87822d86b09ddfca93bab4b85000484579ce Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 18 May 2026 13:50:06 +0100 Subject: [PATCH] Add Across Perps withdraw support --- packages/transaction-controller/CHANGELOG.md | 4 ++ packages/transaction-controller/src/types.ts | 5 ++ .../transaction-pay-controller/CHANGELOG.md | 4 ++ .../strategy/across/AcrossStrategy.test.ts | 27 ++++++++ .../src/strategy/across/AcrossStrategy.ts | 18 ++++- .../src/strategy/across/across-quotes.test.ts | 68 +++++++++++++++++++ .../src/strategy/across/across-quotes.ts | 30 ++++++-- .../src/strategy/across/across-submit.test.ts | 50 ++++++++++++++ .../src/strategy/across/across-submit.ts | 6 ++ .../src/strategy/across/perps.ts | 54 ++++++++++++--- .../src/utils/transaction.test.ts | 30 ++++++++ .../src/utils/transaction.ts | 21 ++++++ 12 files changed, 299 insertions(+), 18 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 948db6e75c..dee47dcf12 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -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)) diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 6ec3b783f3..c738b87109 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -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. */ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 3a66a07a7c..1392bd0eba 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -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)) diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index c63d7ba7a8..d393e6544c 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -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( diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index 0c942dd7d0..13e784d13f 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -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'; @@ -46,7 +49,10 @@ export class AcrossStrategy implements PayStrategy { // 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) { @@ -65,7 +71,13 @@ export class AcrossStrategy implements PayStrategy { return actionableRequests.every((singleRequest) => { if (singleRequest.isPostQuote) { - return isPredictWithdrawTransaction(request.transaction); + return ( + isPredictWithdrawTransaction(request.transaction) || + isSupportedAcrossPerpsWithdrawRequest( + singleRequest, + request.transaction, + ) + ); } try { diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index a21ae52c49..9e814d2f6f 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -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); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index bd5eec27cb..bfc105a52b 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -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, @@ -98,7 +102,7 @@ async function getSingleQuote( fullRequest: PayStrategyGetQuotesRequest, ): Promise> { const { messenger, signal, transaction } = fullRequest; - const normalizedRequest = normalizeAcrossRequest(request, transaction.type); + const normalizedRequest = normalizeAcrossRequest(request, transaction); const { from, isMaxAmount, @@ -350,6 +354,7 @@ async function normalizeQuote( const { usdToFiatRate, sourceFiatRate, targetFiatRate } = getFiatRates( messenger, quote, + request, ); const dustUsd = calculateDustUsd(quote, request, targetFiatRate); @@ -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) { @@ -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; @@ -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, ), }); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index dd453928c1..bbd0eece14 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -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; + + 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, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts index 3c52f2395d..b0ecc893a8 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -25,6 +25,7 @@ import { collectTransactionIds, getTransaction, updateTransaction, + isPerpsWithdrawTransaction, isPredictWithdrawTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; @@ -132,6 +133,7 @@ async function submitTransactions( }); const shouldPrependOriginalTransaction = quote.request.isPostQuote === true && + quote.request.isHyperliquidSource !== true && parentTransaction.txParams.to !== undefined; const hasPrependedOriginalGasLimit = shouldPrependOriginalTransaction && @@ -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; } diff --git a/packages/transaction-pay-controller/src/strategy/across/perps.ts b/packages/transaction-pay-controller/src/strategy/across/perps.ts index 0a24e7617f..e75d35f57a 100644 --- a/packages/transaction-pay-controller/src/strategy/across/perps.ts +++ b/packages/transaction-pay-controller/src/strategy/across/perps.ts @@ -1,4 +1,5 @@ import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -10,6 +11,7 @@ import { USDC_DECIMALS, } from '../../constants'; import type { QuoteRequest } from '../../types'; +import { isPerpsWithdrawTransaction } from '../../utils/transaction'; export const ACROSS_HYPERCORE_USDC_PERPS_ADDRESS = '0x2100000000000000000000000000000000000000' as Hex; @@ -43,33 +45,67 @@ export function isSupportedAcrossPerpsDepositRequest( ); } +/** + * Detect the quote-time parent transaction shape that Across can map to a + * HyperCore USDC-PERPS source withdrawal. + * + * @param request - Transaction pay quote request. + * @param parentTransaction - Parent transaction before Across execution. + * @returns Whether the request matches the supported withdraw path. + */ +export function isSupportedAcrossPerpsWithdrawRequest( + request: Pick, + parentTransaction: TransactionMeta, +): boolean { + return ( + request.isHyperliquidSource === true && + request.isPostQuote === true && + isPerpsWithdrawTransaction(parentTransaction) + ); +} + /** * Convert the transaction-pay request into the Across route shape required for - * direct perps deposits. + * direct perps deposits and withdraws. * * Transaction pay starts from the required on-chain asset identity * (Arbitrum USDC, 6 decimals), while Across now expects the HyperCore - * USDC-PERPS destination token (8 decimals). + * USDC-PERPS token (8 decimals). * * @param request - Transaction pay quote request. - * @param parentTransactionType - Parent transaction type before Across - * execution. + * @param parentTransaction - Parent transaction before Across execution. * @returns Normalized request for Across quoting. */ export function normalizeAcrossRequest( request: QuoteRequest, - parentTransactionType?: TransactionType, + parentTransaction: TransactionMeta, ): QuoteRequest { - if (!isSupportedAcrossPerpsDepositRequest(request, parentTransactionType)) { + if (isSupportedAcrossPerpsWithdrawRequest(request, parentTransaction)) { + return { + ...request, + sourceBalanceRaw: shiftUsdcAmountToHyperCore(request.sourceBalanceRaw), + sourceChainId: CHAIN_ID_HYPERCORE, + sourceTokenAddress: ACROSS_HYPERCORE_USDC_PERPS_ADDRESS, + sourceTokenAmount: shiftUsdcAmountToHyperCore(request.sourceTokenAmount), + }; + } + + if (!isSupportedAcrossPerpsDepositRequest(request, parentTransaction.type)) { return request; } return { ...request, - targetAmountMinimum: new BigNumber(request.targetAmountMinimum) - .shiftedBy(HYPERCORE_USDC_DECIMALS - USDC_DECIMALS) - .toFixed(0), + targetAmountMinimum: shiftUsdcAmountToHyperCore( + request.targetAmountMinimum, + ), targetChainId: CHAIN_ID_HYPERCORE, targetTokenAddress: ACROSS_HYPERCORE_USDC_PERPS_ADDRESS, }; } + +function shiftUsdcAmountToHyperCore(amount: string): string { + return new BigNumber(amount) + .shiftedBy(HYPERCORE_USDC_DECIMALS - USDC_DECIMALS) + .toFixed(0); +} diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index a2328da53f..828c5e0abd 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -24,6 +24,7 @@ import { collectTransactionIds, getTransaction, getTransferredAmountFromTxHash, + isPerpsWithdrawTransaction, isPredictWithdrawTransaction, subscribeAssetChanges, subscribeTransactionChanges, @@ -660,6 +661,35 @@ describe('Transaction Utils', () => { expect(isPredictWithdrawTransaction(transaction)).toBe(false); }); }); + + describe('isPerpsWithdrawTransaction', () => { + it('returns true when the transaction type is perpsWithdraw', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(true); + }); + + it('returns true when a nested transaction has type perpsWithdraw', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.perpsWithdraw }], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(true); + }); + + it('returns false when nested transactions have different types', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.simpleSend }], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(false); + }); + }); }); const TX_HASH_MOCK = '0xabc123'; diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 97e772e666..427925ba0f 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -327,6 +327,27 @@ export function isPredictWithdrawTransaction( ); } +/** + * Check whether a transaction is a Perps withdrawal. + * + * Returns `true` when the transaction's own type is `perpsWithdraw`, or when + * any of its nested transactions has that type. + * + * @param transaction - Transaction metadata. + * @returns `true` when the transaction is a Perps withdrawal. + */ +export function isPerpsWithdrawTransaction( + transaction: TransactionMeta, +): boolean { + return ( + transaction.type === TransactionType.perpsWithdraw || + (transaction.nestedTransactions?.some( + (nt) => nt.type === TransactionType.perpsWithdraw, + ) ?? + false) + ); +} + /** * Handle a transaction change by updating its associated data. *