src/orders/rules.ts
v0.1.0 · 6.2 KB
import { randomBytes } from 'node:crypto';
import type { PhoenixOrderId } from '../ids/order.js';
import type { PhoenixQuant } from '../numbers/quant.js';
import { MAX_CANCEL_IDS } from '../onchain/global.js';
import type { PhxCpi, PhxWriteAnswer } from '../signer/decode.js';
export function createIntentIds(opts: { prefix: string; randomBytes?: (size: number) => Uint8Array }) {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,23}$/.test(opts.prefix))
throw new TypeError('Caller-defined intent prefix required (1..24 characters)');
const boot = Buffer.from((opts.randomBytes ?? randomBytes)(16)).toString('hex');
let seq = 0n;
return {
next(op: string, symbol: string): string {
if (!/^[A-Za-z0-9._:-]{1,16}$/.test(op) || !/^[A-Za-z0-9._:-]{1,32}$/.test(symbol))
throw new TypeError('Invalid operation or symbol');
seq++;
const id = `${opts.prefix}-${boot}-${seq}-${op}-${symbol}`;
if (id.length > 128) throw new RangeError('Intent id exhausted');
return id;
},
};
}
export function iocLimitTicks(q: PhoenixQuant, price: number, isBuy: boolean): bigint {
return q.pxToTicks(price, isBuy ? 'floor' : 'ceil');
}
/** @experimental Band-edge inclusivity and fill behavior at the edge are unverified. */
export function clampIocToBand(
q: PhoenixQuant,
ticks: bigint,
isBuy: boolean,
band: { min: number; max: number } | null,
): bigint {
if (!band) return ticks;
if (!(Number.isFinite(band.min) && Number.isFinite(band.max) && band.min >= 0 && band.max > band.min)) return ticks;
const lo = q.pxToTicks(band.min, 'ceil');
const hi = q.pxToTicks(band.max, 'floor');
if (hi < lo) return 0n;
// Clamping never weakens the caller's cap. If cap and band do not intersect, skip.
if (isBuy) {
if (ticks < lo) return 0n;
return ticks > hi ? hi : ticks;
}
if (ticks > hi) return 0n;
return ticks < lo ? lo : ticks;
}
export function reducibleLots(positionLots: bigint, isBuy: boolean): bigint {
return isBuy ? (positionLots < 0n ? -positionLots : 0n) : positionLots > 0n ? positionLots : 0n;
}
export function planReduceOnly(opts: {
positionLots: bigint;
requestedLots: bigint;
isBuy: boolean;
fullClose: boolean;
}): { send: true; lots: bigint } | { send: false; reason: string } {
const reducible = reducibleLots(opts.positionLots, opts.isBuy);
if (reducible === 0n) return { send: false, reason: 'No reducible position' };
const lots = opts.fullClose ? reducible : opts.requestedLots < reducible ? opts.requestedLots : reducible;
return lots > 0n ? { send: true, lots } : { send: false, reason: 'No positive lots' };
}
export function resolveIocFill(opts: {
cpi: PhxCpi | null;
sentLots: bigint;
isBuy: boolean;
beforeLots: bigint;
read: { lots: bigint; slot: number } | null;
txSlot: number;
}): { filledLots: bigint } | { rejected: true; reason: string } {
const c = opts.cpi?.filledBaseLots ?? null;
if (c === 0n) return { rejected: true, reason: 'No fill reported by transaction' };
if (!opts.read || opts.read.slot < opts.txSlot)
return { rejected: true, reason: 'Position read has not reached transaction slot' };
const d = opts.isBuy ? opts.read.lots - opts.beforeLots : opts.beforeLots - opts.read.lots;
if (d <= 0n) return { rejected: true, reason: 'No positive position delta' };
if (c === null && opts.read.slot !== opts.txSlot)
return { rejected: true, reason: 'Delta without return data requires exact transaction slot' };
const lots = [d, opts.sentLots, ...(c === null ? [] : [c])].reduce((a, b) => (a < b ? a : b));
return lots > 0n ? { filledLots: lots } : { rejected: true, reason: 'No verified fill' };
}
export function avgFillPx(q: PhoenixQuant, cpi: PhxCpi | null): number | null {
if (!cpi || cpi.filledBaseLots <= 0n) return null;
const px = Number(cpi.filledQuoteLots) / 1e6 / q.lotsToSize(cpi.filledBaseLots);
return Number.isFinite(px) && px > 0 ? px : null;
}
export function cancelOutcomeFor(answer: PhxWriteAnswer, id: PhoenixOrderId): boolean {
return (
answer.status === 'confirmed' &&
answer.cancel?.effective === true &&
!answer.cancel.notFound.some((n) => n.priceInTicks === id.priceTicks && n.seq === id.seq)
);
}
export function chunkCancelIds<T>(ids: T[], max: number): T[][] {
if (!Number.isSafeInteger(max) || max < 1 || max > MAX_CANCEL_IDS)
throw new TypeError('Cancel batch must be 1..MAX_CANCEL_IDS');
const chunks: T[][] = [];
for (let i = 0; i < ids.length; i += max) chunks.push(ids.slice(i, i + max));
return chunks;
}
export function isSplittableCancelFailure(reason?: string | null, message?: string | null): boolean {
return /tx_too_large|1232|ids: 1\.\.\d+|ComputationalBudgetExceeded|exceeded CUs|compute budget/i.test(
`${reason ?? ''} ${message ?? ''}`,
);
}
/** @experimental Event names follow rise-public Rust source; live TooManyLimitOrders response is unverified. */
export function isTooManyOrders(reason?: string | null, message?: string | null): boolean {
return reason === 'TooManyLimitOrders' || /too\s*many\s*(limit\s*)?orders/i.test(`${reason ?? ''} ${message ?? ''}`);
}
/** @experimental Only explicit venue band rejections are evidence; no-fill is not proof. */
export function isBandEvidence(reason: string | null, clamped: boolean): boolean {
return clamped && reason === 'OutsideExecutionPriceBand';
}
export interface CancelBatchResult {
ok: boolean;
reason?: string | null;
message?: string | null;
}
export function createCancelBatcher<T>(opts: { max: number; send: (ids: T[]) => Promise<CancelBatchResult> }) {
chunkCancelIds([], opts.max);
let learned = opts.max;
async function one(ids: T[]): Promise<Array<{ ids: T[]; result: CancelBatchResult }>> {
const r = await opts.send(ids);
if (!r.ok && ids.length > 1 && isSplittableCancelFailure(r.reason, r.message)) {
learned = Math.min(learned, Math.max(1, Math.floor(ids.length / 2)));
const out: Array<{ ids: T[]; result: CancelBatchResult }> = [];
for (const batch of chunkCancelIds(ids, learned)) out.push(...(await one(batch)));
return out;
}
return [{ ids, result: r }];
}
return {
max: () => learned,
async cancel(ids: T[]) {
const out: Array<{ ids: T[]; result: CancelBatchResult }> = [];
for (const batch of chunkCancelIds(ids, learned)) out.push(...(await one(batch)));
return out;
},
};
}