Skip to content
markpaper

src/transport/address-budget.ts

v0.3.0 · 9.6 KB

Download file
// Per-address action budget (the second, independent Hyperliquid limit family).
//
// The IP bucket does not cover it. Every address (main account or sub-account) has a buffer of
// 10 000 requests + 1 per $1 of cumulative traded volume. Each placed order counts 1 (a batch of N
// counts N, `modify` counts like a placement); cancels draw from a separate, larger limit. When the
// budget is exhausted the address gets 1 request per 10 s - a bot effectively stops. For a
// bot placing many orders on a small account this, not the IP limit, is the bottleneck.

import type { InfoRequester } from './types.js';

/** Base buffer every address starts with. */
export const ADDRESS_BASE_REQUESTS = 10_000;
/** Price of one extra request bought with `reserveRequestWeight`, USDC (2 000 requests = $1). */
export const REQUEST_PRICE_USD = 0.0005;
/** Allowed rate once the address budget is exhausted: one request per this many ms. */
export const ADDRESS_EXHAUSTED_INTERVAL_MS = 10_000;
/** Recommended `userRateLimit` poll interval (weight 20 per read). */
export const ADDRESS_BUDGET_POLL_MS = 60_000;
/** Do not poll `userRateLimit` more often than this. */
export const ADDRESS_BUDGET_MIN_POLL_MS = 5_000;

/** Raw `userRateLimit` response. */
export interface UserRateLimitResponse {
  /** Cumulative traded volume, USD, decimal string. */
  cumVlm: string;
  nRequestsUsed: number;
  nRequestsCap: number;
  /** Capacity bought on top of the cap via `reserveRequestWeight` (may be absent). */
  nRequestsSurplus?: number;
}

/** Budget mode used to throttle order placement. */
export type AddressBudgetMode = 'normal' | 'economy' | 'freeze';

/** Options for {@link computeAddressBudget} and {@link getAddressBudget}. */
export interface AddressBudgetOptions {
  /** Placements sent after the `userRateLimit` read was taken (local accounting). Default 0. */
  placesSinceRead?: number;
  /**
   * Add `nRequestsSurplus` to the cap. Default true. Pass false when the surplus is already folded
   * into `nRequestsCap` (see {@link surplusAlreadyInCap}) - counting it twice overstates the
   * remainder and the freeze arrives only after the real limit.
   */
  countSurplus?: boolean;
  /** Remaining percentage below which the mode is `freeze`. Default 10. */
  freezePct?: number;
  /** Remaining percentage below which the mode is `economy`. Default 30. */
  economyPct?: number;
}

/** Derived address budget. */
export interface AddressBudget {
  cumVlm: string;
  used: number;
  cap: number;
  surplus: number;
  /** `cap` plus surplus when counted. */
  effectiveCap: number;
  /** `max(0, effectiveCap - used - placesSinceRead)`. */
  remaining: number;
  /** `remaining / effectiveCap * 100` (0 when the cap is 0). */
  remainingPct: number;
  mode: AddressBudgetMode;
  /** No placements left: the address is down to one request per 10 s. */
  exhausted: boolean;
}

/**
 * Base cap implied by volume: `10 000 + floor(cumVlm)`. Integer part is taken from the decimal
 * string, no float rounding. Verified live: a new sub-account showed a cap of 10 000 at $0 volume and
 * 10 010 after $10.
 *
 * @throws TypeError on a non-decimal input.
 */
export function baseAddressRequestCap(cumVlm: string | number): number {
  if (typeof cumVlm === 'number') {
    if (!Number.isFinite(cumVlm) || cumVlm < 0) {
      throw new TypeError(`cumVlm must be a non-negative decimal, got ${String(cumVlm)}`);
    }
    // Floor the number itself: formatting first (toFixed) rounds 10.9999999 up to "11.000000" and
    // overstates the cap - the unsafe direction for a budget.
    return Math.min(Number.MAX_SAFE_INTEGER, ADDRESS_BASE_REQUESTS + Math.floor(cumVlm));
  }
  const s = cumVlm.trim();
  const m = /^(\d+)(?:\.\d*)?$/.exec(s);
  if (!m) throw new TypeError(`cumVlm must be a non-negative decimal, got ${String(cumVlm)}`);
  const whole = BigInt(m[1] as string) + BigInt(ADDRESS_BASE_REQUESTS);
  return whole > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(whole);
}

/** Separate, larger cancel limit derived from the action limit: `min(limit + 100 000, limit * 2)`. */
export function addressCancelCap(limit: number): number {
  return Math.min(limit + 100_000, limit * 2);
}

/** `freeze` below `freezePct`, `economy` below `economyPct`, otherwise `normal`. */
export function budgetMode(remainingPct: number, freezePct = 10, economyPct = 30): AddressBudgetMode {
  if (!(remainingPct >= freezePct)) return 'freeze'; // NaN is treated as freeze (fail closed)
  return remainingPct < economyPct ? 'economy' : 'normal';
}

function isCount(x: unknown): x is number {
  return typeof x === 'number' && Number.isFinite(x) && x >= 0;
}

/**
 * Validates a `userRateLimit` response. A malformed reply throws instead of turning into zeros:
 * "could not read" must never look like "plenty of budget" or "nothing used".
 */
