src/orders/build.ts
v0.2.0 · 8.4 KB
// 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);
}