src/orders/build.test.ts
v0.2.0 · 8.1 KB
import { verifyTypedData } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { describe, expect, it } from 'vitest';
import { decimalToX18, X18 } from '../numbers/x18.js';
import { EXPIRATION_NEVER, ReduceOnlyNotTakerError } from '../signing/appendix.js';
import { parseNonce } from '../signing/nonce.js';
import { productVerifyingContract, subaccountBytes32 } from '../signing/subaccount.js';
import type { Hex } from '../signing/types.js';
import { NadoRejection } from '../transport/errors.js';
import type { ExecuteRequester } from '../transport/types.js';
import {
buildPlaceOrder,
isIdempotentPlacement,
orderTypeForMarket,
placeOrderBody,
sendPlaceOrder,
signPlaceOrder,
} from './build.js';
const master = privateKeyToAccount(generatePrivateKey());
const signer = privateKeyToAccount(generatePrivateKey());
const sender = subaccountBytes32(master.address, 'default');
const BTC = { productId: 2, tickX18: X18, lotX18: 50000000000000n };
const CHAIN_ID = 57073;
const NOW = 1_780_000_000_000; // synthetic round clock
describe('orderTypeForMarket', () => {
it('takers keep their type and reduce-only bit whatever the market says', () => {
expect(orderTypeForMarket({ intent: 'ioc', reduceOnly: true, postOnlyMarket: true })).toEqual({
orderType: 'ioc',
reduceOnly: true,
});
expect(orderTypeForMarket({ intent: 'ioc', reduceOnly: false, postOnlyMarket: undefined })).toEqual({
orderType: 'ioc',
reduceOnly: false,
});
expect(orderTypeForMarket({ intent: 'fok', reduceOnly: true, postOnlyMarket: true })).toEqual({
orderType: 'fok',
reduceOnly: true,
});
});
it('resting: POST_ONLY on a fresh post-only market, DEFAULT otherwise, never reduce-only', () => {
expect(orderTypeForMarket({ intent: 'resting', reduceOnly: true, postOnlyMarket: true })).toEqual({
orderType: 'post_only',
reduceOnly: false,
});
expect(orderTypeForMarket({ intent: 'resting', reduceOnly: true, postOnlyMarket: false })).toEqual({
orderType: 'default',
reduceOnly: false,
});
expect(orderTypeForMarket({ intent: 'resting', reduceOnly: false, postOnlyMarket: undefined })).toEqual({
orderType: 'default',
reduceOnly: false,
});
});
});
describe('buildPlaceOrder', () => {
const base = {
product: BTC,
chainId: CHAIN_ID,
sender,
isBuy: false,
priceX18: 66120n * X18,
sizeX18: decimalToX18('0.0005'),
};
it('produces the knowledge-base payload: signed amount, u64 max expiration, appendix 2561, product domain', () => {
const p = buildPlaceOrder({
...base,
orderType: 'ioc',
reduceOnly: true,
nonce: { nowMs: NOW, tag: 0x321, reduceIntent: true, random: 9 },
});
expect(p.productId).toBe(2);
expect(p.order.amount).toBe(-decimalToX18('0.0005'));
expect(p.order.expiration).toBe(EXPIRATION_NEVER);
expect(p.order.appendix).toBe(2561n);
expect(parseNonce(p.order.nonce)).toMatchObject({
recvTimeMs: NOW + 60_000,
tag: 0x321,
reduceIntent: true,
random: 9,
});
expect(p.typedData.domain.verifyingContract).toBe(productVerifyingContract(2));
expect(p.typedData.domain.chainId).toBe(CHAIN_ID);
expect(p.wireOrder).toEqual({
sender,
priceX18: '66120000000000000000000',
amount: '-500000000000000',
expiration: '18446744073709551615',
nonce: p.order.nonce.toString(),
appendix: '2561',
});
expect(p.expectedDigest).toMatch(/^0x[0-9a-f]{64}$/);
const body = placeOrderBody(p, '0xsig' as Hex);
expect(body).toEqual({ place_order: { product_id: 2, order: p.wireOrder, signature: '0xsig' } });
});
it('buy orders carry a positive amount; explicit nonce and expiration are honoured', () => {
const p = buildPlaceOrder({ ...base, isBuy: true, orderType: 'default', nonce: 12345n, expiration: 99n });
expect(p.order.amount).toBe(decimalToX18('0.0005'));
expect(p.order.nonce).toBe(12345n);
expect(p.order.expiration).toBe(99n);
expect(p.order.appendix).toBe(1n);
expect(p.reduceOnly).toBe(false);
});
it('refuses reduce-only on resting types, off-grid values, zero sizes and bad senders', () => {
expect(() => buildPlaceOrder({ ...base, orderType: 'default', reduceOnly: true })).toThrow(ReduceOnlyNotTakerError);
expect(() => buildPlaceOrder({ ...base, orderType: 'post_only', reduceOnly: true })).toThrow(/2067/);
expect(() => buildPlaceOrder({ ...base, orderType: 'ioc', priceX18: decimalToX18('66120.5') })).toThrow(
/not on the tick/,
);
expect(() => buildPlaceOrder({ ...base, orderType: 'ioc', sizeX18: decimalToX18('0.00012') })).toThrow(
/not on the lot/,
);
expect(
buildPlaceOrder({ ...base, orderType: 'ioc', sizeX18: decimalToX18('0.00012'), skipGridCheck: true }).order
.amount,
).toBe(-decimalToX18('0.00012'));
expect(() => buildPlaceOrder({ ...base, orderType: 'ioc', sizeX18: 0n })).toThrow(/SKIP/);
expect(() => buildPlaceOrder({ ...base, orderType: 'ioc', priceX18: 0n })).toThrow(RangeError);
expect(() => buildPlaceOrder({ ...base, orderType: 'ioc', sender: '0x12' as Hex })).toThrow(TypeError);
});
it('signPlaceOrder yields a signature that verifies under the product domain', async () => {
const { prepared, body } = await signPlaceOrder(signer, {
...base,
orderType: 'ioc',
reduceOnly: true,
nonce: { nowMs: NOW },
});
expect(body.place_order.product_id).toBe(2);
expect(
await verifyTypedData({ ...prepared.typedData, signature: body.place_order.signature, address: signer.address }),
).toBe(true);
expect(
await verifyTypedData({
...prepared.typedData,
domain: { ...prepared.typedData.domain, verifyingContract: productVerifyingContract(18) },
signature: body.place_order.signature,
address: signer.address,
}),
).toBe(false);
});
});
describe('sendPlaceOrder', () => {
const body = placeOrderBody(
buildPlaceOrder({
product: BTC,
chainId: CHAIN_ID,
sender,
isBuy: true,
priceX18: X18,
sizeX18: BTC.lotX18,
orderType: 'ioc',
nonce: 1n,
}),
'0x00' as Hex,
);
const digest = `0x${'AB'.repeat(32)}`;
it('returns the digest (lower-cased) and forwards idempotency / label', async () => {
const seen: unknown[] = [];
const execute: ExecuteRequester = async <T>(_b: unknown, opts?: unknown) => {
seen.push(opts);
return { digest } as T;
};
const out = await sendPlaceOrder(execute, body, { idempotent: true, label: 'order:close' });
expect(out).toEqual({ digest: digest.toLowerCase() });
expect(seen[0]).toMatchObject({ idempotent: true, label: 'order:close' });
await sendPlaceOrder(execute, body);
expect(seen[1]).toMatchObject({ idempotent: false, label: 'place_order' });
});
it('maps a failure envelope and a success without digest to rejections; transport errors propagate', async () => {
const rejecting: ExecuteRequester = async () => {
throw new NadoRejection({ code: 2117, error: 'post-only', path: 'execute' });
};
expect(await sendPlaceOrder(rejecting, body)).toEqual({ rejection: { code: 2117, message: 'post-only' } });
const noDigest: ExecuteRequester = async <T>() => ({ digest: '0x12' }) as T;
expect(await sendPlaceOrder(noDigest, body)).toMatchObject({ rejection: { code: -1 } });
const failing: ExecuteRequester = async () => {
throw new Error('timeout');
};
await expect(sendPlaceOrder(failing, body)).rejects.toThrow('timeout');
});
});
describe('isIdempotentPlacement', () => {
it('only a full reduce-only taker close', () => {
expect(isIdempotentPlacement({ orderType: 'ioc', reduceOnly: true, fullClose: true })).toBe(true);
expect(isIdempotentPlacement({ orderType: 'fok', reduceOnly: true, fullClose: true })).toBe(true);
expect(isIdempotentPlacement({ orderType: 'ioc', reduceOnly: true, fullClose: false })).toBe(false);
expect(isIdempotentPlacement({ orderType: 'ioc', reduceOnly: false, fullClose: true })).toBe(false);
expect(isIdempotentPlacement({ orderType: 'default', reduceOnly: false, fullClose: true })).toBe(false);
});
});