export function parseUserRateLimit(raw: unknown): UserRateLimitResponse {
  const r = raw as Partial<Record<keyof UserRateLimitResponse, unknown>> | null;
  if (!r || typeof r !== 'object' || !isCount(r.nRequestsUsed) || !isCount(r.nRequestsCap)) {
    throw new TypeError('malformed userRateLimit response');
  }
  const cumVlm = typeof r.cumVlm === 'string' ? r.cumVlm : typeof r.cumVlm === 'number' ? String(r.cumVlm) : undefined;
  if (cumVlm === undefined || !/^\d+(?:\.\d+)?$/.test(cumVlm)) {
    throw new TypeError('malformed userRateLimit response: cumVlm');
  }
  const surplus = r.nRequestsSurplus;
  if (surplus !== undefined && surplus !== null && (typeof surplus !== 'number' || !Number.isFinite(surplus))) {
    throw new TypeError('malformed userRateLimit response: nRequestsSurplus');
  }
  return {
    cumVlm,
    nRequestsUsed: r.nRequestsUsed,
    nRequestsCap: r.nRequestsCap,
    ...(typeof surplus === 'number' ? { nRequestsSurplus: surplus } : {}),
  };
}

/** Pure budget arithmetic over a `userRateLimit` response. */
export function computeAddressBudget(rl: UserRateLimitResponse, opts: AddressBudgetOptions = {}): AddressBudget {
  const surplus = Math.max(0, rl.nRequestsSurplus ?? 0);
  const effectiveCap = rl.nRequestsCap + ((opts.countSurplus ?? true) ? surplus : 0);
  const placesSinceRead = Math.max(0, opts.placesSinceRead ?? 0);
  const remaining = Math.max(0, effectiveCap - rl.nRequestsUsed - placesSinceRead);
  const remainingPct = effectiveCap > 0 ? (100 * remaining) / effectiveCap : 0;
  return {
    cumVlm: rl.cumVlm,
    used: rl.nRequestsUsed,
    cap: rl.nRequestsCap,
    surplus,
    effectiveCap,
    remaining,
    remainingPct,
    mode: budgetMode(remainingPct, opts.freezePct, opts.economyPct),
    exhausted: remaining === 0,
  };
}

/**
 * Reads `userRateLimit` (weight 20) for an address and derives the remaining action budget.
 *
 * The budget is shared by everything trading on the address: several bot instances on one account
 * see one remainder. A sub-account is a separate address with its own 10 000 buffer, so one
 * sub-account per bot is the recommended layout. Poll about once a minute
 * ({@link ADDRESS_BUDGET_POLL_MS}), never more often than every 5 s; combine with
 * `createCachedLoader` for a shared, single-flight reader.
 *
 * @param info any InfoRequester (the kit's client or a stub)
 * @param user address, `0x` + 40 hex; sent lower-cased
 * @throws TypeError on a malformed address or response; transport errors propagate
 */
export async function getAddressBudget(
  info: InfoRequester,
  user: string,
  opts: AddressBudgetOptions & { signal?: AbortSignal } = {},
): Promise<AddressBudget & { user: string; raw: UserRateLimitResponse }> {
  if (typeof user !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(user)) {
    throw new TypeError(`user must be a 0x-prefixed 40-hex address, got ${String(user)}`);
  }
  const address = user.toLowerCase();
  const raw = await info({ type: 'userRateLimit', user: address }, opts.signal ? { signal: opts.signal } : undefined);
  const rl = parseUserRateLimit(raw);
  return { user: address, raw: rl, ...computeAddressBudget(rl, opts) };
}

/**
 * Hours until the budget runs out at the current pace, or null when it never does. Every $1 of
 * volume returns one request, so the net drain is `placesPerHour - volumePerHourUsd`.
 */
export function hoursToExhaustion(remaining: number, placesPerHour: number, volumePerHourUsd: number): number | null {
  const net = placesPerHour - volumePerHourUsd;
  if (!(net > 0) || !Number.isFinite(remaining)) return null;
  return Math.max(0, remaining) / net;
}

/** USDC cost of buying `requests` extra requests with `reserveRequestWeight` (0.0005 each). */
export function reserveRequestCostUsd(requests: number): number {
  if (!Number.isFinite(requests) || requests <= 0) return 0;
  // Exact integer numerator: 1 request = 5 / 10 000 USDC.
  return (Math.ceil(requests) * 5) / 10_000;
}

/**
 * Detects whether a `reserveRequestWeight` purchase of `chunk` requests raised `nRequestsCap` by
 * (almost) the chunk while `nRequestsSurplus` also grew. In that case the surplus is already in the
 * cap and must not be added again: call {@link computeAddressBudget} with `countSurplus: false`.
 *
 * Only the main account can buy: the action carries no `vaultAddress`, so weight bought "for" a
 * sub-account lands on the main account.
 *
 * @experimental How cap and surplus change after a purchase was never confirmed live. The check can
 * only lower the computed remainder (the safe direction). Log cap/surplus before and after buying.
 */
export function surplusAlreadyInCap(before: UserRateLimitResponse, after: UserRateLimitResponse, chunk: number): boolean {
  if (!(chunk > 0)) return false;
  const capGrowth = after.nRequestsCap - before.nRequestsCap;
  const surplusGrowth = (after.nRequestsSurplus ?? 0) - (before.nRequestsSurplus ?? 0);
  return capGrowth >= 0.9 * chunk && surplusGrowth > 0;
}
All files