@markpaper/nado-kit
Summary (EN). A TypeScript toolkit for Nado, 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
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:
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.
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 theplace_orderresponse equals it is not verified; cancellation is still confirmed only throughcancelled_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; theretry-exact-position-sizeadvice is safe under both semantics (clip or reject).expirationAtUnixSeconds— the unit of a finiteexpirationis not verified; onlyEXPIRATION_NEVERwas verified live.parseAppendix: fieldsisolated,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_WEIGHTfor 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):
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. Under section 4(d), any distribution of this package or a derivative work must preserve 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.