Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 }),
},
};
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -269,40 +315,78 @@ 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(
HYPERLIQUID_SOURCE_REQUEST_MOCK,
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(
HYPERLIQUID_SOURCE_REQUEST_MOCK,
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,
);
});
});
Expand Down
Loading