src/signing/appendix.ts
v0.2.0 · 5.3 KB
// 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;
}