Skip to content
markpaper

knowledge/hl/fees.md

vregistry-c914171 · 32.9 KB

Download file
# Hyperliquid — Fees: tiers, rebates, builder fee, referral, calculated from fills

## TL;DR

1. **Base HL perp tier: maker 1.5 bp (0.015%), taker 4.5 bp (0.045%).** Verified through `userFees` on a fresh subaccount (2026-09-14). Tiers are calculated from 14-day volume. Maker 0 starts at $500M over 14 days. A rebate is granted only when the account's share exceeds 0.5% of the exchange's total maker volume.
2. **Actual account rates** come from `info` `{type:'userFees', user}` (weight 20): `userAddRate` is the maker rate, `userCrossRate` is the taker rate, and `activeReferralDiscount` is the referral discount. Rates arrive as fractional strings and convert to basis points as `rate × 1e4`.
3. **Actual fee is taken from fills, not a constant.** Field `fee`: plus for payment, minus for rebate. There are also `feeToken`, `crossed` (`true` = taker), and `builderFee`. Net result = `Σ closedPnl − Σ fee`, because `closedPnl` does not include the fee.
4. **Builder fee is set per order** in field `builder: { b, f }` of `exchange.order`. `b` — builder address, strictly lowercase. `f` — rate in **tenths of a basis point**: 10 = 0.01%, 40 = 0.04%. The ceiling for perps is `f = 100` (0.1%). Fee amount: `notional × f / 100000`.
5. **The order will not pass without approval.** The user signs `approveBuilderFee` with the main wallet; an agent key cannot be used for this. This is a gasless EIP-712 signature. If `f` exceeds the approved ceiling (`info maxBuilderFee(user, builder)`), HL rejects **the entire order**. Therefore, calculate the rate as `f = min(approved, target rate)`, and omit the `builder` field when there is no approval.
6. **`maxBuilderFee` — heavy query (weight 20).** Synchronous check for approval before each order can empty the rate-limit bucket and delay openings by tens of seconds. Needs a SWR-cache that never blocks the path of an order.
7. **Retries are sent without `builder` field.** If user lowers or revokes approval, outdated `f` from cache will reject CLOSE, leaving position open.
8. **`builderFee` in `userFills` is the fee paid to any builder.** The field does not identify its recipient. Separate your own builder revenue by matching `oid` against your own order journal, or use the daily `stats-data.hyperliquid.xyz/.../builder_fills/` dump. `info.referral` returns the all-time total in `builderRewards`.
9. **In a backtest,** charge the fee on notional and on **both sides**. Treat funding as a separate line item. A maker-only backtest using a constant 0.015% is valid only when every order really executed as maker.

---

## 1. Maker/taker tiers and discounts

### 1.1. Perp tier table (14-day volume)

| Tier | 14-day volume threshold | Maker, bp | Taker, bp |
|---|---|---|---|
| T0 (base) | $0 | 1.5 | 4.5 |
| T1 | $5M | 1.2 | 4.0 |
| T2 | $25M | 0.8 | 3.5 |
| T3 | $100M | 0.4 | 3.0 |
| T4 | $500M | 0.0 | 2.8 |
| T5 | $2B | 0.0 | 2.6 |
| MM-rebate | >0.5% of the maker volume on the exchange | −0.1 … −0.3 | — |

- T0 verified through `userFees` on a fresh sub-account (2026-09-14): `userAddRate = 0.00015`, `userCrossRate = 0.00045`. Other rows in the table are from a secondary source (2026-09-10, medium confidence), they should be reconciled with the official table.
- Maximum staking discount is **40%**, for it you need **500k HYPE**.
- The window is rolling. Volume outside the current window does not accumulate, and the tier is retained only while the required volume is maintained.
- Neither rebates nor zero maker fees can be bought; they can only be earned through trading. Before reaching the threshold, the maker rate is positive and enters into the cost of each trade.

**Outside the table: spot, HIP-3, upper tier.**

> Verify with the official Fees table before using in the model, not verified in practice.

