Skip to content
markpaper

src/orders/cancel.ts

v0.2.0 · 5.9 KB

Download file
// Cancelling by digest and confirming it.
//
// `cancel_orders: { tx: { sender, productIds, digests, nonce }, signature }` (signed against
// `endpoint_addr`, recv_time nonce) -> `{ cancelled_orders: [{ digest, ... }] }`.
// A cancel is CONFIRMED only when our digest is listed in `cancelled_orders`. Everything else -
// transport failure, a response without the digest, `2020 OrderNotFound` (already cancelled OR filled),
// an unknown digest after a restart - is "not confirmed": do not place a replacement until the book is
// re-read. Cancels are idempotent: a transient error may be retried. Cancel first, place after.

import { buildCancelNonce } from '../signing/nonce.js';
import { isBytes32 } from '../signing/subaccount.js';
import { buildCancelTypedData, type CancellationTypedData, signTypedDataWith } from '../signing/typedData.js';
import type { CancellationMessage, Hex, SignerLike } from '../signing/types.js';
import { NadoRejection } from '../transport/errors.js';
import type { ExecuteRequester } from '../transport/types.js';

/** One order to cancel. */
export interface CancelTarget {
  readonly productId: number;
  readonly digest: Hex;
}

/** Inputs of {@link buildCancelOrders}. */
export interface BuildCancelInput {
  chainId: number;
  /** `endpoint_addr` from the verified `contracts` query. */
  endpointAddr: string;
  sender: Hex;
  targets: readonly CancelTarget[];
  /** Explicit nonce or `nowMs` to build a cancel nonce from. */
  nonce?: bigint | { nowMs: number; windowMs?: number; random?: number };
}

/** A cancellation ready to sign. */
export interface PreparedCancel {
  readonly tx: CancellationMessage;
  readonly typedData: CancellationTypedData;
  readonly digests: readonly Hex[];
}

/** Execute body of `cancel_orders`. */
export type CancelOrdersBody = {
  cancel_orders: {
    tx: { sender: Hex; productIds: number[]; digests: Hex[]; nonce: string };
    signature: Hex;
  };
};

/** Builds the cancellation message and typed data (digests are lower-cased and deduplicated). */
export function buildCancelOrders(input: BuildCancelInput): PreparedCancel {
  const seen = new Set<string>();
  const productIds: number[] = [];
  const digests: Hex[] = [];
  for (const t of input.targets) {
    if (!isBytes32(t.digest)) throw new TypeError(`digest ${String(t.digest)} is not bytes32`);
    const d = t.digest.toLowerCase() as Hex;
    if (seen.has(d)) continue;
    seen.add(d);
    productIds.push(t.productId);
    digests.push(d);
  }
  const nonce =
    typeof input.nonce === 'bigint'
      ? input.nonce
      : buildCancelNonce((input.nonce ?? { nowMs: Date.now() }).nowMs, input.nonce ?? {});
  const tx: CancellationMessage = { sender: input.sender, productIds, digests, nonce };
  const typedData = buildCancelTypedData({ chainId: input.chainId, endpointAddr: input.endpointAddr, tx });
  return { tx, typedData, digests };
}

/** Attaches a signature: the execute body. */
export function cancelOrdersBody(prepared: PreparedCancel, signature: Hex): CancelOrdersBody {
  return {
    cancel_orders: {
      tx: {
        sender: prepared.tx.sender,
        productIds: [...prepared.tx.productIds],
        digests: [...prepared.tx.digests],
        nonce: prepared.tx.nonce.toString(),
      },
      signature,
    },
  };
}

/** Builds and signs in one step. */
export async function signCancelOrders(
  signer: SignerLike,
  input: BuildCancelInput,
): Promise<{ prepared: PreparedCancel; body: CancelOrdersBody }> {
  const prepared = buildCancelOrders(input);
  const signature = await signTypedDataWith(signer, prepared.typedData);
  return { prepared, body: cancelOrdersBody(prepared, signature) };
}

/** Which digests the response confirmed. */
export interface CancelConfirmation {
  /** Every requested digest was listed in `cancelled_orders`. */
  readonly ok: boolean;
  readonly confirmed: readonly Hex[];
  /** Requested digests NOT listed: not confirmed - re-read the book before replacing them. */
  readonly unconfirmed: readonly Hex[];
}

/** Compares the `cancelled_orders` of a response with the requested digests. A malformed response confirms nothing. */
export function confirmCancelled(data: unknown, requested: readonly Hex[]): CancelConfirmation {
  const listed = new Set<string>();
  const rows = (data as { cancelled_orders?: unknown } | null)?.cancelled_orders;
  if (Array.isArray(rows)) {
    for (const row of rows) {
      const d = (row as { digest?: unknown } | null)?.digest;
      if (typeof d === 'string') listed.add(d.toLowerCase());
    }
  }
  const confirmed: Hex[] = [];
  const unconfirmed: Hex[] = [];
  for (const d of requested) (listed.has(d.toLowerCase()) ? confirmed : unconfirmed).push(d.toLowerCase() as Hex);
  return { ok: unconfirmed.length === 0 && requested.length > 0, confirmed, unconfirmed };
}

/** Result of {@link sendCancelOrders}. */
export type CancelOutcome =
  | ({ readonly rejection?: undefined } & CancelConfirmation)
  | {
      readonly ok: false;
      readonly confirmed: readonly [];
      readonly unconfirmed: readonly Hex[];
      readonly rejection: { code: number; message: string };
    };

/**
 * Sends a cancel body (idempotent: transient errors are retried by the client) and confirms digests. A
 * failure envelope (including `2020 OrderNotFound`) confirms nothing. Transport errors propagate.
 */
export async function sendCancelOrders(
  execute: ExecuteRequester,
  body: CancelOrdersBody,
  opts: { label?: string; signal?: AbortSignal } = {},
): Promise<CancelOutcome> {
  const requested = body.cancel_orders.tx.digests;
  try {
    const data = await execute(body, { idempotent: true, label: opts.label ?? 'cancel_orders', signal: opts.signal });
    return confirmCancelled(data, requested);
  } catch (e) {
    if (e instanceof NadoRejection) {
      return {
        ok: false,
        confirmed: [],
        unconfirmed: requested.map((d) => d.toLowerCase() as Hex),
        rejection: { code: e.code, message: e.errorText },
      };
    }
    throw e;
  }
}
All files