Skip to content
markpaper

src/account/fees.ts

v0.3.0 · 4.3 KB

Download file
import type { InfoRequester } from '../transport/types.js';
import { normalizeAddress } from './address.js';
import * as D from './decimal.js';
import { HlAccountError } from './errors.js';
import { describe, isObject } from './parse.js';
import { callOptions } from './call.js';
import type { AccountCallOptions } from './call.js';

export interface UserFees {
  /** Perp maker rate as a fraction (`userAddRate`, "add liquidity"). 0.00015 = 1.5 bps. Negative = rebate. */
  makerRate: number;
  /** Perp taker rate as a fraction (`userCrossRate`, "cross the spread"). */
  takerRate: number;
  /** `makerRate × 1e4`, computed exactly. */
  makerBps: number;
  /** `takerRate × 1e4`, computed exactly. */
  takerBps: number;
  /**
   * `activeReferralDiscount` as a fraction; 0 when absent.
   * Its size and conditions were not verified live.
   */
  referralDiscount: number;
  /**
   * Spot maker rate (`userSpotAddRate`) when present.
   * @experimental Spot fees were not verified live; check against a spot-trading account.
   */
  spotMakerRate: number | null;
  /**
   * Spot taker rate (`userSpotCrossRate`) when present.
   * @experimental See {@link UserFees.spotMakerRate}.
   */
  spotTakerRate: number | null;
  /** Exact decimal strings. */
  exact: { makerRate: string; takerRate: string; makerBps: string; takerBps: string; referralDiscount: string };
  /** The full answer, for fields not modelled here (tiers, staking discount, daily volume). */
  raw: Record<string, unknown>;
}

/**
 * Parses a `userFees` answer.
 *
 * Field names are easy to swap: `userAddRate` is MAKER, `userCrossRate` is TAKER. Rates are fraction
 * strings; basis points are `rate × 1e4` (shifted exactly, no float multiply). The base perp tier of the
 * public HL fee schedule is maker 1.5 bps / taker 4.5 bps.
 *
 * @throws {HlAccountError} `INVALID_RESPONSE` when the answer is not an object or a rate is malformed.
 */
export function parseUserFees(raw: unknown): UserFees {
  if (!isObject(raw)) throw new HlAccountError('INVALID_RESPONSE', `userFees answer is not an object (got ${describe(raw)})`);
  const maker = D.parseDec(raw.userAddRate);
  const taker = D.parseDec(raw.userCrossRate);
  if (!maker) throw new HlAccountError('INVALID_RESPONSE', `userFees.userAddRate is not a decimal (got ${describe(raw.userAddRate)})`);
  if (!taker) throw new HlAccountError('INVALID_RESPONSE', `userFees.userCrossRate is not a decimal (got ${describe(raw.userCrossRate)})`);
  const referral =
    raw.activeReferralDiscount === undefined || raw.activeReferralDiscount === null
      ? D.ZERO
      : D.parseDec(raw.activeReferralDiscount);
  if (!referral) {
    throw new HlAccountError('INVALID_RESPONSE', `userFees.activeReferralDiscount is not a decimal (got ${describe(raw.activeReferralDiscount)})`);
  }
  const makerBps = D.shiftLeft(maker, 4);
  const takerBps = D.shiftLeft(taker, 4);
  const spotMaker = D.parseDec(raw.userSpotAddRate);
  const spotTaker = D.parseDec(raw.userSpotCrossRate);
  return {
    makerRate: D.toNumber(maker),
    takerRate: D.toNumber(taker),
    makerBps: D.toNumber(makerBps),
    takerBps: D.toNumber(takerBps),
    referralDiscount: D.toNumber(referral),
    spotMakerRate: spotMaker ? D.toNumber(spotMaker) : null,
    spotTakerRate: spotTaker ? D.toNumber(spotTaker) : null,
    exact: {
      makerRate: D.toDecString(maker),
      takerRate: D.toDecString(taker),
      makerBps: D.toDecString(makerBps),
      takerBps: D.toDecString(takerBps),
      referralDiscount: D.toDecString(referral),
    },
    raw,
  };
}

/**
 * Reads the account's actual fee rates via `{type:"userFees", user}` (weight 20 — read it once at
 * startup/preflight and log it, do not poll per order).
 *
 * Practice: if a strategy's pre-fee edge is below `makerBps`, warn about negative expectancy: the maker fee
 * is exactly the price of a resting order. Actual charged fees must still come from fills (`fee`), not from
 * these constants.
 *
 * @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE`; transport errors are rethrown.
 */
export async function fetchUserFees(info: InfoRequester, user: string, opts?: AccountCallOptions): Promise<UserFees> {
  const address = normalizeAddress(user, 'user');
  const raw = await info<unknown>({ type: 'userFees', user: address }, callOptions(opts));
  return parseUserFees(raw);
}
All files