diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 00e7b1f4cf3..e9e11a59935 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^11.1.1` to `^11.2.0` ([#9629](https://github.com/MetaMask/core/pull/9629)) - Bump `@metamask/gas-fee-controller` from `^26.2.4` to `^26.3.0` ([#9629](https://github.com/MetaMask/core/pull/9629)) +### 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 +- 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] ### Changed 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 a3587ea1495..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 @@ -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 { @@ -38,16 +40,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 +141,54 @@ 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 +315,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 +323,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,21 +335,58 @@ 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 transaction type', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => [] } as never); + + await applyHyperliquidActivationFee( + HYPERLIQUID_SOURCE_REQUEST_MOCK, + MESSENGER_MOCK, + { type: TransactionType.perpsWithdraw } as TransactionMeta, + ); + + expect(getConfigMock).toHaveBeenCalledWith( + MESSENGER_MOCK, + 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 given transaction type', async () => { + 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, - 'perpsWithdraw', + { + type: TransactionType.batch, + nestedTransactions: [{}], + } as TransactionMeta, ); expect(getConfigMock).toHaveBeenCalledWith( MESSENGER_MOCK, - 'perpsWithdraw', + 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 84a5afe9300..1a6c8360e9b 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'; @@ -14,31 +16,42 @@ import { HYPERLIQUID_INFO_URL } from './constants.js'; 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 +59,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; +} - return false; +/** + * 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, + ); + + 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 +125,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 +135,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,21 +160,44 @@ 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; } } +/** + * 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. @@ -151,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) { @@ -168,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.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index e517e6b5717..d2df829245c 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 @@ -4508,7 +4508,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, 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 a42c3e954ca..a7dd0cc89cb 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, ), ),