- The official perp-fee table HL has one more tier higher than $2B: around $7B for 14 days, taker ~2.4 bp, maker 0.
- HL spot fees are higher than perp: base tier around taker 7 bp / maker 4 bp. Spot volume counts towards the 14-day tier with a multiplier. Verify in docs and on `userFees` account for spot trading.
- On HIP-3 dex, the fee rate is set by the protocol together with the dex deployer, and it may differ from the main perp-dex. For backtesting HIP-3, use the actual `fee / (px × sz)` from own fills on this dex, not a constant from the main dex.
- Backtest rule: the fee rate is a market parameter (main perp / HIP-3 dex / spot), not a single global constant.

### 1.2. Reading account rates: `userFees`

```ts
import * as hl from "@nktkas/hyperliquid";

const info = new hl.InfoClient({ transport: new hl.HttpTransport() });

// {type:'userFees', user} — weight 20 (heavy)
const r = await info.userFees({ user: "0xYOUR_ADDRESS" });
const makerRate = Number(r.userAddRate);   // share: 0.00015 = 1.5 bp.
const takerRate = Number(r.userCrossRate); // share: 0.00045 = 4.5 bp.
const referralDiscount = Number(r.activeReferralDiscount ?? 0);
console.log(`maker ${(makerRate * 1e4).toFixed(2)} bp, taker ${(takerRate * 1e4).toFixed(2)} bp.`);
```

Recommendation: read `userFees` during preflight at bot startup and log the rates. If the strategy's assumed pre-fee edge is smaller than the account's maker rate, the bot should explicitly warn about negative expectancy.

### 1.3. Field names: do not mix up

| field `userFees` | meaning |
|---|---|
| `userAddRate` | maker: add liquidity |
| `userCrossRate` | taker: cross the spread |
| `activeReferralDiscount` | active referral discount (fraction) |

full set of response keys (live read-only query 2026-09-22, zero address and fresh random address — both the same): `dailyUserVlm` (array `{ date: "YYYY-MM-DD", userCross, userAdd, exchange }`, 15 strings), `feeSchedule` (object: `cross`, `add`, `spotCross`, `spotAdd`, `referralDiscount`, `tiers { vip[], mm[] }`, `stakingDiscountTiers[]`), `userCrossRate`, `userAddRate`, `userSpotCrossRate`, `userSpotAddRate`, `activeReferralDiscount`, `trial`, **`feeTrialEscrow`**, `nextTrialAvailableTimestamp`, `stakingLink`, `activeStakingDiscount { bpsOfMaxSupply, discount }`. Field is called `feeTrialEscrow` (also in type `UserFeesResponse` SDK 0.33.3); variant `feeTrialReward` is not present in the response HL — code reading it gets `undefined`. All numbers are decimal strings with a fractional part (`"0.0"`).

---

## 2. Calculating fees from fills

### 2.1. Fill fields related to fees and PnL

| field | type in response | meaning |
|---|---|---|
| `px`, `sz` | string | price and size; notional = `px × sz` |
| `fee` | string | exchange fee for this fill; **> 0** — paid, **< 0** — rebate |
| `feeToken` | string | fee token (for perps, `USDC`) |
| `crossed` | bool | `true` — taker, `false` — maker |
| `closedPnl` | string | realized PnL **not including fee** |
| `builderFee` | string (may be absent) | builder fee for this fill paid to **any** builder; recipient not specified |
| `oid`, `tid` | number | order and trade IDs |

### 2.2. Formulas

```
notional        = px × sz
fee (exchange)     = notional × (crossed ? takerRate : makerRate)
builderFee      = notional × f / 100000          // f is in tenths of a basis point
turnover          = Σ px·sz
feeUsd          = Σ fee
closedPnlUsd    = Σ closedPnl
netUsd          = closedPnlUsd − feeUsd
fee, bps       = 1e4 × feeUsd / turnover
net, bps       = 1e4 × netUsd / turnover
```

Verified on maker fill (2026-09): `fee` matches `px × sz × makerRate`, but comes rounded. Illustration (numbers are hypothetical): 0.3 × 36.30 = $10.89 → × 0.00015 = 0.0016335, in the fill `fee = 0.0016`.

