src/transport/address-budget.ts
v0.3.0 · 9.6 KB
// 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;
}