Skip to content
markpaper

README.md

v0.2.0 · 15 KB

Download file
# @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.
All files