How `closedPnl` is calculated (verified against live HL fills, 2026-09-14):
- if the fill opens or increases position, `closedPnl = 0`;
- if the fill reduces a position, `closedPnl = closing_sz × (px − entryPx) × sign(position)`, where `entryPx` is the average entry price;
- when increasing, `entryPx` recalculated as weighted average, on reversal becomes fill price, and for zero position equals `null`.
- `fee` is not included in `closedPnl`. Example (numbers are hypothetical): a 0.5 short entered at 36.33 is closed by maker fill `{ side: "B", sz: "0.5", px: "36.30", crossed: false, fee: "0.0027", closedPnl: "0.015" }`: `closedPnl = 0.5 × (36.30 − 36.33) × (−1) = 0.015`; the fee is deducted separately.

### 2.3. PnL aggregator over fills

```ts
interface Fill {
  coin: string; px: string; sz: string; side: "B" | "A"; time: number; oid: number;
  closedPnl: string; fee: string; feeToken: string; crossed: boolean; tid: number;
  builderFee?: string;
}

function summarize(fills: Fill[]) {
  let turnover = 0, fee = 0, closedPnl = 0, builderFee = 0, makerVol = 0, takerCount = 0;
  for (const f of fills) {
    const n = Number(f.px) * Number(f.sz);
    turnover += n;
    fee += Number(f.fee);                 // negative means a rebate
    closedPnl += Number(f.closedPnl);
    builderFee += Number(f.builderFee ?? 0);
    if (f.crossed) takerCount++; else makerVol += n;
  }
  const net = closedPnl - fee;            // builderFee is separate — see "Open questions"
  return {
    turnover, fee, closedPnl, net, builderFee,
    makerShare: turnover ? makerVol / turnover : 0,
    takerCount,                            // every taker fill is a bug for a post-only bot
    feeBps: turnover ? 1e4 * fee / turnover : 0,
    netBps: turnover ? 1e4 * net / turnover : 0,
  };
}
```

- The history can be pulled at startup via `userFillsByTime({ user, startTime, endTime })` (weight 20), and live fills from WS can be appended.
- The fills feed shows what is not visible in `accountValue`: how much was spent on fees, the maker/taker split of trades, and the net result per basis point of turnover.
- For a post-only bot, `crossed = true` indicates an error: the order crossed the spread.

---

## 3. Builder fee

### 3.1. Mechanics

- Builder fee applies **to one order**. It is deducted only from orders where `builder: { b, f }` is present in the payload. Trades made by the user directly on app.hyperliquid.xyz do not contain this field and builder fee is not taken from them.
- The `builder` field is at the top level of the `order` action alongside `orders` and `grouping`. It enters the signed payload of the order and **does not require a separate signature**. Implementation matches the official example `hyperliquid-python-sdk/examples/basic_builder_fee.py`.
- `b` — builder address in lowercase. An address with mixed case (checksummed) gives REJECTED.
- `f` — tenths of a basis point. Ceiling for perps — **100** (0.1%).

| `f` | basis points | % | `maxFeeRate` (string in approve) | notional fraction |
|---|---|---|---|---|
| 10 | 1 | 0.01% | `"0.01%"` | 0.0001 |
| 20 | 2 | 0.02% | `"0.02%"` | 0.0002 |
| 40 | 4 | 0.04% | `"0.04%"` | 0.0004 |
| 100 | 10 | 0.1% (perp ceiling) | `"0.1%"` | 0.001 |

Unit conversion: `% = f / 1000`, `bp = f / 10`, `fraction = f / 100000`. In the daily dump, `builder_fee` matches `notional × f / 100000` for the order's rate.

### 3.2. Requirements for builder wallet

- The builder's perp account must hold **at least 100 USDC**.
- The account's collateral mode can change (for example, to Unified Account) without an explicit action by the owner, so periodically check the builder wallet's mode (accounts.md §5.2).

### 3.3. Approval: `approveBuilderFee`

