TL;DR
- Base HL perp tier: maker 1.5 bp (0.015%), taker 4.5 bp (0.045%). Verified through
userFeeson 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. - Actual account rates come from
info{type:'userFees', user}(weight 20):userAddRateis the maker rate,userCrossRateis the taker rate, andactiveReferralDiscountis the referral discount. Rates arrive as fractional strings and convert to basis points asrate × 1e4. - Actual fee is taken from fills, not a constant. Field
fee: plus for payment, minus for rebate. There are alsofeeToken,crossed(true= taker), andbuilderFee. Net result =Σ closedPnl − Σ fee, becauseclosedPnldoes not include the fee. - Builder fee is set per order in field
builder: { b, f }ofexchange.order.b— builder address, strictly lowercase.f— rate in tenths of a basis point: 10 = 0.01%, 40 = 0.04%. The ceiling for perps isf = 100(0.1%). Fee amount:notional × f / 100000. - The order will not pass without approval. The user signs
approveBuilderFeewith the main wallet; an agent key cannot be used for this. This is a gasless EIP-712 signature. Iffexceeds the approved ceiling (info maxBuilderFee(user, builder)), HL rejects the entire order. Therefore, calculate the rate asf = min(approved, target rate), and omit thebuilderfield when there is no approval. 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.- Retries are sent without
builderfield. If user lowers or revokes approval, outdatedffrom cache will reject CLOSE, leaving position open. builderFeeinuserFillsis the fee paid to any builder. The field does not identify its recipient. Separate your own builder revenue by matchingoidagainst your own order journal, or use the dailystats-data.hyperliquid.xyz/.../builder_fills/dump.info.referralreturns the all-time total inbuilderRewards.- 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
userFeeson 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
userFeesaccount 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
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), whereentryPxis the average entry price; - when increasing,
entryPxrecalculated as weighted average, on reversal becomes fill price, and for zero position equalsnull. feeis not included inclosedPnl. 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
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 = trueindicates 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
builderfield is at the top level of theorderaction alongsideordersandgrouping. It enters the signed payload of the order and does not require a separate signature. Implementation matches the official examplehyperliquid-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'sbuilderfield is rejected.
Manually signing in the browser, without SDK (structure matches what @nktkas/hyperliquid does):
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:
const exch = new hl.ExchangeClient({ wallet: masterWallet, transport: new hl.HttpTransport() });
await exch.approveBuilderFee({ maxFeeRate: "0.04%", builder: "0xbuilder_address_lowercase" });
Approval-page workflow:
- First read
maxBuilderFee. If the value is already ≥ the threshold, do not request a signature. - After submission, read
maxBuilderFeeagain. 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. - The string
maxFeeRateand 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
fin an order. To display a percentage, usevalue / 1000. - Weight 20 (heavy), as with
metaanduserFills. 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 thebuilderfield, and trading is not blocked (safe-by-default).
3.5. Order with builder fee: gate and rate
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
- 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.
- 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. - 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.
- 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.
- When changing the key or master address, explicitly delete the
master:buildercache 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
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-JSlz4jsdecompresses it withLZ4.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_feeis the amount actually deducted for the builder in USDC;user— address of the payer;- the builder's own address also appears as
userwhen 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, butpending. - 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
pendingforever.- Diagnosis:
curlfor 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.
- Diagnosis:
- 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.
PENDINGshould 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
userFillsByTimereturnsbuilderFeefor each fill. But this is the fee paid to any builder. If a user trades through another frontend at the same time, filtering onbuilderFee > 0attributes 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
oidappears 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
oids 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), andsideisBid/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
userFeesthere is a fieldactiveReferralDiscount— the active referral discount for the account. info.referral({ user })→referrerState.data.referralStates[].cumFeesRewardedToReferrergives referral earnings for each referred user.unclaimedRewardsincludes 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
builderfield. 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
feein 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
activeReferralDiscountcan 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
feein a fill includes builder fee or builder fee appears only inbuilderFee. The aggregator above treats them separately. Verify this on your own fill containing the builder field. feeTokenon spot: for perpsUSDC. For spot purchases, commission may be charged in another token, not verified.- The builder-fee ceiling for spot and the behavior of
approveBuilderFeeon testnet (hyperliquidChain: "Testnet") are not verified. Only perps on mainnet withf ≤ 100are verified. - The exact HL error string for
fabove the approved value and for a checksum-cased builder address was not captured. Only the REJECTED status is known. - Whether
buildercan 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.”
userFeeson 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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting the material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license.