Skip to content
markpaper

src/signing/appendix.ts

v0.2.0 · 5.3 KB

Download file
// Order appendix (uint128) and expiration.
//
// Bit layout (documentation, low bits confirmed against live book data 2026-07-24):
//   | value 127..64 | builder 63..48 | fee 47..38 | reserved 37..14 | trigger 13..12 |
//   | reduce-only 11 | order type 10..9 | isolated 8 | version 7..0 |
// Order types: 0 DEFAULT (GTC limit), 1 IOC, 2 FOK, 3 POST_ONLY. Live values: DEFAULT 1, IOC 513,
// IOC + reduce-only 2561, POST_ONLY 1537.
//
// CONSTRAINT (error 2067): reduce-only is accepted for TAKER types only (IOC / FOK). A resting
// reduce-only order is impossible on this venue; the builder refuses the combination instead of
// waiting for the rejection.
//
// `expiration` is a plain uint64 timestamp; the order type is NOT encoded in it (unlike the older Vertex
// stack). Every live resting GTC carries 2^64 - 1, and IOCs sent with that value executed fine.

import type { OrderType } from '../markets/types.js';

/** `version` field of the appendix. */
export const APPENDIX_VERSION = 1n;

const ORDER_TYPE_BITS: Record<OrderType, bigint> = { default: 0n, ioc: 1n, fok: 2n, post_only: 3n };
const ORDER_TYPE_BY_BITS: readonly OrderType[] = ['default', 'ioc', 'fok', 'post_only'];

const VERSION_MASK = 0xffn;
const ISOLATED_BIT = 1n << 8n;
const TYPE_SHIFT = 9n;
const TYPE_MASK = 0x3n;
const REDUCE_ONLY_BIT = 1n << 11n;
const TRIGGER_SHIFT = 12n;
const TRIGGER_MASK = 0x3n;
const FEE_SHIFT = 38n;
const FEE_MASK = (1n << 10n) - 1n;
const BUILDER_SHIFT = 48n;
const BUILDER_MASK = (1n << 16n) - 1n;
const VALUE_SHIFT = 64n;
const VALUE_MASK = (1n << 64n) - 1n;

/** Taker types (the only ones that may carry reduce-only). */
export const TAKER_ORDER_TYPES: readonly OrderType[] = ['ioc', 'fok'];
/** Resting types. */
export const RESTING_ORDER_TYPES: readonly OrderType[] = ['default', 'post_only'];

/** True for IOC / FOK. */
export function isTakerType(orderType: OrderType): boolean {
  return orderType === 'ioc' || orderType === 'fok';
}

/** Thrown when an appendix would combine reduce-only with a resting type (the venue answers 2067). */
export class ReduceOnlyNotTakerError extends RangeError {
  override readonly name = 'ReduceOnlyNotTakerError';
  readonly orderType: OrderType;
  constructor(orderType: OrderType) {
    super(`reduce-only requires a taker order type on Nado (IOC/FOK), got ${orderType} - the venue answers 2067`);
    this.orderType = orderType;
  }
}

/**
 * Builds the appendix for an order type and reduce-only flag: DEFAULT -> 1n, IOC -> 513n,
 * IOC + reduceOnly -> 2561n, POST_ONLY -> 1537n, FOK -> 1025n.
 *
 * @throws ReduceOnlyNotTakerError when `reduceOnly` is combined with DEFAULT / POST_ONLY.
 */
export function buildAppendix(orderType: OrderType, reduceOnly = false): bigint {
  const bits = ORDER_TYPE_BITS[orderType];
  if (bits === undefined) throw new RangeError(`unknown order type ${String(orderType)}`);
  if (reduceOnly && !isTakerType(orderType)) throw new ReduceOnlyNotTakerError(orderType);
  return APPENDIX_VERSION | (bits << TYPE_SHIFT) | (reduceOnly ? REDUCE_ONLY_BIT : 0n);
}

/** Decoded appendix. Fields other than `version`, `orderType`, `reduceOnly` were not verified live. */
export interface AppendixParts {
  readonly version: number;
  readonly orderType: OrderType;
  readonly reduceOnly: boolean;
  /** @experimental documentation only */
  readonly isolated: boolean;
  /** @experimental documentation only */
  readonly trigger: number;
  /** @experimental documentation only */
  readonly fee: number;
  /** @experimental documentation only */
  readonly builder: number;
  /** @experimental documentation only */
  readonly value: bigint;
}

/** Splits an appendix (bigint or the decimal string from `orders`) into its fields. */
export function parseAppendix(appendix: bigint | string): AppendixParts {
  const a = typeof appendix === 'bigint' ? appendix : parseAppendixString(appendix);
  if (a < 0n) throw new RangeError('appendix must not be negative');
  const orderType = ORDER_TYPE_BY_BITS[Number((a >> TYPE_SHIFT) & TYPE_MASK)] as OrderType;
  return {
    version: Number(a & VERSION_MASK),
    orderType,
    reduceOnly: (a & REDUCE_ONLY_BIT) !== 0n,
    isolated: (a & ISOLATED_BIT) !== 0n,
    trigger: Number((a >> TRIGGER_SHIFT) & TRIGGER_MASK),
    fee: Number((a >> FEE_SHIFT) & FEE_MASK),
    builder: Number((a >> BUILDER_SHIFT) & BUILDER_MASK),
    value: (a >> VALUE_SHIFT) & VALUE_MASK,
  };
}

function parseAppendixString(s: string): bigint {
  const t = s.trim();
  if (!/^\d+$/.test(t)) throw new TypeError(`appendix "${s}" is not an unsigned integer string`);
  return BigInt(t);
}

/** "Never expires": uint64 max, as carried by live resting orders. Used for every order type. */
export const EXPIRATION_NEVER = (1n << 64n) - 1n;

/**
 * Expiration for a timed order from a unix timestamp in SECONDS.
 *
 * @experimental Only {@link EXPIRATION_NEVER} was verified live. The unit of a finite expiration
 * (seconds vs milliseconds) and the venue's behaviour at expiry were not observed; `placed_at` in
 * `orders` is in seconds, which is the basis for this guess.
 */
export function expirationAtUnixSeconds(unixSeconds: number): bigint {
  if (!Number.isInteger(unixSeconds) || unixSeconds <= 0)
    throw new RangeError(`unixSeconds must be a positive integer, got ${String(unixSeconds)}`);
  const v = BigInt(unixSeconds);
  if (v >= EXPIRATION_NEVER) throw new RangeError('expiration does not fit uint64');
  return v;
}
All files