- This is a user-signed action. It is signed by the **user's master wallet**, not the bot's agent key. The signature is off-chain: there is no gas and no on-chain transaction. Send the signed action in a POST request to `https://api.hyperliquid.xyz/exchange`.
- In the service with agent keys, approval is usually presented on a separate web page where the user connects their wallet.
- **What approval does NOT grant:** withdrawal or transfer rights, trading on behalf of the user, or access to keys. It only permits charging a fee up to the approved ceiling on orders routed by this builder. Trading on behalf of the user is a separate permission (agent wallet).
- **Revocation:** On `https://app.hyperliquid.xyz/builderCodes`, use the same wallet to find the builder and click Remove. Revocation takes effect immediately. Afterward, every order containing this builder's `builder` field is rejected.

Manually signing in the browser, without SDK (structure matches what `@nktkas/hyperliquid` does):

```ts
const BUILDER = "0xbuilder_address_lowercase";        // strictly lowercase
const ZERO_ADDRESS = "0x" + "0".repeat(40);

const [user] = await provider.request({ method: "eth_requestAccounts" });
const chainHex: string = await provider.request({ method: "eth_chainId" });
const nonce = Date.now();

const action = {
  type: "approveBuilderFee",
  signatureChainId: chainHex,          // wallet chainId in hex
  hyperliquidChain: "Mainnet",
  maxFeeRate: "0.04%",                 // string containing %, ↔ maxBuilderFee = 40 (example)
  builder: BUILDER,
  nonce,
};

const typedData = {
  domain: {
    name: "HyperliquidSignTransaction", version: "1",
    chainId: parseInt(chainHex, 16), verifyingContract: ZERO_ADDRESS,
  },
  types: {
    EIP712Domain: [
      { name: "name", type: "string" }, { name: "version", type: "string" },
      { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
    ],
    "HyperliquidTransaction:ApproveBuilderFee": [
      { name: "hyperliquidChain", type: "string" },
      { name: "maxFeeRate", type: "string" },
      { name: "builder", type: "address" },
      { name: "nonce", type: "uint64" },
    ],
  },
  primaryType: "HyperliquidTransaction:ApproveBuilderFee",
  // message has exactly 4 fields; type and signatureChainId are only in action
  message: { hyperliquidChain: "Mainnet", maxFeeRate: "0.04%", builder: BUILDER, nonce },
};

const sig: string = await provider.request({
  method: "eth_signTypedData_v4",
  params: [user.toLowerCase(), JSON.stringify(typedData)],
});
const r = "0x" + sig.slice(2, 66), s = "0x" + sig.slice(66, 130), v = parseInt(sig.slice(130, 132), 16);

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature: { r, s, v }, nonce }),
});
```

Through the SDK, the call looks as follows (the 0.27.x form has not been verified live; see “Open questions”). The master wallet must sign:

```ts
const exch = new hl.ExchangeClient({ wallet: masterWallet, transport: new hl.HttpTransport() });
await exch.approveBuilderFee({ maxFeeRate: "0.04%", builder: "0xbuilder_address_lowercase" });
```

Approval-page workflow:
1. First read `maxBuilderFee`. If the value is already ≥ the threshold, do not request a signature.
2. After submission, read `maxBuilderFee` again. The value may not update immediately after submit, so ask the user to retry the check in a few seconds instead of showing an error.
3. The string `maxFeeRate` and the threshold should match exactly: `"0.04%"` ↔ `40`. What the user signs becomes their ceiling.

### 3.4. Approval check: `maxBuilderFee`

```
POST /info  {"type":"maxBuilderFee","user":"0xYOUR_ADDRESS","builder":"0xbuilder_lowercase"}
→ 40        // number in tenths of a basis point; 0 = not approved (or user has no HL account)
```

- The units are the same as for `f` in an order. To display a percentage, use `value / 1000`.
- Weight **20** (heavy), as with `meta` and `userFills`. It cannot be called for each order without caching.
- Approval is stored on the HL side. It should not be stored as the source of truth in your own database, only as a cache.
- If the request fails, treat the result as `0`: the order is sent without the `builder` field, and trading is not blocked (safe-by-default).

### 3.5. Order with builder fee: gate and rate

