From 677ae2e3a7dd02b4118956ac7e982b76a3410707 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 30 Mar 2026 14:28:40 +0100 Subject: [PATCH 1/2] Reenable limited Across support for perps deposits --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../strategy/across/AcrossStrategy.test.ts | 28 ++++++- .../src/strategy/across/AcrossStrategy.ts | 10 ++- .../src/strategy/across/across-actions.ts | 8 ++ .../src/strategy/across/across-quotes.test.ts | 48 +++++++++++- .../src/strategy/across/across-quotes.ts | 6 +- .../src/strategy/across/perps.ts | 75 +++++++++++++++++++ .../src/utils/source-amounts.test.ts | 34 ++++++++- .../src/utils/source-amounts.ts | 43 +++++++---- 9 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/across/perps.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 477f6a94e9f..f3cf55b1ae3 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add route-based `confirmations_pay` strategy resolution ([#8282](https://github.com/MetaMask/core/pull/8282)) +### Fixed + +- Support for perps deposit for Across ([#8334](https://github.com/MetaMask/core/pull/8334)) + ## [19.0.0] ### Added 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 23e55f64a6f..ecf7a349e64 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -9,6 +9,7 @@ import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; import { AcrossStrategy } from './AcrossStrategy'; import type { AcrossQuote } from './types'; +import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../../constants'; import type { PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, @@ -91,7 +92,7 @@ describe('AcrossStrategy', () => { expect(strategy.supports(baseRequest)).toBe(false); }); - it('returns true for perps deposits when other constraints are met', () => { + it('returns true for supported perps direct deposits', () => { const strategy = new AcrossStrategy(); expect( strategy.supports({ @@ -100,10 +101,35 @@ describe('AcrossStrategy', () => { ...TRANSACTION_META_MOCK, type: TransactionType.perpsDeposit, } as TransactionMeta, + requests: [ + { + from: '0xabc' as Hex, + sourceBalanceRaw: '100', + sourceChainId: CHAIN_ID_ARBITRUM, + sourceTokenAddress: ARBITRUM_USDC_ADDRESS, + sourceTokenAmount: '100', + targetAmountMinimum: '100', + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }, + ], }), ).toBe(true); }); + it('returns false for unsupported perps deposits', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsDeposit, + } as TransactionMeta, + }), + ).toBe(false); + }); + it('returns false for perps across deposits', () => { 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 fa89045d73f..17ac4b1c419 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -2,6 +2,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; +import { isSupportedAcrossPerpsDepositRequest } from './perps'; import type { AcrossQuote } from './types'; import type { PayStrategy, @@ -19,8 +20,13 @@ export class AcrossStrategy implements PayStrategy { return false; } - if (request.transaction?.type === TransactionType.perpsAcrossDeposit) { - return false; + if (request.transaction?.type === TransactionType.perpsDeposit) { + return request.requests.every((singleRequest) => + isSupportedAcrossPerpsDepositRequest( + singleRequest, + request.transaction?.type, + ), + ); } // Across doesn't support same-chain swaps (e.g. mUSD conversions). diff --git a/packages/transaction-pay-controller/src/strategy/across/across-actions.ts b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts index 2fa7613e7f2..7f6dbde7a11 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-actions.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts @@ -3,6 +3,7 @@ import type { TransactionDescription } from '@ethersproject/abi'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { isSupportedAcrossPerpsDepositRequest } from './perps'; import type { AcrossAction, AcrossActionArg } from './types'; import type { QuoteRequest } from '../../types'; @@ -77,6 +78,13 @@ export function getAcrossDestination( transaction: TransactionMeta, request: QuoteRequest, ): AcrossDestination { + if (isSupportedAcrossPerpsDepositRequest(request, transaction.type)) { + return { + actions: [], + recipient: request.from, + }; + } + const { from } = request; const destinationCalls = getDestinationCalls(transaction); const swapRecipientTransferCallIndex = destinationCalls.findIndex((call) => 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 29a80dd984d..766001d26f0 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 @@ -5,10 +5,16 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getAcrossQuotes } from './across-quotes'; +import { ACROSS_HYPERCORE_USDC_PERPS_ADDRESS } from './perps'; import * as acrossTransactions from './transactions'; import type { AcrossSwapApprovalResponse } from './types'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; -import { TransactionPayStrategy } from '../../constants'; +import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, + CHAIN_ID_HYPERCORE, + TransactionPayStrategy, +} from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { QuoteRequest } from '../../types'; import { getGasBuffer, getSlippage } from '../../utils/feature-flags'; @@ -383,6 +389,46 @@ describe('Across Quotes', () => { expect(body.actions).toStrictEqual([]); }); + it('converts supported perps deposits to Across HyperCore direct deposits', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '1000000', + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsDeposit, + txParams: { + from: FROM_MOCK, + to: ARBITRUM_USDC_ADDRESS, + data: buildTransferData(TRANSFER_RECIPIENT, 1), + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('amount')).toBe('100000000'); + expect(params.get('destinationChainId')).toBe( + String(parseInt(CHAIN_ID_HYPERCORE, 16)), + ); + expect(params.get('outputToken')).toBe( + ACROSS_HYPERCORE_USDC_PERPS_ADDRESS, + ); + expect(params.get('recipient')).toBe(FROM_MOCK); + expect(getRequestBody().actions).toStrictEqual([]); + }); + 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 713d0a316f8..52fd12ab668 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -4,6 +4,7 @@ import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { getAcrossDestination } from './across-actions'; +import { normalizeAcrossRequest } from './perps'; import { getAcrossOrderedTransactions } from './transactions'; import type { AcrossAction, @@ -80,6 +81,7 @@ async function getSingleQuote( fullRequest: PayStrategyGetQuotesRequest, ): Promise> { const { messenger, transaction } = fullRequest; + const normalizedRequest = normalizeAcrossRequest(request, transaction.type); const { from, isMaxAmount, @@ -89,7 +91,7 @@ async function getSingleQuote( targetAmountMinimum, targetChainId, targetTokenAddress, - } = request; + } = normalizedRequest; const config = getPayStrategiesConfig(messenger); const slippageDecimal = getSlippage( @@ -123,7 +125,7 @@ async function getSingleQuote( }, }; - return await normalizeQuote(originalQuote, request, fullRequest); + return await normalizeQuote(originalQuote, normalizedRequest, fullRequest); } type AcrossApprovalRequest = { diff --git a/packages/transaction-pay-controller/src/strategy/across/perps.ts b/packages/transaction-pay-controller/src/strategy/across/perps.ts new file mode 100644 index 00000000000..0a24e7617f8 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/perps.ts @@ -0,0 +1,75 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, + CHAIN_ID_HYPERCORE, + HYPERCORE_USDC_DECIMALS, + USDC_DECIMALS, +} from '../../constants'; +import type { QuoteRequest } from '../../types'; + +export const ACROSS_HYPERCORE_USDC_PERPS_ADDRESS = + '0x2100000000000000000000000000000000000000' as Hex; + +/** + * Detect the quote-time parent transaction shape that Across can map to the + * new HyperCore USDC-PERPS direct-deposit route. + * + * The parent transaction remains `perpsDeposit` while quotes are being + * selected. `perpsAcrossDeposit` is only assigned later to the generated + * Across submission transaction(s). + * + * @param request - Transaction pay quote request. + * @param parentTransactionType - Parent transaction type before Across + * execution. + * @returns Whether the request matches the supported direct-deposit path. + */ +export function isSupportedAcrossPerpsDepositRequest( + request: Pick< + QuoteRequest, + 'isPostQuote' | 'targetChainId' | 'targetTokenAddress' + >, + parentTransactionType?: TransactionType, +): boolean { + return ( + parentTransactionType === TransactionType.perpsDeposit && + request.isPostQuote !== true && + request.targetChainId === CHAIN_ID_ARBITRUM && + request.targetTokenAddress.toLowerCase() === + ARBITRUM_USDC_ADDRESS.toLowerCase() + ); +} + +/** + * Convert the transaction-pay request into the Across route shape required for + * direct perps deposits. + * + * 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). + * + * @param request - Transaction pay quote request. + * @param parentTransactionType - Parent transaction type before Across + * execution. + * @returns Normalized request for Across quoting. + */ +export function normalizeAcrossRequest( + request: QuoteRequest, + parentTransactionType?: TransactionType, +): QuoteRequest { + if (!isSupportedAcrossPerpsDepositRequest(request, parentTransactionType)) { + return request; + } + + return { + ...request, + targetAmountMinimum: new BigNumber(request.targetAmountMinimum) + .shiftedBy(HYPERCORE_USDC_DECIMALS - USDC_DECIMALS) + .toFixed(0), + targetChainId: CHAIN_ID_HYPERCORE, + targetTokenAddress: ACROSS_HYPERCORE_USDC_PERPS_ADDRESS, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index a6d0841367f..69c5e24acea 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -1,3 +1,5 @@ +import { TransactionType } from '@metamask/transaction-controller'; + import { updateSourceAmounts } from './source-amounts'; import { getTokenFiatRate } from './token'; import { getTransaction } from './transaction'; @@ -53,7 +55,9 @@ describe('Source Amounts Utils', () => { getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); - getTransactionMock.mockReturnValue({ id: TRANSACTION_ID_MOCK } as never); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + } as never); }); describe('updateSourceAmounts', () => { @@ -117,6 +121,34 @@ describe('Source Amounts Utils', () => { expect(transactionData.sourceAmounts).toHaveLength(1); }); + it('does not return empty array if payment token matches but supported perps deposit and across strategy', () => { + getStrategyMock.mockReturnValue(TransactionPayStrategy.Across); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + type: TransactionType.perpsDeposit, + } as never); + + const transactionData: TransactionData = { + isLoading: false, + paymentToken: { + ...PAYMENT_TOKEN_MOCK, + address: ARBITRUM_USDC_ADDRESS, + chainId: CHAIN_ID_ARBITRUM, + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + address: ARBITRUM_USDC_ADDRESS, + chainId: CHAIN_ID_ARBITRUM, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toHaveLength(1); + }); + it('returns empty array if skipIfBalance and has balance', () => { const transactionData: TransactionData = { isLoading: false, diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 0169872fbaa..6ebfc39d39b 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -1,3 +1,4 @@ +import { TransactionType } from '@metamask/transaction-controller'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -152,8 +153,15 @@ function calculateSourceAmount( return undefined; } - const strategy = getStrategyType(transactionId, messenger); - const isAlwaysRequired = isQuoteAlwaysRequired(token, strategy); + const { parentTransactionType, strategy } = getStrategyContext( + transactionId, + messenger, + ); + const isAlwaysRequired = isQuoteAlwaysRequired( + token, + strategy, + parentTransactionType, + ); if (isSameToken(token, paymentToken) && !isAlwaysRequired) { log('Skipping token as same as payment token'); @@ -195,34 +203,43 @@ function calculateSourceAmount( * * @param token - Target token. * @param strategy - Payment strategy. + * @param parentTransactionType - Parent transaction type, if available. * @returns True if a quote is always required, false otherwise. */ function isQuoteAlwaysRequired( token: TransactionPayRequiredToken, strategy: TransactionPayStrategy, + parentTransactionType?: TransactionType, ): boolean { const isHyperliquidDeposit = token.chainId === CHAIN_ID_ARBITRUM && token.address.toLowerCase() === ARBITRUM_USDC_ADDRESS.toLowerCase(); - return strategy === TransactionPayStrategy.Relay && isHyperliquidDeposit; + return ( + isHyperliquidDeposit && + (strategy === TransactionPayStrategy.Relay || + (strategy === TransactionPayStrategy.Across && + parentTransactionType === TransactionType.perpsDeposit)) + ); } -/** - * Get the strategy type for a transaction. - * - * @param transactionId - ID of the transaction. - * @param messenger - Controller messenger. - * @returns Payment strategy type. - */ -function getStrategyType( +function getStrategyContext( transactionId: string, messenger: TransactionPayControllerMessenger, -): TransactionPayStrategy { +): { + parentTransactionType?: TransactionType; + strategy: TransactionPayStrategy; +} { const transaction = getTransaction( transactionId, messenger, ) as TransactionMeta; - return messenger.call('TransactionPayController:getStrategy', transaction); + return { + parentTransactionType: transaction.type, + strategy: messenger.call( + 'TransactionPayController:getStrategy', + transaction, + ), + }; } From efc279b45f751d60d2f1ca0a20d1686f7aede3de Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 30 Mar 2026 14:55:22 +0100 Subject: [PATCH 2/2] Update AcrossStrategy test for perpsAcrossDeposit behavior --- .../src/strategy/across/AcrossStrategy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ecf7a349e64..f47b84d3cda 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -130,7 +130,7 @@ describe('AcrossStrategy', () => { ).toBe(false); }); - it('returns false for perps across deposits', () => { + it('applies generic cross-chain handling to perps across deposits', () => { const strategy = new AcrossStrategy(); expect( strategy.supports({ @@ -140,7 +140,7 @@ describe('AcrossStrategy', () => { type: TransactionType.perpsAcrossDeposit, } as TransactionMeta, }), - ).toBe(false); + ).toBe(true); }); it('returns false for same-chain swaps', () => {