src/signing/typedData.ts
v0.2.0 · 8.9 KB
// Typed data builders and signing helpers for `place_order`, `cancel_orders` and `link_signer`.
//
// The kit never holds keys: signing goes through a viem `Account` (`privateKeyToAccount`), a
// `WalletClient` wrapped in a function, or anything with a compatible `signTypedData`.
import { hashTypedData, verifyTypedData } from 'viem';
import { toWire } from '../numbers/x18.js';
import { isBytes32, normalizeAddress, productVerifyingContract } from './subaccount.js';
import {
type Address,
CANCELLATION_PRODUCTS_TYPES,
CANCELLATION_TYPES,
type CancellationMessage,
type CancellationProductsMessage,
type Hex,
LINK_SIGNER_TYPES,
type LinkSignerMessage,
type NadoDomain,
nadoDomain,
ORDER_TYPES,
type OrderMessage,
type SignerLike,
type WireOrder,
} from './types.js';
/** Typed data of an `Order`, ready for `signTypedData` / `hashTypedData`. */
export interface OrderTypedData {
readonly domain: NadoDomain;
readonly types: typeof ORDER_TYPES;
readonly primaryType: 'Order';
readonly message: OrderMessage;
}
/** Typed data of a `Cancellation`. */
export interface CancellationTypedData {
readonly domain: NadoDomain;
readonly types: typeof CANCELLATION_TYPES;
readonly primaryType: 'Cancellation';
readonly message: CancellationMessage;
}
/** Typed data of a `CancellationProducts`. @experimental */
export interface CancellationProductsTypedData {
readonly domain: NadoDomain;
readonly types: typeof CANCELLATION_PRODUCTS_TYPES;
readonly primaryType: 'CancellationProducts';
readonly message: CancellationProductsMessage;
}
/** Typed data of a `LinkSigner`. */
export interface LinkSignerTypedData {
readonly domain: NadoDomain;
readonly types: typeof LINK_SIGNER_TYPES;
readonly primaryType: 'LinkSigner';
readonly message: LinkSignerMessage;
}
const INT128_MAX = (1n << 127n) - 1n;
const INT128_MIN = -(1n << 127n);
const UINT64_MAX = (1n << 64n) - 1n;
const UINT128_MAX = (1n << 128n) - 1n;
function assertRange(name: string, v: bigint, min: bigint, max: bigint): void {
if (typeof v !== 'bigint') throw new TypeError(`${name} must be a bigint`);
if (v < min || v > max) throw new RangeError(`${name}=${v} is out of range`);
}
/** Validates the field ranges of an order message (int128 / uint64 / uint128, bytes32 sender). */
export function assertOrderMessage(order: OrderMessage): void {
if (!isBytes32(order.sender)) throw new TypeError('order.sender must be a bytes32 subaccount');
assertRange('priceX18', order.priceX18, INT128_MIN, INT128_MAX);
assertRange('amount', order.amount, INT128_MIN, INT128_MAX);
assertRange('expiration', order.expiration, 0n, UINT64_MAX);
assertRange('nonce', order.nonce, 0n, UINT64_MAX);
assertRange('appendix', order.appendix, 0n, UINT128_MAX);
if (order.priceX18 <= 0n) throw new RangeError('order.priceX18 must be positive');
if (order.amount === 0n) throw new RangeError('order.amount must not be zero');
}
/**
* Typed data of `place_order`: domain `verifyingContract = address(productId)`. Take `productId` from the
* SAME product object that provides `product_id` in the body: a signature and a body built from
* different sources are the "wrong instrument" failure mode.
*/
export function buildOrderTypedData(input: {
chainId: number;
productId: number;
order: OrderMessage;
}): OrderTypedData {
assertOrderMessage(input.order);
return {
domain: nadoDomain(input.chainId, productVerifyingContract(input.productId)),
types: ORDER_TYPES,
primaryType: 'Order',
message: input.order,
};
}
/** Typed data of `cancel_orders`: domain `verifyingContract = endpoint_addr` from `contracts`. */
export function buildCancelTypedData(input: {
chainId: number;
endpointAddr: string;
tx: CancellationMessage;
}): CancellationTypedData {
const { tx } = input;
if (!isBytes32(tx.sender)) throw new TypeError('tx.sender must be a bytes32 subaccount');
if (tx.productIds.length !== tx.digests.length)
throw new RangeError('productIds and digests must have the same length');
if (tx.productIds.length === 0) throw new RangeError('nothing to cancel');
for (const d of tx.digests) if (!isBytes32(d)) throw new TypeError(`digest ${String(d)} is not bytes32`);
for (const p of tx.productIds)
if (!Number.isSafeInteger(p) || p < 0) throw new RangeError(`bad productId ${String(p)}`);
assertRange('nonce', tx.nonce, 0n, UINT64_MAX);
return {
domain: nadoDomain(input.chainId, normalizeAddress(input.endpointAddr)),
types: CANCELLATION_TYPES,
primaryType: 'Cancellation',
message: tx,
};
}
/**
* Typed data of a cancel-by-products action.
*
* @experimental The struct exists in the signing schema; the execute body and response were not verified live.
*/
export function buildCancelProductsTypedData(input: {
chainId: number;
endpointAddr: string;
tx: CancellationProductsMessage;
}): CancellationProductsTypedData {
const { tx } = input;
if (!isBytes32(tx.sender)) throw new TypeError('tx.sender must be a bytes32 subaccount');
if (tx.productIds.length === 0) throw new RangeError('nothing to cancel');
assertRange('nonce', tx.nonce, 0n, UINT64_MAX);
return {
domain: nadoDomain(input.chainId, normalizeAddress(input.endpointAddr)),
types: CANCELLATION_PRODUCTS_TYPES,
primaryType: 'CancellationProducts',
message: tx,
};
}
/**
* Typed data of `link_signer`. Signed by the MASTER wallet (in a browser popup, never on a server) with
* the incrementing `tx_nonce` from the `nonces` query - not a recv_time nonce. Revoke = link the zero
* address (`signerBytes32(ZERO_ADDRESS)`).
*/
export function buildLinkSignerTypedData(input: {
chainId: number;
endpointAddr: string;
tx: LinkSignerMessage;
}): LinkSignerTypedData {
const { tx } = input;
if (!isBytes32(tx.sender)) throw new TypeError('tx.sender must be a bytes32 subaccount');
if (!isBytes32(tx.signer)) throw new TypeError('tx.signer must be bytes32 (address + 12 zero bytes)');
assertRange('nonce', tx.nonce, 0n, UINT64_MAX);
return {
domain: nadoDomain(input.chainId, normalizeAddress(input.endpointAddr)),
types: LINK_SIGNER_TYPES,
primaryType: 'LinkSigner',
message: tx,
};
}
type AnyTypedData = OrderTypedData | CancellationTypedData | CancellationProductsTypedData | LinkSignerTypedData;
/** Calls the signer with typed data; accepts an object with `signTypedData` or a bare function. */
export async function signTypedDataWith(signer: SignerLike, typedData: AnyTypedData): Promise<Hex> {
const params = typedData as unknown as Parameters<Extract<SignerLike, (...args: never) => unknown>>[0];
const signature = typeof signer === 'function' ? await signer(params) : await signer.signTypedData(params);
if (typeof signature !== 'string' || !/^0x[0-9a-fA-F]+$/.test(signature))
throw new TypeError('signer returned a malformed signature');
return signature;
}
/** Signs an order (see {@link buildOrderTypedData}). */
export function signOrder(
signer: SignerLike,
input: { chainId: number; productId: number; order: OrderMessage },
): Promise<Hex> {
return signTypedDataWith(signer, buildOrderTypedData(input));
}
/** Signs a cancellation by digests (see {@link buildCancelTypedData}). */
export function signCancellation(
signer: SignerLike,
input: { chainId: number; endpointAddr: string; tx: CancellationMessage },
): Promise<Hex> {
return signTypedDataWith(signer, buildCancelTypedData(input));
}
/** Signs a `LinkSigner` with the master wallet (see {@link buildLinkSignerTypedData}). */
export function signLinkSigner(
signer: SignerLike,
input: { chainId: number; endpointAddr: string; tx: LinkSignerMessage },
): Promise<Hex> {
return signTypedDataWith(signer, buildLinkSignerTypedData(input));
}
/** Order message with every bigint as a decimal string (the JSON body form). */
export function orderToWire(order: OrderMessage): WireOrder {
return {
sender: order.sender,
priceX18: toWire(order.priceX18),
amount: toWire(order.amount),
expiration: toWire(order.expiration),
nonce: toWire(order.nonce),
appendix: toWire(order.appendix),
};
}
/**
* EIP-712 hash of the order typed data.
*
* @experimental The knowledge base only records that `place_order` RETURNS a 32-byte digest; whether that
* digest equals the EIP-712 struct hash (as on the Vertex stack this venue descends from) was not
* verified. Compare with the digest of a live response before relying on it, e.g. to pre-register a
* cancel target. Cancel confirmation must still come from `cancelled_orders`.
*/
export function orderDigest(input: { chainId: number; productId: number; order: OrderMessage }): Hex {
return hashTypedData(buildOrderTypedData(input));
}
/** Verifies an order signature against an address (pure EOA recovery, no network). */
export function verifyOrderSignature(input: {
chainId: number;
productId: number;
order: OrderMessage;
signature: Hex;
address: Address;
}): Promise<boolean> {
return verifyTypedData({ ...buildOrderTypedData(input), signature: input.signature, address: input.address });
}