```ts
const PERP_CAP_F = 100;        // HL ceiling for perps
const TARGET_F = 10;           // target rate (illustrative value: 10 = 0.01%)
const MIN_ACCEPTED_F = 5;      // minimum accepted approval (conditional value)

async function builderFor(masterAddr: string, isCloseRetry: boolean) {
  if (!BUILDER) return undefined;                    // feature is disabled
  if (isCloseRetry) return undefined;                // exit retry — no builder (see 3.7)
  const approved = await getApprovedBuilderFeeCached(masterAddr, BUILDER); // 0 on error
  if (approved < MIN_ACCEPTED_F) return undefined;   // not approved → order without fee
  const f = Math.min(PERP_CAP_F, approved, TARGET_F); // NEVER above signed ceiling
  return { b: BUILDER.toLowerCase() as `0x${string}`, f };
}

const builder = await builderFor(master, isCloseRetry);
await exch.order({
  orders: [{ a: assetIndex, b: isBuy, p: limitPxStr, s: sizeStr, r: reduceOnly, t: { limit: { tif: "Ioc" } } }],
  grouping: "na",
  ...(builder ? { builder } : {}),
});
```

**Increasing the rate while grandfathering old approvals** is a general technique. Calculate the actual rate as `f = min(ceiling, approved, target)`, so a user who approved the previous lower rate continues trading at that rate without signing again, while new users sign the target rate.

### 3.6. Approval cache: SWR and asymmetric TTL

1. **Antipattern — a blocking cache with the same TTL in every client.** Caches created by one restart expire simultaneously. At the TTL boundary, every order from every user makes a blocking weight-20 request: the bucket falls to zero, delaying openings for all users.
2. **Pattern — one process-wide SWR cache** keyed by lowercase `master:builder`. A stale value is returned immediately; refresh runs in the background with in-flight request deduplication. The order path is not blocked. A blocking request remains only for a cold cache or an explicit freshness requirement.
3. **The TTL is asymmetric** and selected from the value at write time:
   - “approved” lives for a long time (hours). The state is stable. Worst case: the user revokes approval, the exchange rejects the order, and the same happens on the next order.
   - "not approved" lives short (minutes). Transitional onboarding state: user is about to hit Approve. With a long TTL, just-approved users trade for hours without builder field or fail onboarding gate.
   - Freshness is compared with the TTL recorded in the cache element, not against a global constant. Otherwise, "not approved" would have a long TTL and background update wouldn't run.
4. For the “Refresh” button in the UI, use a **short freshness window** (seconds). This is not a complete cache bypass: a click immediately after approval sees the new value, while repeated clicks within the window use the cache.
5. **When changing the key or master address,** explicitly delete the `master:builder` cache entry for both the old and new master. Otherwise, a user who has just approved sees “not approved” until the TTL ends. Leave an in-flight request alone: it will write a fresh value under the same key.

### 3.7. When not to include the builder field

| Situation | Why |
|---|---|
| Approval < minimum or the request failed (0) | HL will reject the entire order |
| **Close retries** | If approval is lowered or revoked, stale `f` from the cache rejects CLOSE, and every retry with the same `f` before the cache expires is also rejected: the position remains open. Send close retries without `builder` |
| Native TP/SL (`positionTpsl`) | They are sent without builder-field (whether to attach it is not verified, §8). Thus, closures via native TP/SL do not bring in builder-revenue, and no builder-fills occur |

---

## 4. Accounting for builder revenue

### 4.1. Three sources

| Method | What it gives | Pros | Cons |
|---|---|---|---|
| Own order journal × rate | real-time evaluation | instantly | may diverge from the exchange. Not suitable as a basis for payouts: accurate delayed data are better than instant incorrect ones |
| Daily dump `builder_fills` | breakdown by users and fills | 1 GET daily, HL data | lag up to 3 days, missing days, archive not covering the entire history |
| `info.referral({ user: builder })` | all-time total | authoritative and complete | aggregate only |

Scheme: take the headline and control total from `info.referral`, and the per-user breakdown from dumps. Reconciliation: `builderRewards − Σ builder_fee(dumps)` = earnings outside the archive (before the archive begins, plus lagging days). The difference must be **small and non-negative**.

