From 145dde4ef64656428125634a778ca11eb57a4ad8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 22:41:11 +0800 Subject: [PATCH 1/5] fix(transaction-pay): top up HyperCore deposits for activation fee Unactivated trade-with-token deposits sized to exact margin left users short after HyperLiquid's ~$1 first-credit fee, so the auto-placed order failed. Co-authored-by: Cursor --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../relay/hyperliquid-activation.test.ts | 108 ++++++++++++++++++ .../strategy/relay/hyperliquid-activation.ts | 97 ++++++++++++++-- .../src/utils/feature-flags.ts | 5 + 4 files changed, 199 insertions(+), 12 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a5209b89a14..de221b396dd 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Increase HyperCore perps deposit `targetAmountMinimum` by the one-time HyperLiquid activation fee (~$1 USDC) for unactivated accounts so trade-with-token deposits still leave the intended trading margin after the fee is deducted ([#TAT-3400](https://consensyssoftware.atlassian.net/browse/TAT-3400)) - Fix Relay quote validation ([#9723](https://github.com/MetaMask/core/pull/9723)) - Keep the quote when validation fails with reason `insufficient-source-balance`, while still surfacing `quoteError`; all other validation-failure reasons continue to remove the quote. - Exclude a zero `gas` value from the simulated transaction. diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts index a00eb8f115b..20bab4a477c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts @@ -389,5 +389,113 @@ describe('HyperLiquid Activation', () => { TransactionType.batch, ); }); + + describe('HyperLiquid deposit target (TAT-3400)', () => { + // Normalized perps deposit: Arbitrum USDC remapped to HyperCore USDC. + // $20.00 margin at 8 decimals = 2000000000. + const DEPOSIT_TARGET_AMOUNT_MOCK = '2000000000'; + const DEPOSIT_TARGET_WITH_FEE_MOCK = '2100000000'; + + const HYPERLIQUID_DEPOSIT_REQUEST_MOCK: QuoteRequest = { + from: ADDRESS_MOCK, + isHyperliquidSource: false, + sourceBalanceRaw: '50000000', + sourceChainId: '0x1', + sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: DEPOSIT_TARGET_AMOUNT_MOCK, + targetChainId: '0x539', + targetTokenAddress: '0x00000000000000000000000000000000' as Hex, + }; + + const PERPS_DEPOSIT_TRANSACTION_MOCK = { + type: TransactionType.perpsDepositAndOrder, + } as TransactionMeta; + + it('increases targetAmountMinimum by the activation fee for an unactivated deposit', async () => { + // Feature flag may be off (withdrawal default); deposits still apply. + getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [], + } as never); + + const result = await applyHyperliquidActivationFee( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK, + MESSENGER_MOCK, + PERPS_DEPOSIT_TRANSACTION_MOCK, + ); + + expect(result.targetAmountMinimum).toBe(DEPOSIT_TARGET_WITH_FEE_MOCK); + expect(result.hyperliquidActivationFeeUsd).toBe('1'); + expect(result.sourceTokenAmount).toBe( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK.sourceTokenAmount, + ); + }); + + it('does not change the target when the deposit account is already activated', async () => { + getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [outboundSend()], + } as never); + + const result = await applyHyperliquidActivationFee( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK, + MESSENGER_MOCK, + PERPS_DEPOSIT_TRANSACTION_MOCK, + ); + + expect(result).toStrictEqual(HYPERLIQUID_DEPOSIT_REQUEST_MOCK); + }); + + it('does not change a non-perps quote targeting HyperCore', async () => { + getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); + + const result = await applyHyperliquidActivationFee( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK, + MESSENGER_MOCK, + { type: TransactionType.simpleSend } as TransactionMeta, + ); + + expect(result).toStrictEqual(HYPERLIQUID_DEPOSIT_REQUEST_MOCK); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('increases the target for perpsDeposit as well as perpsDepositAndOrder', async () => { + getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [], + } as never); + + const result = await applyHyperliquidActivationFee( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK, + MESSENGER_MOCK, + { type: TransactionType.perpsDeposit } as TransactionMeta, + ); + + expect(result.targetAmountMinimum).toBe(DEPOSIT_TARGET_WITH_FEE_MOCK); + expect(result.hyperliquidActivationFeeUsd).toBe('1'); + }); + + it('uses a custom fee amount from the feature flag for deposits', async () => { + getConfigMock.mockReturnValue({ enabled: false, amountUsd: 2 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [], + } as never); + + const result = await applyHyperliquidActivationFee( + HYPERLIQUID_DEPOSIT_REQUEST_MOCK, + MESSENGER_MOCK, + PERPS_DEPOSIT_TRANSACTION_MOCK, + ); + + // $20 + $2 = $22 = 2200000000 (8 decimals). + expect(result.targetAmountMinimum).toBe('2200000000'); + expect(result.hyperliquidActivationFeeUsd).toBe('2'); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts index 1a6c8360e9b..619eda1911d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts @@ -4,7 +4,12 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { HYPERCORE_USDC_DECIMALS } from '../../constants.js'; +import { + CHAIN_ID_HYPERCORE, + HYPERCORE_USDC_ADDRESS, + HYPERCORE_USDC_DECIMALS, + PERPS_DEPOSIT_TYPES, +} from '../../constants.js'; import { projectLogger } from '../../logger.js'; import type { QuoteRequest, @@ -199,16 +204,60 @@ function getEffectiveTransactionType( } /** - * Reserve the one-time HyperLiquid activation fee for an unactivated HyperCore - * source account. + * Whether this quote is a perps deposit whose target is HyperCore USDC. * - * Reduces the amount sent to the provider so HyperLiquid retains enough balance - * for the activation fee on the `sendAsset` step, and records the reserved fee - * (USD) so it can be added to the provider fee — keeping the displayed - * withdrawal amount unchanged. + * `normalizeRequest` remaps Arbitrum-USDC perps deposits to HyperCore before + * this runs, so the check is against the normalized target. * - * No-op for non-HyperLiquid sources, when the feature flag is disabled, when - * the account is already activated, or when the amount is too small to reserve. + * @param request - Normalized quote request. + * @param transaction - Parent transaction metadata. + * @returns True when the quote deposits into HyperCore for a perps deposit type. + */ +function isHyperliquidDepositTarget( + request: QuoteRequest, + transaction?: TransactionMeta, +): boolean { + const effectiveType = getEffectiveTransactionType(transaction); + + if ( + !effectiveType || + !PERPS_DEPOSIT_TYPES.includes(effectiveType as TransactionType) + ) { + return false; + } + + if (request.isHyperliquidSource) { + return false; + } + + return ( + request.targetChainId === CHAIN_ID_HYPERCORE && + request.targetTokenAddress.toLowerCase() === + HYPERCORE_USDC_ADDRESS.toLowerCase() && + new BigNumber(request.targetAmountMinimum).gt(0) + ); +} + +/** + * Reserve or top up the one-time HyperLiquid activation fee for an unactivated + * HyperCore account. + * + * **Withdrawals** (`isHyperliquidSource`): reduces the amount sent to the + * provider so HyperLiquid retains enough balance for the activation fee on the + * `sendAsset` step, and records the reserved fee (USD) so it can be added to + * the provider fee — keeping the displayed withdrawal amount unchanged. Gated + * by the remote activation-fee feature flag (default off). + * + * **Deposits** (perps deposit types targeting HyperCore USDC): increases + * `targetAmountMinimum` by the activation fee so the first inbound credit still + * leaves the intended trading margin after HyperLiquid deducts ~$1 USDC. Trade + * with token sizes the deposit at exact `marginRequired`; without this bump the + * auto-placed order fails with insufficient margin for unactivated accounts. + * Always applied for unactivated deposit targets (correctness); uses the + * configured fee amount (default $1). + * + * No-op when the withdrawal feature flag is disabled, when the account is + * already activated, or when a withdrawal amount is too small to reserve. * * @param request - Normalized quote request. * @param messenger - Controller messenger. @@ -223,16 +272,21 @@ export async function applyHyperliquidActivationFee( transaction?: TransactionMeta, signal?: AbortSignal, ): Promise { - if (!request.isHyperliquidSource) { + const transactionType = getEffectiveTransactionType(transaction); + const isDeposit = isHyperliquidDepositTarget(request, transaction); + + if (!request.isHyperliquidSource && !isDeposit) { return request; } const { enabled, amountUsd } = getHyperliquidActivationFeeConfig( messenger, - getEffectiveTransactionType(transaction), + transactionType, ); - if (!enabled) { + // Withdrawals remain behind the remote flag (historical default: off). + // Deposits always top up when unactivated — under-funding by $1 is a bug. + if (request.isHyperliquidSource && !enabled) { return request; } @@ -243,6 +297,25 @@ export async function applyHyperliquidActivationFee( } const feeRaw = new BigNumber(amountUsd).shiftedBy(HYPERCORE_USDC_DECIMALS); + + if (isDeposit) { + const increasedTarget = new BigNumber(request.targetAmountMinimum).plus( + feeRaw, + ); + + log('Increasing HyperLiquid deposit target for activation fee', { + amountUsd, + originalTargetAmountMinimum: request.targetAmountMinimum, + increasedTargetAmountMinimum: increasedTarget.toFixed(0), + }); + + return { + ...request, + targetAmountMinimum: increasedTarget.toFixed(0), + hyperliquidActivationFeeUsd: String(amountUsd), + }; + } + const reducedAmount = new BigNumber(request.sourceTokenAmount).minus(feeRaw); // Can't reserve more than the balance — let the original amount through so diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 71a530dc464..5dc1ccfea1f 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1067,6 +1067,11 @@ export function getFiatOrderPollTimeoutMs( * `sendAsset` step retains enough balance) and surfaced as part of the * provider fee. Defaults to disabled with a $1 fee. * + * Perps deposits targeting HyperCore always top up `targetAmountMinimum` by + * this fee amount for unactivated accounts (independent of `enabled`), so + * trade-with-token still leaves the intended margin after HyperLiquid deducts + * the activation fee from the first inbound credit. + * * @param messenger - Controller messenger. * @param transactionType - Parent transaction type used to resolve overrides. * @returns The activation-fee configuration. From c7b43344a95f7ac8d59081da3ec141d046544483 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 23:04:31 +0800 Subject: [PATCH 2/5] fix: address CI feedback Link the changelog entry to PR #9751 and cover the HyperLiquid-source deposit-target branch so package coverage thresholds pass. Co-authored-by: Cursor --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../relay/hyperliquid-activation.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index de221b396dd..a5bed5ff55b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Increase HyperCore perps deposit `targetAmountMinimum` by the one-time HyperLiquid activation fee (~$1 USDC) for unactivated accounts so trade-with-token deposits still leave the intended trading margin after the fee is deducted ([#TAT-3400](https://consensyssoftware.atlassian.net/browse/TAT-3400)) +- Increase HyperCore perps deposit `targetAmountMinimum` by the one-time HyperLiquid activation fee (~$1 USDC) for unactivated accounts so trade-with-token deposits still leave the intended trading margin after the fee is deducted ([#9751](https://github.com/MetaMask/core/pull/9751)) - Fix Relay quote validation ([#9723](https://github.com/MetaMask/core/pull/9723)) - Keep the quote when validation fails with reason `insufficient-source-balance`, while still surfacing `quoteError`; all other validation-failure reasons continue to remove the quote. - Exclude a zero `gas` value from the simulated transaction. diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts index 20bab4a477c..e999dfdc977 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts @@ -496,6 +496,33 @@ describe('HyperLiquid Activation', () => { expect(result.targetAmountMinimum).toBe('2200000000'); expect(result.hyperliquidActivationFeeUsd).toBe('2'); }); + + it('does not treat a HyperLiquid-source request as a deposit target', async () => { + // Defensive: a perpsDeposit parent with isHyperliquidSource must follow + // the withdrawal path, not bump targetAmountMinimum. + getConfigMock.mockReturnValue({ enabled: true, amountUsd: 1 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [], + } as never); + + const mixedRequest: QuoteRequest = { + ...HYPERLIQUID_SOURCE_REQUEST_MOCK, + isHyperliquidSource: true, + }; + + const result = await applyHyperliquidActivationFee( + mixedRequest, + MESSENGER_MOCK, + PERPS_DEPOSIT_TRANSACTION_MOCK, + ); + + expect(result.targetAmountMinimum).toBe( + HYPERLIQUID_SOURCE_REQUEST_MOCK.targetAmountMinimum, + ); + expect(result.sourceTokenAmount).toBe(REDUCED_AMOUNT_MOCK); + expect(result.hyperliquidActivationFeeUsd).toBe('1'); + }); }); }); }); From c7eeb06828fbbc48a4397145666ad22bcc74e82b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 23:18:37 +0800 Subject: [PATCH 3/5] fix: update Hyperliquid deposit quote test for activation probe Co-authored-by: Cursor --- .../src/strategy/relay/relay-quotes.test.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) 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 69ee629ae48..ff325d85d4c 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 @@ -3885,10 +3885,26 @@ describe('Relay Quotes Utils', () => { targetTokenAddress: ARBITRUM_USDC_ADDRESS, }; - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => QUOTE_MOCK, - } as never); + // Deposit targets always probe HyperLiquid activation before quoting. + // Treat the account as already activated so the destination remapping + // assertion is not conflated with the $1 activation top-up. + successfulFetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + delta: { + type: 'send', + user: FROM_MOCK, + destination: '0x6b9e773128f453f5c2c60935ee2de2cbc5390a24', + }, + }, + ], + } as never) + .mockResolvedValueOnce({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); await getRelayQuotes({ accountSupports7702: true, @@ -3901,7 +3917,7 @@ describe('Relay Quotes Utils', () => { }); const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, + successfulFetchMock.mock.calls[1][1]?.body as string, ); expect(body).toStrictEqual( From 33cb4c61b29b447eec16edba488d00173e158dbb Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Aug 2026 18:42:41 +0800 Subject: [PATCH 4/5] fix(transaction-pay): guarantee full HyperCore perps deposit amount Relay quotes for HyperCore perps deposits used tradeType EXPECTED_OUTPUT, which only guarantees `target * (1 - slippage)` arrives on the destination. Clients size these deposits at the exact margin required and immediately place an order needing that same margin, so any slippage inside the 0.5% band left the order short and it failed with insufficient margin. HyperCore deposits reach this state because `skipDelegation` is true for them, so they never take the delegation path that already used EXACT_OUTPUT. Request EXACT_OUTPUT for them directly instead. Verified against the live Relay API from $10 to $1000: the guaranteed minimum previously fell short at every size (-$0.05 to -$5.00) and now equals the requested target, at identical source cost. This replaces the previous activation-fee approach, which assumed HyperLiquid deducts ~$1 from the first inbound credit. That premise was not supported by mainnet ledger data, and a flat $1 both over-charged small deposits and under-funded ones above ~$200. --- .../transaction-pay-controller/CHANGELOG.md | 3 +- .../relay/hyperliquid-activation.test.ts | 135 ------------------ .../strategy/relay/hyperliquid-activation.ts | 97 ++----------- .../src/strategy/relay/relay-quotes.test.ts | 78 +++++++--- .../src/strategy/relay/relay-quotes.ts | 44 +++++- .../src/utils/feature-flags.ts | 5 - 6 files changed, 114 insertions(+), 248 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a5bed5ff55b..3b8a5c52897 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Increase HyperCore perps deposit `targetAmountMinimum` by the one-time HyperLiquid activation fee (~$1 USDC) for unactivated accounts so trade-with-token deposits still leave the intended trading margin after the fee is deducted ([#9751](https://github.com/MetaMask/core/pull/9751)) +- 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. - Fix Relay quote validation ([#9723](https://github.com/MetaMask/core/pull/9723)) - Keep the quote when validation fails with reason `insufficient-source-balance`, while still surfacing `quoteError`; all other validation-failure reasons continue to remove the quote. - Exclude a zero `gas` value from the simulated transaction. diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts index e999dfdc977..a00eb8f115b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.test.ts @@ -389,140 +389,5 @@ describe('HyperLiquid Activation', () => { TransactionType.batch, ); }); - - describe('HyperLiquid deposit target (TAT-3400)', () => { - // Normalized perps deposit: Arbitrum USDC remapped to HyperCore USDC. - // $20.00 margin at 8 decimals = 2000000000. - const DEPOSIT_TARGET_AMOUNT_MOCK = '2000000000'; - const DEPOSIT_TARGET_WITH_FEE_MOCK = '2100000000'; - - const HYPERLIQUID_DEPOSIT_REQUEST_MOCK: QuoteRequest = { - from: ADDRESS_MOCK, - isHyperliquidSource: false, - sourceBalanceRaw: '50000000', - sourceChainId: '0x1', - sourceTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, - sourceTokenAmount: '0', - targetAmountMinimum: DEPOSIT_TARGET_AMOUNT_MOCK, - targetChainId: '0x539', - targetTokenAddress: '0x00000000000000000000000000000000' as Hex, - }; - - const PERPS_DEPOSIT_TRANSACTION_MOCK = { - type: TransactionType.perpsDepositAndOrder, - } as TransactionMeta; - - it('increases targetAmountMinimum by the activation fee for an unactivated deposit', async () => { - // Feature flag may be off (withdrawal default); deposits still apply. - getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [], - } as never); - - const result = await applyHyperliquidActivationFee( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK, - MESSENGER_MOCK, - PERPS_DEPOSIT_TRANSACTION_MOCK, - ); - - expect(result.targetAmountMinimum).toBe(DEPOSIT_TARGET_WITH_FEE_MOCK); - expect(result.hyperliquidActivationFeeUsd).toBe('1'); - expect(result.sourceTokenAmount).toBe( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK.sourceTokenAmount, - ); - }); - - it('does not change the target when the deposit account is already activated', async () => { - getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [outboundSend()], - } as never); - - const result = await applyHyperliquidActivationFee( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK, - MESSENGER_MOCK, - PERPS_DEPOSIT_TRANSACTION_MOCK, - ); - - expect(result).toStrictEqual(HYPERLIQUID_DEPOSIT_REQUEST_MOCK); - }); - - it('does not change a non-perps quote targeting HyperCore', async () => { - getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); - - const result = await applyHyperliquidActivationFee( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK, - MESSENGER_MOCK, - { type: TransactionType.simpleSend } as TransactionMeta, - ); - - expect(result).toStrictEqual(HYPERLIQUID_DEPOSIT_REQUEST_MOCK); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('increases the target for perpsDeposit as well as perpsDepositAndOrder', async () => { - getConfigMock.mockReturnValue({ enabled: false, amountUsd: 1 }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [], - } as never); - - const result = await applyHyperliquidActivationFee( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK, - MESSENGER_MOCK, - { type: TransactionType.perpsDeposit } as TransactionMeta, - ); - - expect(result.targetAmountMinimum).toBe(DEPOSIT_TARGET_WITH_FEE_MOCK); - expect(result.hyperliquidActivationFeeUsd).toBe('1'); - }); - - it('uses a custom fee amount from the feature flag for deposits', async () => { - getConfigMock.mockReturnValue({ enabled: false, amountUsd: 2 }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [], - } as never); - - const result = await applyHyperliquidActivationFee( - HYPERLIQUID_DEPOSIT_REQUEST_MOCK, - MESSENGER_MOCK, - PERPS_DEPOSIT_TRANSACTION_MOCK, - ); - - // $20 + $2 = $22 = 2200000000 (8 decimals). - expect(result.targetAmountMinimum).toBe('2200000000'); - expect(result.hyperliquidActivationFeeUsd).toBe('2'); - }); - - it('does not treat a HyperLiquid-source request as a deposit target', async () => { - // Defensive: a perpsDeposit parent with isHyperliquidSource must follow - // the withdrawal path, not bump targetAmountMinimum. - getConfigMock.mockReturnValue({ enabled: true, amountUsd: 1 }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [], - } as never); - - const mixedRequest: QuoteRequest = { - ...HYPERLIQUID_SOURCE_REQUEST_MOCK, - isHyperliquidSource: true, - }; - - const result = await applyHyperliquidActivationFee( - mixedRequest, - MESSENGER_MOCK, - PERPS_DEPOSIT_TRANSACTION_MOCK, - ); - - expect(result.targetAmountMinimum).toBe( - HYPERLIQUID_SOURCE_REQUEST_MOCK.targetAmountMinimum, - ); - expect(result.sourceTokenAmount).toBe(REDUCED_AMOUNT_MOCK); - expect(result.hyperliquidActivationFeeUsd).toBe('1'); - }); - }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts index 619eda1911d..1a6c8360e9b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts @@ -4,12 +4,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { - CHAIN_ID_HYPERCORE, - HYPERCORE_USDC_ADDRESS, - HYPERCORE_USDC_DECIMALS, - PERPS_DEPOSIT_TYPES, -} from '../../constants.js'; +import { HYPERCORE_USDC_DECIMALS } from '../../constants.js'; import { projectLogger } from '../../logger.js'; import type { QuoteRequest, @@ -204,60 +199,16 @@ function getEffectiveTransactionType( } /** - * Whether this quote is a perps deposit whose target is HyperCore USDC. + * Reserve the one-time HyperLiquid activation fee for an unactivated HyperCore + * source account. * - * `normalizeRequest` remaps Arbitrum-USDC perps deposits to HyperCore before - * this runs, so the check is against the normalized target. + * Reduces the amount sent to the provider so HyperLiquid retains enough balance + * for the activation fee on the `sendAsset` step, and records the reserved fee + * (USD) so it can be added to the provider fee — keeping the displayed + * withdrawal amount unchanged. * - * @param request - Normalized quote request. - * @param transaction - Parent transaction metadata. - * @returns True when the quote deposits into HyperCore for a perps deposit type. - */ -function isHyperliquidDepositTarget( - request: QuoteRequest, - transaction?: TransactionMeta, -): boolean { - const effectiveType = getEffectiveTransactionType(transaction); - - if ( - !effectiveType || - !PERPS_DEPOSIT_TYPES.includes(effectiveType as TransactionType) - ) { - return false; - } - - if (request.isHyperliquidSource) { - return false; - } - - return ( - request.targetChainId === CHAIN_ID_HYPERCORE && - request.targetTokenAddress.toLowerCase() === - HYPERCORE_USDC_ADDRESS.toLowerCase() && - new BigNumber(request.targetAmountMinimum).gt(0) - ); -} - -/** - * Reserve or top up the one-time HyperLiquid activation fee for an unactivated - * HyperCore account. - * - * **Withdrawals** (`isHyperliquidSource`): reduces the amount sent to the - * provider so HyperLiquid retains enough balance for the activation fee on the - * `sendAsset` step, and records the reserved fee (USD) so it can be added to - * the provider fee — keeping the displayed withdrawal amount unchanged. Gated - * by the remote activation-fee feature flag (default off). - * - * **Deposits** (perps deposit types targeting HyperCore USDC): increases - * `targetAmountMinimum` by the activation fee so the first inbound credit still - * leaves the intended trading margin after HyperLiquid deducts ~$1 USDC. Trade - * with token sizes the deposit at exact `marginRequired`; without this bump the - * auto-placed order fails with insufficient margin for unactivated accounts. - * Always applied for unactivated deposit targets (correctness); uses the - * configured fee amount (default $1). - * - * No-op when the withdrawal feature flag is disabled, when the account is - * already activated, or when a withdrawal amount is too small to reserve. + * No-op for non-HyperLiquid sources, when the feature flag is disabled, when + * the account is already activated, or when the amount is too small to reserve. * * @param request - Normalized quote request. * @param messenger - Controller messenger. @@ -272,21 +223,16 @@ export async function applyHyperliquidActivationFee( transaction?: TransactionMeta, signal?: AbortSignal, ): Promise { - const transactionType = getEffectiveTransactionType(transaction); - const isDeposit = isHyperliquidDepositTarget(request, transaction); - - if (!request.isHyperliquidSource && !isDeposit) { + if (!request.isHyperliquidSource) { return request; } const { enabled, amountUsd } = getHyperliquidActivationFeeConfig( messenger, - transactionType, + getEffectiveTransactionType(transaction), ); - // Withdrawals remain behind the remote flag (historical default: off). - // Deposits always top up when unactivated — under-funding by $1 is a bug. - if (request.isHyperliquidSource && !enabled) { + if (!enabled) { return request; } @@ -297,25 +243,6 @@ export async function applyHyperliquidActivationFee( } const feeRaw = new BigNumber(amountUsd).shiftedBy(HYPERCORE_USDC_DECIMALS); - - if (isDeposit) { - const increasedTarget = new BigNumber(request.targetAmountMinimum).plus( - feeRaw, - ); - - log('Increasing HyperLiquid deposit target for activation fee', { - amountUsd, - originalTargetAmountMinimum: request.targetAmountMinimum, - increasedTargetAmountMinimum: increasedTarget.toFixed(0), - }); - - return { - ...request, - targetAmountMinimum: increasedTarget.toFixed(0), - hyperliquidActivationFeeUsd: String(amountUsd), - }; - } - const reducedAmount = new BigNumber(request.sourceTokenAmount).minus(feeRaw); // Can't reserve more than the balance — let the original amount through so 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 ff325d85d4c..93a32cf364b 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 @@ -3885,26 +3885,10 @@ describe('Relay Quotes Utils', () => { targetTokenAddress: ARBITRUM_USDC_ADDRESS, }; - // Deposit targets always probe HyperLiquid activation before quoting. - // Treat the account as already activated so the destination remapping - // assertion is not conflated with the $1 activation top-up. - successfulFetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - delta: { - type: 'send', - user: FROM_MOCK, - destination: '0x6b9e773128f453f5c2c60935ee2de2cbc5390a24', - }, - }, - ], - } as never) - .mockResolvedValueOnce({ - ok: true, - json: async () => QUOTE_MOCK, - } as never); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); await getRelayQuotes({ accountSupports7702: true, @@ -3917,7 +3901,7 @@ describe('Relay Quotes Utils', () => { }); const body = JSON.parse( - successfulFetchMock.mock.calls[1][1]?.body as string, + successfulFetchMock.mock.calls[0][1]?.body as string, ); expect(body).toStrictEqual( @@ -3929,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 faf40470811..ea2ae167587 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, + useExactOutput: boolean, +): RelayQuoteRequest['tradeType'] { + if (useExactInput) { + return 'EXACT_INPUT'; + } + + return useExactOutput ? 'EXACT_OUTPUT' : 'EXPECTED_OUTPUT'; +} + function normalizeRequest( request: QuoteRequest, transaction: TransactionMeta, diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 5dc1ccfea1f..71a530dc464 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1067,11 +1067,6 @@ export function getFiatOrderPollTimeoutMs( * `sendAsset` step retains enough balance) and surfaced as part of the * provider fee. Defaults to disabled with a $1 fee. * - * Perps deposits targeting HyperCore always top up `targetAmountMinimum` by - * this fee amount for unactivated accounts (independent of `enabled`), so - * trade-with-token still leaves the intended margin after HyperLiquid deducts - * the activation fee from the first inbound credit. - * * @param messenger - Controller messenger. * @param transactionType - Parent transaction type used to resolve overrides. * @returns The activation-fee configuration. From 914a41696499d594e29df133ef0eeccc74905ab0 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Aug 2026 18:58:57 +0800 Subject: [PATCH 5/5] fix(transaction-pay): allow undefined useExactInput in getTradeType isMaxAmount and isPostQuote are both optional, so useExactInput is boolean | undefined and the build failed on the narrowed parameter type. --- .../src/strategy/relay/relay-quotes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ea2ae167587..3cf47e38597 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -645,7 +645,7 @@ function isHypercoreDeposit(request: QuoteRequest): boolean { * @returns The Relay trade type. */ function getTradeType( - useExactInput: boolean, + useExactInput: boolean | undefined, useExactOutput: boolean, ): RelayQuoteRequest['tradeType'] { if (useExactInput) {