Skip to content
markpaper

src/orders/build.ts

v0.2.0 · 8.4 KB

Download file
// Building a `place_order`: order type for the market, message, typed data, wire body.
//
// Payload (`orders.md` §1):
//   { place_order: { product_id, order: { sender, priceX18, amount, expiration, nonce, appendix }, signature } }
// `amount` is signed (+ buy, - sell); price and size must already be on the grid; expiration is
// 2^64 - 1; the nonce carries recv_time + our tag (+ an optional caller-defined flag bit); the appendix carries type and reduce-only.
// The response is `{ digest }` and nothing else.

import type { OrderType } from '../markets/types.js';
import { isOnGrid } from '../numbers/quant.js';
import { buildAppendix, EXPIRATION_NEVER, isTakerType } from '../signing/appendix.js';
import { buildOrderNonce } from '../signing/nonce.js';
import { isBytes32 } from '../signing/subaccount.js';
import {
  buildOrderTypedData,
  type OrderTypedData,
  orderDigest,
  orderToWire,
  signTypedDataWith,
} from '../signing/typedData.js';
import type { Hex, OrderMessage, SignerLike, WireOrder } from '../signing/types.js';
import { NadoRejection } from '../transport/errors.js';
import { hasUsableDigest } from '../transport/interpret.js';
import type { ExecuteRequester } from '../transport/types.js';

/** Time-in-force intent of the caller: rest in the book or take now. */
export type OrderIntent = 'resting' | 'ioc' | 'fok';

/**
 * Order type for THIS market (pure, knowledge base `orders.md` §4.2):
 *
 * - a taker intent stays a taker with its real reduce-only bit. On a `post_only` market takers do not
 *   exist; the venue's refusal (2117) must reach the caller instead of being rewritten into POST_ONLY / FOK;
 * - a resting intent goes out as POST_ONLY when the FRESH cache says the market is post-only (without
 *   reduce-only - impossible on a resting order; a reduce intent can be kept in the nonce flag bit); otherwise DEFAULT,
 *   and a 2117 refusal is then repaired by re-sending as POST_ONLY (see `interpretExecuteError`).
 *
 * `postOnlyMarket` is `undefined` when the status is unknown or stale (see `isPostOnlyMarket`).
 */
export function orderTypeForMarket(input: {
  intent: OrderIntent;
  reduceOnly: boolean;
  postOnlyMarket: boolean | undefined;
}): {
  orderType: OrderType;
  reduceOnly: boolean;
} {
  if (input.intent === 'ioc') return { orderType: 'ioc', reduceOnly: input.reduceOnly };
  if (input.intent === 'fok') return { orderType: 'fok', reduceOnly: input.reduceOnly };
  return { orderType: input.postOnlyMarket === true ? 'post_only' : 'default', reduceOnly: false };
}

/** Product grid needed to build an order: the SAME object must feed `product_id` and the signing domain. */
export interface OrderProduct {
  readonly productId: number;
  readonly tickX18: bigint;
  readonly lotX18: bigint;
}

/** Inputs of {@link buildPlaceOrder}. */
export interface BuildPlaceOrderInput {
  product: OrderProduct;
  chainId: number;
  /** bytes32 subaccount of the MASTER account (even when a linked signer signs). */
  sender: Hex;
  isBuy: boolean;
  /** Price on the tick grid, x18. */
  priceX18: bigint;
  /** Absolute size on the lot grid, x18. */
  sizeX18: bigint;
  orderType: OrderType;
  /** Only with IOC / FOK (the builder throws otherwise). */
  reduceOnly?: boolean;
  /** Explicit nonce, or the inputs to build one from `nowMs`. */
  nonce?: bigint | { nowMs: number; tag?: number; reduceIntent?: boolean; random?: number; windowMs?: number };
  /** Default {@link EXPIRATION_NEVER}. */
  expiration?: bigint;
  /** Skip the grid check (only when the caller already quantized with the same product). Default false. */
  skipGridCheck?: boolean;
}

/** An order ready to sign. */
export interface PreparedOrder {
  readonly productId: number;
  readonly orderType: OrderType;
  readonly reduceOnly: boolean;
  readonly isBuy: boolean;
  /** Message with bigint fields (what gets signed). */
  readonly order: OrderMessage;
  readonly typedData: OrderTypedData;
  /** Message as it goes into the JSON body. */
  readonly wireOrder: WireOrder;
  /** EIP-712 hash of the typed data. @experimental see `orderDigest`. */
  readonly expectedDigest: Hex;
}

/** Execute body of `place_order`. */
export type PlaceOrderBody = {
  place_order: { product_id: number; order: WireOrder; signature: Hex };
};

