README.md
v0.2.0 · 15 KB
# @markpaper/nado-kit
**Summary (EN).** A TypeScript toolkit for [Nado](https://nado.xyz), the CLOB perp DEX on Ink (ex-Vertex stack), which
has no official JS SDK. It turns the practical rules of the markpaper Nado knowledge base (`knowledge/nado/`) into
small, tested, network-free functions: x18 numbers and lot/tick quantization, `symbols` decoding with a stale-aware
cache, EIP-712 typed data for `place_order` / `cancel_orders` / `link_signer`, the recv-time nonce with a process tag,
the order appendix, a gateway client with envelope parsing, error classification and a two-bucket weight throttle,
`subaccount_info` parsing with positions and equity, chunked order reads with a completeness flag, and IOC fill
measurement by position delta. Signing goes through a `viem` account you provide; the kit never holds keys.
Requires Node.js 22.12+, ESM only, Apache-2.0.
---
A collection of functions for the Nado exchange (a CLOB perpetual DEX on Ink, based on the ex-Vertex stack). Nado has
no official JS SDK: requests are assembled and signed directly. This package is not an SDK “for everything”; it
contains the pieces that every project otherwise rewrites and breaks: x18 numbers, quantization to arbitrary lots,
`symbols` parsing, EIP-712 signatures against `address(productId)`, tagged nonces, response envelopes and rejection
codes, weight-based throttling, account and order reads, and IOC fill measurement from position delta. Every rule
comes from the markpaper repository's `knowledge/nado/` knowledge base; each rule has a reason recorded there.
## Installation
```sh
npm i @markpaper/nado-kit viem
```
Requires **Node.js 22.12+**, ESM only. `viem` is a peer dependency used to sign typed data and verify signatures.
There are no network dependencies: HTTP uses global `fetch` (undici), which negotiates compression itself (Nado
returns 403 to clients without it — do not set the `Accept-Encoding` header manually).
## Imports
Everything is exported flat from the package root, and every module is also available as a namespace:
```ts
import { createGatewayClient, parseSymbols, buildPlaceOrder } from '@markpaper/nado-kit';
import { numbers, markets, signing, transport, account, orders } from '@markpaper/nado-kit';
```
| Namespace | Contents |
| --- | --- |
| `numbers` | lossless x18 ↔ decimal string / bigint, price and size quantization by tick and lot, notional minimum |
| `markets` | types and fail-closed `symbols` parser, leverage from weights, `trading_status` → accepted market actions, cache with TTL, stale serving, and stability checks |
| `signing` | EIP-712 domain and types, bytes32 sub-account, recv_time nonce with tag, appendix, typed data and signing through viem, digest (experimental) |
| `transport` | `/query` and `/execute` gateway client, response envelope, error classes, `interpretExecuteError`, weights, query/execute throttle, retries, network preflight |
| `account` | `parseSubaccountInfo`, positions with entry and uPnL, equity, chunked order reads with a completeness flag, positions → orders → positions fence |
| `orders` | order type for market mode, size planning with two minimums, `place_order` / `cancel_orders` body, cancellation confirmation, IOC fill from delta |
## Functions and their knowledge-base sources
| Function | What it does | Knowledge-base file |
| --- | --- | --- |
| `x18ToBigInt`, `parseUint`, `x18ToDecimalString`, `x18ToNumber`, `decimalToX18`, `numberToX18`, `stepDecimals`, `notionalX18`, `toWire` | x18 without floating point, strict string parsing, nonce > 2^53 | `markets-and-numbers.md` §2 |
| `quantizeSize`, `quantizePrice`, `quantizeX18`, `priceRoundingForSide`, `isOnGrid`, `createQuantizer` | quotient epsilon, size floor, ceil only for full close, side-aware price floor/ceil | `markets-and-numbers.md` §3 |
| `meetsMinSize`, `minSizeForNotional` | notional minimum (`min_size` = $100 in the book) | `orders.md` §5 |
| `parseSymbols`, `coinOfSymbol`, `checkProductsStable` | `XXX-PERP` object, one malformed product invalidates the whole response, ID/lot/tick stability excluding `trading_status` | `markets-and-numbers.md` §1, §7 |
| `maxLeverageFromWeights`, `initialMarginFraction`, `effectiveLongWeight` | leverage ≈ 1/(1 − long_weight_initial) | `markets-and-numbers.md` §4, `account-and-fees.md` §5 |
| `marketMode`, `acceptedOrderTypes`, `resolveMarketMode`, `isPostOnlyMarket`, `canActivateMarket` | `post_only` → POST_ONLY only (2117), `not_tradable` → nothing (2069); mode only from fresh cache | `markets-and-numbers.md` §5, `orders.md` §4 |
| `createSymbolsCache` | 5-minute TTL, serve stale, freshness = two TTLs, single-flight, `pinnedCoins` prevents freezes after a rename | `markets-and-numbers.md` §7 |
| `subaccountBytes32`, `signerBytes32`, `parseSubaccountBytes32`, `productVerifyingContract`, `ZERO_ADDRESS` | sender = master address + sub-account name; verifyingContract = address(productId) | `api-and-signing.md` §5–6 |
| `buildNonce`, `buildOrderNonce`, `buildCancelNonce`, `parseNonce`, `isOurNonce`, `nonceReduceIntent`, `recvTimeFor`, `isRecvTimeWithinWindow` | `(recv_time << 20) \| tag`, neutral reduce-intent bit in low bits, tag unchanged between deployments | `api-and-signing.md` §7 |
| `buildAppendix`, `parseAppendix`, `EXPIRATION_NEVER` | version 1, type in bits 10..9, reduceOnly (bit 11) only with IOC/FOK, live values 1 / 513 / 2561 / 1537 | `api-and-signing.md` §8–9 |
| `buildOrderTypedData`, `buildCancelTypedData`, `buildLinkSignerTypedData`, `signOrder`, `signCancellation`, `signLinkSigner`, `verifyOrderSignature`, `orderToWire` | typed data and signing through viem; domains separated by product | `api-and-signing.md` §5, §11–12 |
| `createGatewayClient`, `resolveGatewayUrl`, `parseEnvelope`, `unwrapEnvelope` | `/query` and `/execute`, `success` / `failure` envelope, plain text from malformed query, 10-second timeout, class-based retries | `api-and-signing.md` §1–2, §13 |
| `NadoRejection`, `NadoHttpError`, `NadoTimeoutError`, …, `isTransientError`, `isRateLimitError` | error classes and classification | `api-and-signing.md` §13, `rate-limits.md` §6 |
| `interpretExecuteError`, `NADO_ERROR_CODES`, `hasUsableDigest` | 2117 → resend POST_ONLY (resting DEFAULT only), 2064 → exact position size, 2020 → reconcile, 429 → retry, 5xx → reconcile | `orders.md` §4, §6, §9–11 |
| `createWeightThrottle`, `queryWeight`, `executeWeight`, `chunkProductIds` | explicit sustained rate / burst / concurrency and chunk size; execute first; immediately reject a request heavier than the bucket | `rate-limits.md` §2–4 |
| `parseContracts`, `verifyNetwork`, `createNetworkVerifier` | preflight: chain ID from `contracts` must match or refuse; `endpoint_addr` for signing cancellations | `api-and-signing.md` §1.3 |
| `parseSubaccountInfo`, `positionAmountX18`, `amountsUnchanged` | `healths[2]` = account value, `perp_balances`, `v_quote`, fail-closed | `account-and-fees.md` §1, §4 |
| `positionsFromInfo`, `equityFromInfo` | entry ≈ \|v_quote/amount\|, uPnL = amount × oracle + v_quote, equity = account value (collateral already included), degradation on unknown product | `account-and-fees.md` §1.3, §2 |
| `flattenOrdersData`, `parseOpenOrders`, `readOpenOrders`, `createSweepClock` | side from sign of `unfilled_amount`, ours/foreign from tag, `ordersComplete` only after complete survey, per-sub-account survey clock | `orders.md` §8, `account-and-fees.md` §3 |
| `readAccountSnapshot` | positions → orders → positions fence, `stable`, survey window consumed only by a usable snapshot | `account-and-fees.md` §3–4 |
| `orderTypeForMarket` | IOC remains IOC with RO bit; resting → POST_ONLY in fresh `post_only`, otherwise DEFAULT | `orders.md` §4.2 |
| `planOrderSize` | two minimums (resting / taker), openings below minimum are skipped, full RO close uses ceil + bump and is never gated | `orders.md` §5–6 |
| `buildPlaceOrder`, `placeOrderBody`, `signPlaceOrder`, `sendPlaceOrder`, `isIdempotentPlacement` | `place_order` payload, signed `amount` = side, response only digest, success without digest = rejection | `orders.md` §1, §7, `api-and-signing.md` §10 |
| `buildCancelOrders`, `cancelOrdersBody`, `signCancelOrders`, `confirmCancelled`, `sendCancelOrders` | cancellation confirmed only by digest in `cancelled_orders`; 2020 does not confirm | `orders.md` §9 |
| `measureIocFill` | position before → place → up to 3 reads at 150 ms; order-direction delta capped at sent size; no delta → `REJECTED` | `orders.md` §7 |
## Signing with viem
You own the signer key (linked signer, “1-Click Trading”); the package receives an object with `signTypedData`.
`sender` is always the **master account's** bytes32 sub-account, even when a linked signer signs. Addresses below are
placeholders.
```ts
import { privateKeyToAccount } from 'viem/accounts';
import {
CHAIN_ID, createGatewayClient, createNetworkVerifier, createSymbolsCache, createQuantizer, createWeightThrottle,
subaccountBytes32, orderTypeForMarket, isPostOnlyMarket, planOrderSize, signPlaceOrder, sendPlaceOrder,
measureIocFill, parseSubaccountInfo, positionAmountX18, interpretExecuteError,
} from '@markpaper/nado-kit';
const signer = privateKeyToAccount(process.env.NADO_SIGNER_KEY as `0x${string}`); // linked signer key, not the master key
const sender = subaccountBytes32('0xYOUR_ADDRESS', 'default'); // master-account address + sub-account name
const MY_TAG = 0x123; // 12-bit process tag: choose once and never change
const throttle = createWeightThrottle({
queriesPerMinute: config.queriesPerMinute,
queryBurst: config.queryBurst,
executesPerMinute: config.executesPerMinute,
executeBurst: config.executeBurst,
maxConcurrent: config.maxConcurrent,
});
const client = createGatewayClient({ network: 'mainnet', throttle });
const network = createNetworkVerifier(client.query, CHAIN_ID.mainnet); // compare chain ID with `contracts` before first execute
const symbols = createSymbolsCache({ load: () => client.query({ type: 'symbols', product_type: 'perp' }) });
const { chainId } = await network.get();
const btc = await symbols.resolve('BTC');
if (!btc) throw new Error('BTC is not listed right now'); // based on live symbols, not memory
const q = createQuantizer(btc);
// $120 IOC entry at a price through the spread: type for market mode, size for two minimums.
const { orderType, reduceOnly } = orderTypeForMarket({
intent: 'ioc',
reduceOnly: false,
postOnlyMarket: isPostOnlyMarket(btc.tradingStatus, symbols.isFresh()),
});
const priceX18 = q.priceToX18(76_000 * 1.005, 'ceil');
const plan = planOrderSize({ size: 120 / 76_380, priceX18, lotX18: btc.lotX18, intent: 'ioc', reduceOnly, minRestingNotionalX18: btc.minSizeX18 });
if (plan.action === 'skip') throw new Error(plan.reason);
const { body } = await signPlaceOrder(signer, {
product: btc, chainId, sender, isBuy: true, priceX18, sizeX18: plan.sizeX18, orderType, reduceOnly,
nonce: { nowMs: Date.now(), tag: MY_TAG, reduceIntent: false },
});
const readPosition = async () =>
positionAmountX18(parseSubaccountInfo(await client.query({ type: 'subaccount_info', subaccount: sender })), btc.productId);
// The place_order response contains only a digest. Measure the IOC fill from position delta.
const fill = await measureIocFill({ readPositionX18: readPosition, send: () => sendPlaceOrder(client.execute, body), isBuy: true, sentX18: plan.sizeX18 });
console.log(fill.status, fill.fillX18);
```
Cancellation: `signCancelOrders(signer, { chainId, endpointAddr, sender, targets: [{ productId, digest }] })` →
`sendCancelOrders(client.execute, body)`; cancellation is confirmed only when the digest returns in `cancelled_orders`.
A transport error on execute (5xx, timeout) has unknown outcome: `interpretExecuteError(err, { idempotent })`
returns `retry` for cancellation and full close, and `reconcile` for everything else.
Linking a signer (`link_signer`) is signed by the **master** in the browser with `tx_nonce` from the `nonces` query:
`buildLinkSignerTypedData({ chainId, endpointAddr, tx: { sender, signer: signerBytes32(addr), nonce } })` provides typed
data for the wallet popup. The master key never reaches the server.
## What is marked `@experimental`
Anything marked “according to documentation” or “not verified” in the knowledge base is marked `@experimental` in
JSDoc and is either implemented so that failure is safe or only collects data:
- **`orderDigest` / `PreparedOrder.expectedDigest`** — EIP-712 hash of the order's typed data. Whether the digest in
the `place_order` response equals it is not verified; cancellation is still confirmed only through `cancelled_orders`.
- **`marketMode('reduce_only' | 'soft_reduce_only')`** — semantics inferred from the name (taker RO only), `verified: false`.
An unknown status means the market accepts nothing (fail-closed).
- **`NADO_ERROR_CODES.REDUCE_ONLY_OVERSIZED` (2064)** — the meaning “RO larger than position” was not observed; the
`retry-exact-position-size` advice is safe under both semantics (clip or reject).
- **`expirationAtUnixSeconds`** — the unit of a finite `expiration` is not verified; only `EXPIRATION_NEVER` was verified live.
- **`parseAppendix`: fields `isolated`, `trigger`, `fee`, `builder`, `value`** — layout from documentation only.
- **`CANCELLATION_PRODUCTS_TYPES`, `buildCancelProductsTypedData`** — present in the signing schema; execute body not verified live.
- **`SubaccountInfo.initialHealthX18` / `maintenanceHealthX18`** — `healths[0..1]` according to documentation.
- **`NadoPosition.entryPx`** — estimate `|v_quote/amount|`, not checked against the UI.
- **`DEFAULT_QUERY_WEIGHT`** for undocumented queries (`fee_rates`).
- **Limits 2400/600 and all weights** — from documentation and not verified by hitting the live limits; configure local throttle budgets below them.
## Knowledge base
The rules behind every function and their reasons are documented in the markpaper repository's Nado knowledge base
(`knowledge/nado/`; start with `knowledge/nado/README.md`). The knowledge base has a separate CC BY 4.0 license.
## Development
PowerShell (from the markpaper monorepo root):
```powershell
pnpm --filter @markpaper/nado-kit typecheck
pnpm --filter @markpaper/nado-kit test # network-free unit tests
pnpm --filter @markpaper/nado-kit build
pnpm --filter @markpaper/nado-kit smoke # read-only live smoke test over public mainnet data (no keys or addresses)
```
Or from the package directory: `npm run typecheck`, `npm test`, `npm run build`, `npm run smoke`.
## License
Apache License 2.0 — see [LICENSE](LICENSE). Under section 4(d), any distribution of this package or a derivative
work must preserve [NOTICE](NOTICE) and include **“markpaper — nado-kit.”**
## Disclaimer
This is not financial advice or a recommendation to trade. The Nado API, limits, fees, market modes, and response
shapes change without notice. Recheck behavior against official documentation and on **testnet** (Ink Sepolia)
before using real money, begin at minimum size, and use this package at your own risk. The software is provided
“as is,” without warranties of any kind.