src/orders/cancel.ts
v0.2.0 · 5.9 KB
// 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;
}
}