/**
 * Builds the order message and typed data. Validates: bytes32 sender, positive price on the tick,
 * positive size on the lot, reduce-only only with takers.
 */
export function buildPlaceOrder(input: BuildPlaceOrderInput): PreparedOrder {
  const { product } = input;
  if (!isBytes32(input.sender)) throw new TypeError('sender must be a bytes32 subaccount');
  if (input.priceX18 <= 0n) throw new RangeError(`priceX18 must be positive, got ${input.priceX18}`);
  if (input.sizeX18 <= 0n)
    throw new RangeError(
      `sizeX18 must be positive, got ${input.sizeX18} (a size that quantizes to 0 is a SKIP, not an order)`,
    );
  if (!input.skipGridCheck) {
    if (!isOnGrid(input.priceX18, product.tickX18))
      throw new RangeError(`priceX18 ${input.priceX18} is not on the tick ${product.tickX18}`);
    if (!isOnGrid(input.sizeX18, product.lotX18))
      throw new RangeError(`sizeX18 ${input.sizeX18} is not on the lot ${product.lotX18}`);
  }
  const reduceOnly = input.reduceOnly ?? false;
  const appendix = buildAppendix(input.orderType, reduceOnly); // throws on resting + reduce-only
  const nonce =
    typeof input.nonce === 'bigint'
      ? input.nonce
      : buildOrderNonce((input.nonce ?? { nowMs: Date.now() }).nowMs, input.nonce ?? {});
  const order: OrderMessage = {
    sender: input.sender,
    priceX18: input.priceX18,
    amount: input.isBuy ? input.sizeX18 : -input.sizeX18,
    expiration: input.expiration ?? EXPIRATION_NEVER,
    nonce,
    appendix,
  };
  const typedData = buildOrderTypedData({ chainId: input.chainId, productId: product.productId, order });
  return {
    productId: product.productId,
    orderType: input.orderType,
    reduceOnly,
    isBuy: input.isBuy,
    order,
    typedData,
    wireOrder: orderToWire(order),
    expectedDigest: orderDigest({ chainId: input.chainId, productId: product.productId, order }),
  };
}

/** Attaches a signature to a prepared order: the execute body. */
export function placeOrderBody(prepared: PreparedOrder, signature: Hex): PlaceOrderBody {
  return { place_order: { product_id: prepared.productId, order: prepared.wireOrder, signature } };
}

/** Builds and signs in one step. */
export async function signPlaceOrder(
  signer: SignerLike,
  input: BuildPlaceOrderInput,
): Promise<{ prepared: PreparedOrder; body: PlaceOrderBody }> {
  const prepared = buildPlaceOrder(input);
  const signature = await signTypedDataWith(signer, prepared.typedData);
  return { prepared, body: placeOrderBody(prepared, signature) };
}

/** A sequencer refusal, as data. */
export interface PlaceRejection {
  readonly code: number;
  readonly message: string;
}

/** Outcome of {@link sendPlaceOrder}: a digest, or a definitive refusal. Transport errors are thrown. */
export type PlaceOutcome =
  | { readonly digest: Hex; readonly rejection?: undefined }
  | { readonly digest?: undefined; readonly rejection: PlaceRejection };

/**
 * Sends a `place_order` body. A failure envelope becomes `{ rejection }` (final, not applied); a success
 * without a usable digest is ALSO a rejection (code -1). Transport errors propagate: the outcome is
 * unknown and the caller must reconcile, not retry (unless the action is a cancel or a full close).
 */
export async function sendPlaceOrder(
  execute: ExecuteRequester,
  body: PlaceOrderBody,
  opts: { idempotent?: boolean; label?: string; signal?: AbortSignal } = {},
): Promise<PlaceOutcome> {
  try {
    const data = await execute<{ digest?: unknown }>(body, {
      idempotent: opts.idempotent ?? false,
      label: opts.label ?? 'place_order',
      signal: opts.signal,
    });
    if (!hasUsableDigest(data))
      return {
        rejection: {
          code: -1,
          message: `success without a usable digest (${String((data as { digest?: unknown })?.digest)})`,
        },
      };
    return { digest: data.digest.toLowerCase() as Hex };
  } catch (e) {
    if (e instanceof NadoRejection) return { rejection: { code: e.code, message: e.errorText } };
    throw e;
  }
}

/** True when a full reduce-only close may be marked idempotent for retries (taker + reduce-only + full close). */
export function isIdempotentPlacement(input: {
  orderType: OrderType;
  reduceOnly: boolean;
  fullClose: boolean;
}): boolean {
  return input.fullClose && input.reduceOnly && isTakerType(input.orderType);
}
All files