Skip to content
markpaper

src/risk/builder-fee.ts

v0.3.0 · 5.3 KB

Download file
// Builder fee units, conversions and the per-order gate.
//
// Units: `f` in the order's `builder: { b, f }` field (and the value returned by
// `info maxBuilderFee`) is in TENTHS of a basis point: 25 = 0.025%, 50 = 0.05%.
// % = f / 1000, bps = f / 10, fraction of notional = f / 100000.

import { dec, decMul, decShift, decToNumber, decToString, type DecInput } from './decimal.js';
import type { Numeric } from './fees.js';

/** Exchange ceiling of the builder fee on perps, in tenths of a bp (100 = 0.1%). */
export const PERP_BUILDER_FEE_CAP = 100;

function assertTenths(f: number, name = 'f'): void {
  if (!Number.isInteger(f) || f < 0) {
    throw new RangeError(`${name} must be a non-negative integer in tenths of a bp, got ${f}`);
  }
}

/** Builder fee in tenths of a bp -> basis points (`f / 10`). */
export function builderFeeToBps(f: number): number {
  assertTenths(f);
  return decToNumber(decShift(f, -1));
}

/** Builder fee in tenths of a bp -> percent (`f / 1000`): 50 -> 0.05. */
export function builderFeeToPercent(f: number): number {
  assertTenths(f);
  return decToNumber(decShift(f, -3));
}

/** Builder fee in tenths of a bp -> fraction of notional (`f / 100000`): 25 -> 0.00025. */
export function builderFeeToRate(f: number): number {
  assertTenths(f);
  return decToNumber(decShift(f, -5));
}

/** Builder fee amount of an order/fill: `notional × f / 100000` (exact). */
export function builderFeeUsd(notionalUsd: Numeric, f: number): number {
  assertTenths(f);
  return decToNumber(decShift(decMul(notionalUsd as DecInput, f), -5));
}

/**
 * Formats `f` as the `maxFeeRate` string signed in `approveBuilderFee`:
 * 50 -> "0.05%", 25 -> "0.025%", 100 -> "0.1%".
 *
 * The signed string and the threshold you later compare `maxBuilderFee` against
 * must match one-to-one: what the user signs becomes their ceiling.
 */
export function builderFeeToMaxFeeRate(f: number): string {
  assertTenths(f);
  return `${decToString(decShift(f, -3))}%`;
}

/**
 * Parses a `maxFeeRate` string ("0.05%") back to tenths of a bp (50).
 * @throws RangeError when the string is not a percentage or is not a whole
 * number of tenths of a bp.
 */
export function parseMaxFeeRate(maxFeeRate: string): number {
  const s = maxFeeRate.trim();
  if (!s.endsWith('%')) throw new RangeError(`maxFeeRate must end with "%": "${maxFeeRate}"`);
  const tenths = decShift(dec(s.slice(0, -1)), 3);
  if (tenths.e !== 0 || tenths.c < 0n) {
    throw new RangeError(`maxFeeRate is not a whole non-negative number of tenths of a bp: "${maxFeeRate}"`);
  }
  return Number(tenths.c);
}

export interface ResolveBuilderFeeInput {
  /** Builder address; it is lowercased (a checksummed address gets the order REJECTED). */
  builder: string | undefined | null;
  /**
   * Approved ceiling from `maxBuilderFee(user, builder)` in tenths of a bp.
   * Pass 0 when the read failed: the order then goes without a builder field
   * instead of being blocked (safe by default).
   */
  approved: number | null | undefined;
  /** Target rate of the service, tenths of a bp. */
  target: number;
  /**
   * Minimum approval for which the builder field is attached at all (an onboarding
   * gate, or a floor low enough to keep serving older, lower approvals). Default 1.
   */
  minAccepted?: number;
  /** Ceiling, default {@link PERP_BUILDER_FEE_CAP}; values above 100 are clamped to 100. */
  cap?: number;
  /**
   * `true` for a retry of a CLOSE. Retries go WITHOUT the builder field: if the
   * user lowered or revoked the approval, a stale cached `f` would reject every
   * close retry and the position would stay open.
   */
  isCloseRetry?: boolean;
}

const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;

/**
 * Decides the `builder: { b, f }` field of an order, or `undefined` to omit it.
 *
 * Rules: if `f` exceeds the user's approved ceiling the exchange rejects the
 * WHOLE order, so `f = min(cap, approved, target)` and never above what was
 * signed; no approval (or below `minAccepted`) means no builder field; close
 * retries never carry it. Do not attach it to native TP/SL either, so a stale
 * approval can never drop a stop.
 *
 * @throws RangeError when `builder` is set but is not a 0x-prefixed 20-byte
 * address (never on a close retry). Validate the address once at config load.
 */
export function resolveBuilderFee(input: ResolveBuilderFeeInput): { b: `0x${string}`; f: number } | undefined {
  // Close retries are decided before any validation: an exit must never be
  // blocked, not even by a misconfigured builder address.
  if (input.isCloseRetry) return undefined;
  if (!input.builder) return undefined;
  if (!ADDRESS_RE.test(input.builder)) throw new RangeError(`builder is not an address: "${input.builder}"`);
  const approved = typeof input.approved === 'number' && Number.isFinite(input.approved) ? Math.floor(input.approved) : 0;
  const rawMin = input.minAccepted ?? 1;
  // A NaN minimum would make `approved < NaN` false and attach the field below the gate.
  if (!Number.isFinite(rawMin)) return undefined;
  const minAccepted = Math.max(1, rawMin);
  if (approved < minAccepted) return undefined;
  const cap = Math.min(PERP_BUILDER_FEE_CAP, input.cap ?? PERP_BUILDER_FEE_CAP);
  const target = Number.isFinite(input.target) ? Math.floor(input.target) : 0;
  const f = Math.min(cap, approved, target);
  if (!(f > 0)) return undefined;
  return { b: input.builder.toLowerCase() as `0x${string}`, f };
}
All files