### 4.2. `info.referral` for builder-address

```ts
const r = await info.referral({ user: "0xbuilder_address_lowercase" }); // weight 20
const builderRewards = Number(r.builderRewards);       // all-time builder fee
const unclaimedRewards = Number(r.unclaimedRewards);   // builderRewards + HL referral rewards = "Rewards Earned" in UI
const referrerRewards = (r.referrerState?.data?.referralStates ?? [])
  .reduce((s: number, st: any) => s + (Number(st?.cumFeesRewardedToReferrer) || 0), 0);
```

Cache with background updates: value grows slowly. In case of an error, do not overwrite the last good value.

### 4.3. Daily dump `builder_fills`

- URL: `https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/<builder>/<YYYYMMDD>.csv.lz4`
  - `<builder>` must be written **only in lowercase**: path is case-sensitive.
  - Dumps are sliced by **UTC-date**, time in the file with suffix `Z`. Date should be generated in UTC.
  - The host is S3, not the info API. Per-IP info throttling does not apply to it, and a separate retry policy is unnecessary. Approximately 8 requests per day are enough.
- Compression is **LZ4 Frame** (magic `04 22 4D 18`, with content checksum). Pure-JS `lz4js` decompresses it with `LZ4.decompress(Uint8Array)`. A block decoder (node-lz4 in block mode) **does not work**.
- Columns: `time,user,coin,side,px,sz,crossed,special_trade_type,tif,is_trigger,counterparty,closed_pnl,twap_id,builder_fee`
  - indices are taken from the header, not from fixed positions;
  - `builder_fee` is the amount actually deducted for the builder in USDC;
  - `user` — address of the payer;
  - the builder's own address also appears as `user` when it trades with its own code. Exclude it when calculating revenue from users.
- **Publication lag — up to 3 days.** Example (2026-07): file for day D appeared only on D+3. Until the object is available, S3 responds with `403 AccessDenied`. This **is not an error**, but `pending`.
- **Some days HL never publishes.** In observation (2026-07) two days gave 403 and after 10 days, although neighboring days were published. Possible reasons are either no builder-fill on that day or HL pipeline lost the file. Such a day remains in `pending` forever.
  - Diagnosis: `curl` for suspicious date and its neighbor. 403 both show this lag. 403 only one — the day is missing.
  - A missing day does not affect the total from `builderRewards`, but the per-user breakdown will be understated.
- The archive does not cover the entire history: before a certain date, every day returns 403. Take the all-time total from `info.referral`.

### 4.4. Criterion for archive completeness

- Day statuses: `PENDING` (HL responded 403/404), `OK`, `EMPTY`, `ERROR`. Readiness is determined by the **actual HL response**, not by a waiting window.
- Absence of "holes in status ERROR" ≠ "archive complete". Complete archive means **no holes and no PENDING**. Days that HL has yet to publish also indicate an incomplete archive.
- `PENDING` should be split into fresh lag and **stuck days**: older than 4 days with observed lag up to 3 days.
- Ingestion must be idempotent: replace each day in full (per-day replace). Backfill the archive at startup and periodically afterward.

### 4.5. Recovery of a missing day from `userFillsByTime`

- `userFillsByTime` returns `builderFee` for each fill. **But this is the fee paid to any builder.** If a user trades through another frontend at the same time, filtering on `builderFee > 0` attributes someone else's fee to your builder and inflates the base — severalfold if there are many such trades.
- **The correct method:** take only fills whose `oid` appears in your own journal of submitted orders. Builder fee is charged precisely on the orders submitted by that builder. Reconciliation for a day with a published dump produces **line for line** the same set and the same total: third-party data is excluded, and your own data is not lost.
- If there are no `oid`s within the window, the day cannot be recovered. It remains PENDING or ERROR. Silently recording an empty day is worse than leaving a gap: it will look like "data exists".
- Partial success should not be recorded. If at least one user does not respond, the day remains PENDING; otherwise, replacing the day will erase the possibility of re-uploading it later.
- Recovery is expensive: instead of one GET, it requires one weight-20 request per user. This path is only for stuck days, never for fresh publication lag. Recovery requests must not consume the rate limit needed by trading requests.
- **String formatting must match the dump byte for byte.** Fill time uses second-precision ISO (`YYYY-MM-DDTHH:MM:SSZ`), and `side` is `Bid`/`Ask`. Otherwise, string time comparisons such as “fill after the association date” will break.
- Before enabling, run recovery for a day where there is a dump and compare the results.

