From d40c7c9234e34b5b7541db234e01b09ff4cc0f3a Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:43:50 +0200 Subject: [PATCH 1/4] feat: base HyperLiquid activation detection on spot sends and creation fees The activation fee reserve for Pay withdrawals only applied to accounts with no outbound ledger history at all. HyperLiquid charges the one-time 1 USDC activation fee on the first spot send unless it was already paid by the inbound transfer that created the account. Bridge withdrawals use a separate fee lane and do not settle it, so accounts whose only outbound history was bridge withdrawals were wrongly classified as activated and their max withdrawals failed with "Insufficient USDC balance for token transfer gas". - Stop treating bridge withdrawals as activation - Treat a creation entry carrying a fee >= 1 as paid activation - Reserve the fee when the activation check fails, instead of skipping --- .../relay/hyperliquid-activation.test.ts | 63 +++++++++-- .../strategy/relay/hyperliquid-activation.ts | 106 ++++++++++++------ .../src/strategy/relay/relay-quotes.test.ts | 10 +- 3 files changed, 136 insertions(+), 43 deletions(-) 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 40adcdd015a..9afd122e0b5 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 @@ -38,16 +38,21 @@ const HYPERLIQUID_SOURCE_REQUEST_MOCK: QuoteRequest = { }; /** - * An inbound `send` (funds received from another account) - does not activate. + * An inbound `send` (funds received from another account) - does not activate + * unless it created the account and carries the activation fee. * + * @param fee - Optional fee recorded on the transfer. + * @param time - Optional entry timestamp. * @returns A ledger update representing an inbound send. */ -function inboundSend(): HyperLiquidLedgerUpdate { +function inboundSend(fee?: string, time?: number): HyperLiquidLedgerUpdate { return { + time, delta: { type: 'send', user: OTHER_ADDRESS_MOCK, destination: ADDRESS_MOCK, + ...(fee === undefined ? {} : { fee }), }, }; } @@ -134,15 +139,57 @@ describe('HyperLiquid Activation', () => { ).toBe(true); }); - it('returns true for a withdraw', () => { + it('returns false for a bridge withdraw', () => { + // Bridge withdrawals do not settle the spot activation fee. expect( isHyperLiquidAccountActivated( [{ delta: { type: 'withdraw' } }], ADDRESS_MOCK, ), + ).toBe(false); + }); + + it('returns true when the creation entry carries the activation fee', () => { + expect( + isHyperLiquidAccountActivated([inboundSend('1.0')], ADDRESS_MOCK), ).toBe(true); }); + it('returns true when the earliest of several entries carries the activation fee', () => { + expect( + isHyperLiquidAccountActivated( + [inboundSend('0.0', 300), inboundSend('1.0', 100)], + ADDRESS_MOCK, + ), + ).toBe(true); + }); + + it('returns false when only a later entry carries a fee', () => { + // A fee on a non-creation inbound entry belongs to the sender's own + // first-send activation, not to this account. + expect( + isHyperLiquidAccountActivated( + [ + { time: 100, delta: { type: 'deposit' } }, + inboundSend('1.0', 200), + ], + ADDRESS_MOCK, + ), + ).toBe(false); + }); + + it('returns false when the creation entry fee is below the activation fee', () => { + expect( + isHyperLiquidAccountActivated([inboundSend('0.001231')], ADDRESS_MOCK), + ).toBe(false); + }); + + it('returns false when the creation entry fee is not numeric', () => { + expect( + isHyperLiquidAccountActivated([inboundSend('abc')], ADDRESS_MOCK), + ).toBe(false); + }); + it('matches the address case-insensitively', () => { expect( isHyperLiquidAccountActivated( @@ -269,7 +316,7 @@ describe('HyperLiquid Activation', () => { expect(result).toStrictEqual(request); }); - it('treats the account as activated when the info request throws', async () => { + it('reserves the fee when the info request throws', async () => { fetchMock.mockRejectedValue(new Error('network')); const result = await applyHyperliquidActivationFee( @@ -277,10 +324,11 @@ describe('HyperLiquid Activation', () => { MESSENGER_MOCK, ); - expect(result).toStrictEqual(HYPERLIQUID_SOURCE_REQUEST_MOCK); + expect(result.sourceTokenAmount).toBe(REDUCED_AMOUNT_MOCK); + expect(result.hyperliquidActivationFeeUsd).toBe('1'); }); - it('treats the account as activated when the info request is not ok', async () => { + it('reserves the fee when the info request is not ok', async () => { fetchMock.mockResolvedValue({ ok: false, status: 500 } as never); const result = await applyHyperliquidActivationFee( @@ -288,7 +336,8 @@ describe('HyperLiquid Activation', () => { MESSENGER_MOCK, ); - expect(result).toStrictEqual(HYPERLIQUID_SOURCE_REQUEST_MOCK); + expect(result.sourceTokenAmount).toBe(REDUCED_AMOUNT_MOCK); + expect(result.hyperliquidActivationFeeUsd).toBe('1'); }); it('resolves the config for the given transaction type', async () => { 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 60b7a6bb953..57f480dddfd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts @@ -14,31 +14,42 @@ import { HYPERLIQUID_INFO_URL } from './constants'; const log = createModuleLogger(projectLogger, 'hyperliquid-activation'); /** - * HyperLiquid non-funding ledger `delta` types that represent an outbound - * transfer initiated by the account (and therefore pay the activation fee). - * A funding deposit can arrive as an inbound `send`, so direction is checked - * for transfer types via the `user`/`destination` fields. + * HyperLiquid non-funding ledger `delta` types that represent a spot-lane + * transfer. Bridge withdrawals (`withdraw`) are deliberately excluded: they + * use a separate fee lane and do not settle the spot activation fee, so an + * account whose only outbound history is bridge withdrawals still pays the + * activation fee on its first spot send. */ const OUTBOUND_TRANSFER_TYPES = new Set(['send', 'spotTransfer']); +/** + * The activation fee is 1 quote token (e.g. 1 USDC). Ledger entries with a + * `fee` at or above this mark the account's activation as paid; smaller fees + * (e.g. HIP-3 dex transfer fees) do not. + */ +const ACTIVATION_FEE_MINIMUM = 1; + /** * Minimal shape of a HyperLiquid `userNonFundingLedgerUpdates` entry. Only the - * fields used to determine transfer direction are typed. + * fields used to determine activation are typed. */ export type HyperLiquidLedgerUpdate = { + time?: number; delta?: { type?: string; user?: string; destination?: string; + fee?: string; }; }; /** - * Whether a single ledger entry is an outbound action initiated by the account. + * Whether a single ledger entry is an outbound spot transfer initiated by the + * account. * * @param update - The ledger entry. * @param normalizedAddress - The account's lowercased address. - * @returns True when the account itself sent funds out. + * @returns True when the account itself sent funds out via the spot lane. */ function isOutboundFromAccount( update: HyperLiquidLedgerUpdate, @@ -46,37 +57,61 @@ function isOutboundFromAccount( ): boolean { const delta = update?.delta; - if (!delta?.type) { + if (!delta?.type || !OUTBOUND_TRANSFER_TYPES.has(delta.type)) { return false; } - // Bridge withdrawals are always outbound from the account. - if (delta.type === 'withdraw') { - return true; - } - // A spot send/transfer counts only when this account is the sender and the // funds leave it (an inbound receipt has `user` set to the other party). - if (OUTBOUND_TRANSFER_TYPES.has(delta.type)) { - const sender = delta.user?.toLowerCase(); - const destination = delta.destination?.toLowerCase(); - return sender === normalizedAddress && destination !== normalizedAddress; - } + const sender = delta.user?.toLowerCase(); + const destination = delta.destination?.toLowerCase(); + return sender === normalizedAddress && destination !== normalizedAddress; +} + +/** + * Whether the account was created by an inbound transfer that paid the + * activation fee on its behalf. + * + * When a transfer creates a new HyperCore account, HyperLiquid charges the + * activation fee on that transfer and records it on the entry's `fee`. Such + * accounts send for free from their very first outbound transfer. The fee is + * only meaningful on the account's earliest entry: a later inbound entry with + * a fee belongs to the sender (their own first-send activation), not to this + * account. + * + * @param updates - Raw HyperLiquid non-funding ledger updates. + * @returns True when the account's creation entry carries the activation fee. + */ +function hasPaidActivationOnCreation( + updates: HyperLiquidLedgerUpdate[], +): boolean { + const firstUpdate = updates.reduce( + (earliest: HyperLiquidLedgerUpdate | undefined, update) => + earliest === undefined || (update.time ?? 0) < (earliest.time ?? 0) + ? update + : earliest, + undefined, + ); - return false; + const fee = parseFloat(firstUpdate?.delta?.fee ?? ''); + + return Number.isFinite(fee) && fee >= ACTIVATION_FEE_MINIMUM; } /** - * Whether the HyperCore account has already paid the activation fee. + * Whether the HyperCore account has already paid the one-time spot activation + * fee. * - * The fee is charged on the account's first outbound send/withdrawal, so an - * account that has only ever received deposits (or has no non-funding ledger - * history) is treated as unactivated. Direction matters: only entries the - * account itself initiated count. + * The fee is charged once per account, either on the inbound transfer that + * creates the account, or on top of the account's first outbound spot send if + * activation was not paid at creation (e.g. accounts created via the Arbitrum + * bridge or perps trading). Bridge withdrawals do not settle it. HyperLiquid + * purges emptied accounts along with their ledger, which also resets + * activation — an empty ledger is therefore correctly treated as unactivated. * * @param updates - Raw HyperLiquid non-funding ledger updates. * @param address - The account's address. - * @returns True when the account has made a prior outbound transfer. + * @returns True when the account's activation fee is already paid. */ export function isHyperLiquidAccountActivated( updates: HyperLiquidLedgerUpdate[], @@ -88,8 +123,9 @@ export function isHyperLiquidAccountActivated( const normalizedAddress = address.toLowerCase(); - return updates.some((update) => - isOutboundFromAccount(update, normalizedAddress), + return ( + hasPaidActivationOnCreation(updates) || + updates.some((update) => isOutboundFromAccount(update, normalizedAddress)) ); } @@ -97,13 +133,13 @@ export function isHyperLiquidAccountActivated( * Query HyperLiquid for the account's activation state. * * On any error (network failure, non-OK response, malformed body) the account - * is treated as activated so the common path is never penalised by a transient - * HyperLiquid outage; an unactivated account would then surface the original - * HyperLiquid error, matching the pre-feature behaviour. + * is treated as unactivated so the fee is reserved. A wrongly reserved fee + * leaves a recoverable amount on HyperLiquid, while a wrongly skipped reserve + * makes the whole withdrawal fail with an insufficient-balance error. * * @param address - The HyperCore account address. * @param signal - Optional abort signal forwarded to the request. - * @returns True when the account is activated (or activation can't be determined). + * @returns True when the account is confirmed activated. */ async function fetchIsAccountActivated( address: Hex, @@ -122,18 +158,18 @@ async function fetchIsAccountActivated( }); if (!response.ok) { - log('Activation check returned non-OK, assuming activated', { + log('Activation check returned non-OK, assuming unactivated', { status: response.status, }); - return true; + return false; } const updates = (await response.json()) as HyperLiquidLedgerUpdate[]; return isHyperLiquidAccountActivated(updates, address); } catch (error) { - log('Activation check failed, assuming activated', { error }); - return true; + log('Activation check failed, assuming unactivated', { error }); + return false; } } 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 cd6ffa21974..57a5842c775 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 @@ -4507,7 +4507,15 @@ describe('Relay Quotes Utils', () => { successfulFetchMock .mockResolvedValueOnce({ ok: true, - json: async () => [{ delta: { type: 'withdraw' } }], + json: async () => [ + { + delta: { + type: 'send', + user: FROM_MOCK, + destination: '0x6b9e773128f453f5c2c60935ee2de2cbc5390a24', + }, + }, + ], } as never) .mockResolvedValueOnce({ ok: true, From ccaaf4d65215846944a1d527ac7273e95031f1d7 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:49:02 +0200 Subject: [PATCH 2/4] feat: add changelog entry for activation detection rework --- packages/transaction-pay-controller/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0c54d07e874..6d4fc1defd1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Reserve the HyperLiquid activation fee based on how HyperLiquid actually charges it ([#9594](https://github.com/MetaMask/core/pull/9594)) + - Bridge withdrawals no longer count as activation, so accounts whose only outbound history is bridge withdrawals now get the fee reserved and their max withdrawals no longer fail with `Insufficient USDC balance for token transfer gas` + - Accounts created by an inbound transfer that paid the activation fee are detected as activated, so no fee is reserved and the full balance can be withdrawn + - Activation-check failures now reserve the fee instead of skipping it + ## [25.1.1] ### Changed From 48e16566f881b3814730d5135891c0c6c13d819d Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:56:08 +0200 Subject: [PATCH 3/4] chore: format hyperliquid-activation test for oxfmt --- .../src/strategy/relay/hyperliquid-activation.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 9afd122e0b5..af64cd5f1fd 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 @@ -169,10 +169,7 @@ describe('HyperLiquid Activation', () => { // first-send activation, not to this account. expect( isHyperLiquidAccountActivated( - [ - { time: 100, delta: { type: 'deposit' } }, - inboundSend('1.0', 200), - ], + [{ time: 100, delta: { type: 'deposit' } }, inboundSend('1.0', 200)], ADDRESS_MOCK, ), ).toBe(false); From 01b59c7f2b64ee40e31eb84a3e68f1e2b730f68e Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:58:09 +0200 Subject: [PATCH 4/4] feat: resolve activation fee flag through nested type for batch withdrawals EIP-7702 wrapped withdrawals have a top-level type of batch, so the perpsWithdraw feature flag override was never matched and the activation fee was silently disabled for most accounts. --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../relay/hyperliquid-activation.test.ts | 44 +++++++++++++++++-- .../strategy/relay/hyperliquid-activation.ts | 33 ++++++++++++-- .../src/strategy/relay/relay-quotes.ts | 2 +- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6d4fc1defd1..fa6c462aa1d 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bridge withdrawals no longer count as activation, so accounts whose only outbound history is bridge withdrawals now get the fee reserved and their max withdrawals no longer fail with `Insufficient USDC balance for token transfer gas` - Accounts created by an inbound transfer that paid the activation fee are detected as activated, so no fee is reserved and the full balance can be withdrawn - Activation-check failures now reserve the fee instead of skipping it +- Resolve the activation fee feature flag override through the nested transaction type when the parent transaction is a batch, so EIP-7702 batched withdrawals match their `perpsWithdraw` override instead of silently disabling the fee ([#9594](https://github.com/MetaMask/core/pull/9594)) ## [25.1.1] 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 af64cd5f1fd..e35359ba66d 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 @@ -1,3 +1,5 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import type { @@ -337,18 +339,54 @@ describe('HyperLiquid Activation', () => { expect(result.hyperliquidActivationFeeUsd).toBe('1'); }); - it('resolves the config for the given transaction type', async () => { + it('resolves the config for the transaction type', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => [] } as never); await applyHyperliquidActivationFee( HYPERLIQUID_SOURCE_REQUEST_MOCK, MESSENGER_MOCK, - 'perpsWithdraw', + { type: TransactionType.perpsWithdraw } as TransactionMeta, ); expect(getConfigMock).toHaveBeenCalledWith( MESSENGER_MOCK, - 'perpsWithdraw', + TransactionType.perpsWithdraw, + ); + }); + + it('resolves the config for the nested transaction type when batched', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => [] } as never); + + await applyHyperliquidActivationFee( + HYPERLIQUID_SOURCE_REQUEST_MOCK, + MESSENGER_MOCK, + { + type: TransactionType.batch, + nestedTransactions: [{}, { type: TransactionType.perpsWithdraw }], + } as TransactionMeta, + ); + + expect(getConfigMock).toHaveBeenCalledWith( + MESSENGER_MOCK, + TransactionType.perpsWithdraw, + ); + }); + + it('resolves the config for the batch type when no nested transaction is typed', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => [] } as never); + + await applyHyperliquidActivationFee( + HYPERLIQUID_SOURCE_REQUEST_MOCK, + MESSENGER_MOCK, + { + type: TransactionType.batch, + nestedTransactions: [{}], + } as TransactionMeta, + ); + + expect(getConfigMock).toHaveBeenCalledWith( + MESSENGER_MOCK, + TransactionType.batch, ); }); }); 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 57f480dddfd..84ac7db27ff 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/hyperliquid-activation.ts @@ -1,3 +1,5 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -173,6 +175,29 @@ async function fetchIsAccountActivated( } } +/** + * Effective parent transaction type used to resolve feature flag overrides. + * + * A batch transaction (e.g. an EIP-7702 batch wrapping a perps withdraw) takes + * its type from the first typed nested transaction. + * + * @param transaction - The parent transaction metadata. + * @returns The resolved transaction type, or `undefined`. + */ +function getEffectiveTransactionType( + transaction?: TransactionMeta, +): string | undefined { + if (transaction?.type !== TransactionType.batch) { + return transaction?.type; + } + + const nestedType = transaction.nestedTransactions?.find( + (tx) => tx.type, + )?.type; + + return nestedType ?? transaction.type; +} + /** * Reserve the one-time HyperLiquid activation fee for an unactivated HyperCore * source account. @@ -187,15 +212,15 @@ async function fetchIsAccountActivated( * * @param request - Normalized quote request. * @param messenger - Controller messenger. - * @param transactionType - Parent transaction type used to resolve the feature - * flag override. + * @param transaction - Parent transaction used to resolve the feature flag + * override by its effective type. * @param signal - Optional abort signal forwarded to the activation request. * @returns The (possibly adjusted) quote request. */ export async function applyHyperliquidActivationFee( request: QuoteRequest, messenger: TransactionPayControllerMessenger, - transactionType?: string, + transaction?: TransactionMeta, signal?: AbortSignal, ): Promise { if (!request.isHyperliquidSource) { @@ -204,7 +229,7 @@ export async function applyHyperliquidActivationFee( const { enabled, amountUsd } = getHyperliquidActivationFeeConfig( messenger, - transactionType, + getEffectiveTransactionType(transaction), ); if (!enabled) { 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 969ba352c3c..6fb465e4785 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -122,7 +122,7 @@ export async function getRelayQuotes( applyHyperliquidActivationFee( normalizedRequest, request.messenger, - request.transaction.type, + request.transaction, request.signal, ), ),