Skip to content
Merged
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780))

### 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.2]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,17 @@ async function getSingleQuote(
try {
// For post-quote or max amount flows, use EXACT_INPUT - user specifies how much to send,
// and we show them how much they'll receive after fees.
// For regular flows with a target amount, use EXPECTED_OUTPUT.
// For regular flows with a target amount, use EXPECTED_OUTPUT, except
// HyperCore deposits, which need a guaranteed amount (see below).
// 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) &&
Expand Down Expand Up @@ -338,7 +345,7 @@ async function getSingleQuote(
: {}),
recipient: effectiveRequest.recipient ?? from,
slippageTolerance,
tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT',
tradeType: getTradeType(useExactInput, useExactOutput),
user: from,
};

Expand Down Expand Up @@ -605,6 +612,42 @@ async function processMoneyAccountPostQuote(
});
}

/**
* 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';
}

/**
* Normalizes requests for Relay.
*
Expand Down