---

## 5. Referrals and transfers

- In `userFees` there is a field `activeReferralDiscount` — the active referral discount for the account.
- `info.referral({ user })` → `referrerState.data.referralStates[].cumFeesRewardedToReferrer` gives referral earnings for each referred user. `unclaimedRewards` includes both builder and referral rewards.
- If builder revenue is shared with someone at a specified rate, apply the rate in effect at the time of each fill, not the current rate to the entire history.
- **Activation fee $1.** A transfer (`usdSend`/`sendAsset`) to an address that has never transacted on HyperCore withholds **$1 from the amount**, regardless of volume. The fee is $0 for an existing HL address. In the UI, this appears in the “USD Transfer Fee … destination address has not sent any transaction on Hyperliquid before” modal; in the documentation, it is the activation-gas-fee section. A $2 transfer to a fresh wallet arrives as approximately $1.
  - **CRITICAL:** The recipient address must support HyperCore (self-custody). A CEX deposit address is not suitable, as funds will be lost.

---

## 6. Impact of fees on strategies

### 6.1. Taker strategies

- A strategy that enters via IOC trades as a taker. **Maker rates and rebates do not apply**; the model must use the taker rate.
- Builder fee (up to the 0.1% perp ceiling) is added to the user's exchange fee on every order with the `builder` field. In the user model: `taker + f/1000 %` per side.

### 6.2. Backtest: how to account for fees

Taker model (market entry and exit):
```
dir          = +1 LONG / −1 SHORT
pricePnlPct  = (exitPx − entryPx) / entryPx · 100 · dir
notional     = entryPx · sz                           // = margin · leverage
feeUsd       = notional · (feePctPerSide / 100) · 2   // base taker: feePctPerSide = 0.045
pnlUsd       = notional · pricePnlPct / 100 − feeUsd
```
The same in basis points: `fees = notional × takerFeeBps / 10000 × 2`, `pnl = margin × roePct/100 − fees` (margin — position margin).

Maker model: the maker rate (`0.00015` at the base tier) is charged **on both sides**—on entry (`cost × makerRate`) and on exit (`proceeds × makerRate`).

- **Fees cannot be omitted when comparing strategies with different trade counts.** Without fees, the variant with fewer trades looks better than it really is.
- The 0.015% rate in the model matches the base maker tier, but the model is still optimistic if it assumes every order executes as maker: actual taker executions, rebates, and tiers are not included. In live trading, take the fee from `fee` in fills.
- **Funding is a separate line item.** Take actual funding from `userFunding`.
- A micro-order slightly above the $10 mainnet minimum is a cheap way to test mechanics (reduce-only clamp, IOC floor, place→cancel cycle) against real matching. The fee is measured in cents, and the result is more reliable than testnet.

---

## 7. Pitfalls

