diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index abe28115cc..a8c46dd5da 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +### Fixed + +- Request `EXACT_OUTPUT` instead of `EXPECTED_OUTPUT` from Relay for HyperCore perps deposits, so the full deposit target is guaranteed to arrive ([#9751](https://github.com/MetaMask/core/pull/9751)) + - `EXPECTED_OUTPUT` only guarantees `target * (1 - slippage)` on the destination, so a deposit sized to the exact margin required could arrive short and the follow-on order would fail with insufficient margin. + ## [26.2.0] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 69ee629ae4..93a32cf364 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -3913,6 +3913,58 @@ describe('Relay Quotes Utils', () => { ); }); + // A HyperCore deposit funds an order that needs the whole target as margin. + // EXPECTED_OUTPUT only guarantees `target * (1 - slippage)`, which leaves the + // follow-on order short and it fails on insufficient margin. + it('requests an exact output for Hyperliquid deposits so the full margin is guaranteed', async () => { + const arbitrumToHyperliquidRequest: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [arbitrumToHyperliquidRequest], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsDepositAndOrder, + }, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_OUTPUT'); + }); + + it('still requests an expected output for non-Hyperliquid targets', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXPECTED_OUTPUT'); + }); + it('does not convert to Hyperliquid deposit when parent transaction is not a Perps deposit', async () => { const arbitrumUsdcRequest: QuoteRequest = { ...QUOTE_REQUEST_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index faf4047081..3cf47e3859 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -309,6 +309,12 @@ async function getSingleQuote( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const useExactInput = isMaxAmount || request.isPostQuote; + // HyperCore perps deposits fund an order that requires the full target as + // margin, so the delivered amount must be guaranteed rather than expected. + // EXPECTED_OUTPUT only guarantees `target * (1 - slippage)`, which leaves + // the follow-on order short and it fails on insufficient margin. + const useExactOutput = !useExactInput && isHypercoreDeposit(request); + const useExecute = supports7702 && isRelayExecuteEnabled(messenger) && @@ -338,7 +344,7 @@ async function getSingleQuote( : {}), recipient: effectiveRequest.recipient ?? from, slippageTolerance, - tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + tradeType: getTradeType(useExactInput, useExactOutput), user: from, }; @@ -613,6 +619,42 @@ async function processMoneyAccountPostQuote( * Hyperliquid-specific rewrites on transaction type. * @returns Normalized request. */ +/** + * Whether the quote deposits into HyperCore USDC. + * + * `normalizeRequest` remaps Arbitrum-USDC perps deposits to HyperCore before + * the quote is built, so the check is against the normalized target. + * + * @param request - Normalized quote request. + * @returns True when the target is HyperCore USDC. + */ +function isHypercoreDeposit(request: QuoteRequest): boolean { + return ( + !request.isHyperliquidSource && + request.targetChainId === CHAIN_ID_HYPERCORE && + request.targetTokenAddress.toLowerCase() === + HYPERCORE_USDC_ADDRESS.toLowerCase() + ); +} + +/** + * Resolve the Relay trade type for a quote. + * + * @param useExactInput - Whether the user specified the amount to send. + * @param useExactOutput - Whether the delivered amount must be guaranteed. + * @returns The Relay trade type. + */ +function getTradeType( + useExactInput: boolean | undefined, + useExactOutput: boolean, +): RelayQuoteRequest['tradeType'] { + if (useExactInput) { + return 'EXACT_INPUT'; + } + + return useExactOutput ? 'EXACT_OUTPUT' : 'EXPECTED_OUTPUT'; +} + function normalizeRequest( request: QuoteRequest, transaction: TransactionMeta,