| What breaks | Why | How to do it right |
|---|---|---|
| Order with `builder` is rejected entirely | `f` > `maxBuilderFee(user, builder)`, no approval or it was withdrawn | Apply only if `approved ≥ minimum`, `f = min(100, approved, target)`, without approval — `builder: undefined` |
| REJECTED with correct approval | `b` in checksum (mixed case) | Convert builder address to lowercase when loading config, in `approveBuilderFee`, in info requests, and in URL dump |
| Delay in openings for all users | Synchronous check of `maxBuilderFee` (weight 20) in the order path, stale caches expire simultaneously | Shared SWR-cache, outdated value immediately, background update, deduplicate requests in flight |
| User just approved but service sees "not approved" for hours | Cache for `master:builder` not cleared on key or master change; long TTL for "not approved" | Explicitly delete cache entry on reinstallation of the key (old and new master); asymmetric TTL (long for "approved", short for "not approved"); short freshness window for "Update" |
| Position does not close | Retry CLOSE with stale `f` after approval reduction | Retries on exit — without `builder` field |
| Approval does not go through the bot | `approveBuilderFee` signed with agent-key | Sign with user's main wallet, e.g., on a separate web page |
| Builder revenue is overstated | `builderFee > 0` in `userFills` — commission for any builder | Filter by `oid` from own journal or take a dump of `builder_fills` |
| Day is forever in PENDING, user breakdown incomplete | HL did not publish dump (403 with published neighbors) | Detector for stuck days (>4 day), recovery from `userFillsByTime` with `oid` filter |
| Date comparison breaks on restored rows | Time format and side differ from dump | ISO with seconds and `Z`, `Bid`/`Ask`, byte-by-byte as in the dump |
| Dump does not open | Block-decoder LZ4 used | LZ4 Frame (`04 22 4D 18`), `lz4js` |
| Dump gives 403 | Lag to 3 days or URL address not in lowercase | 403 = pending; path in lowercase; UTC-dates |
| A transfer to a fresh address arrives $1 short | $1 activation fee for an address with no HyperCore transactions | Account for it in the minimum transfer amount; do not send to CEX deposit addresses |
| Backtest promises profit, live trading is in the minus | Commission only on one side, maker instead of taker, no funding | Commission × 2 side; taker for IOC; `userFunding` separately; live — `fee` from fills |
| A strategy whose edge is below the maker rate quietly loses money | Pre-fee strategy edge is below the account's maker rate (1.5 bp at the base tier) | Read `userFees` in preflight and warn; calculate net bp from fills |

---

## 8. Open questions / not verified

- **The T1–T5 tier and rebate table** comes from a secondary source rather than the official table (medium confidence). It is not verified which rebate tiers correspond to which shares of maker volume. The tier above $2B, spot rates, the spot-volume multiplier for tiers, and fees on HIP-3 dexes (xyz and others) are recorded in §1.1 only as hypotheses recalled from the documentation; they must be checked against the official table and live `userFees`.
- **Referral discount:** field `activeReferralDiscount` can be read, but its amount and conditions (volume limit and duration) are not verified live. How the staking discount combines with the referral discount and tiers is also not verified: only the upper threshold of 40% / 500k HYPE is confirmed.
- **Whether `fee` in a fill includes builder fee** or builder fee appears only in `builderFee`. The aggregator above treats them separately. Verify this on your own fill containing the builder field.
- **`feeToken` on spot:** for perps `USDC`. For spot purchases, commission may be charged in another token, not verified.
- **The builder-fee ceiling for spot** and the behavior of `approveBuilderFee` on testnet (`hyperliquidChain: "Testnet"`) are not verified. Only perps on mainnet with `f ≤ 100` are verified.
- **The exact HL error string** for `f` above the approved value and for a checksum-cased builder address was not captured. Only the REJECTED status is known.
- **Whether `builder` can be attached to trigger orders (TP/SL, `positionTpsl`)** is not verified, so they are sent without the builder field.
- **The SDK call `exch.approveBuilderFee({ maxFeeRate, builder })`** was not executed live for 0.27.x. The manual EIP-712 signature, whose structure was taken from the SDK, was verified.
- **A switch to Unified Account mode without owner action:** the conditions under which HL does this are unknown. Monitor the mode if it matters.
- **Dump lag.** A lag of up to 3 days was observed (2026-07), with no guarantee from HL. The adopted rule is: allow up to 3 days and consider a day stuck once it is older than 4 days.
- **Resolved contradiction: “0.015% is a volume tier; a new account has a higher rate.”** `userFees` on a fresh subaccount (2026-09-14) showed exactly 1.5 bp maker: 0.015% is the base rate. The maker backtest is optimistic because it assumes “all executions are maker” and omits funding, not because of the tier.

---

Knowledge snapshot: 2026-09; dates of individual checks appear in the text. The HL API changes, so recheck limits and response shapes.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license._
All files