A transport and signing reference for developers writing trading code for Nado (a CLOB perpetual DEX on Ink, Kraken's L2; stack and team from ex-Vertex). The official JS SDK is not used here: requests are assembled and signed directly (viem for EIP-712, fetch for HTTP).
TL;DR
- Two paths on the gateway:
POST …/v1/query(reads, unsigned) andPOST …/v1/execute(signed actions). A response always uses the envelope{status:'success', data, request_type}or{status:'failure', error, error_code}. A failure envelope from execute is a final sequencer rejection and must not be retried. Verified with live requests 2026-07-24. → §2 - Verify the network with the exchange itself. The
contractsquery returnschain_idandendpoint_addr. Ifchain_iddoes not match the chain being signed for, the process must not sign any execute request: a gateway for another network with a “similar” configuration could execute testnet orders on mainnet. → §1.3 - EIP-712 domain:
{name:'Nado', version:'0.0.1', chainId, verifyingContract}. Forplace_order,verifyingContract = address(productId)(the product ID encoded as a 20-byte address); for every other execute action, useendpoint_addrfromcontracts. A signature made under another product ID does not verify: the domains are separated. Verified 2026-07-24. → §5 - There are no API keys. A bot trades through a linked signer (“1-Click Trading”): a separate key that can trade for the sub-account and can withdraw only to the master wallet. The limit is 50 link/revoke operations per sub-account over 7 days; a new signer replaces the old one; revoke = link the zero address. → §11
- Sender is not an address but a bytes32 sub-account: 20 address bytes + up to 12 ASCII bytes of the name, right-padded with zeros.
defaultis the UI sub-account. A different name is a different account, and it looks like a perfectly valid empty account. → §6 - The order and cancellation nonce is not a counter. The high 44 bits are
recv_time(the deadline in milliseconds by which the request must reach the engine); the low 20 are client-defined. The nonce is returned in the order list, so its low bits are the only place to mark “ours / foreign” and the order's role (Nado has nocloid). → §7 - The
place_orderresponse contains only adigest(32 bytes). It contains neither status nor fill size. Measure an IOC fill by the position delta before and after the write. → §10,orders.md§7 - Every number on the wire is an integer x18 string (value × 1e18; prices use the
_x18suffix). Read values asBigInt, notNumber. →markets-and-numbers.md§2
1. Networks and endpoints
1.1. Gateway
| Network | Gateway | Chain | Chain ID |
|---|---|---|---|
| mainnet | https://gateway.prod.nado.xyz/v1 | Ink | 57073 |
| testnet | https://gateway.test.nado.xyz/v1 | Ink Sepolia | 763373 |
Both chain IDs were confirmed through a live contracts query (2026-07-24). Nado also has direct hosts such as direct-gateway.prod.nado-backend.xyz: they are located in Tokyo and intended to provide low latency for clients in Asia (not verified; see “Open questions”); everything in this knowledge base was verified through the regular gateway.
UI: app.nado.xyz; testnet UI and faucet: testnet.nado.xyz (see ops.md §3). Documentation: docs.nado.xyz (gateway/signing, order appendix, rate limits, and fee schedule sections).
Ink network parameters for a wallet (needed to sign LinkSigner in a browser wallet): RPC https://rpc-gel.inkonchain.com, explorer https://explorer.inkonchain.com, native asset ETH.
1.2. Geoblocking
Nado blocks by IP: Russia is completely prohibited, while the United States and Canada are view-only. The server must be located in an allowed jurisdiction, and this must be checked before launch rather than inferred from the first rejection. Compliance with the ToS is the operator's responsibility. According to Nado documentation and observations in 2026-07.
1.3. Network preflight (fail-closed)
Query {type:'contracts'} (weight 1) → {chain_id, endpoint_addr, …}. Once at process startup:
- Compare the response's
chain_idwith the configured chain ID. A mismatch means refuse startup (throw), not warn: with the wrong chain ID, a signature simply will not pass, but a gateway for another network paired with the correct chain ID in configuration means orders on the wrong network. - Store
endpoint_addr(normalized to lowercase, 40 hex digits) as theverifyingContractfor every execute action exceptplace_order. - Until identity is confirmed, send no execute request (single-flight on the verification promise).
A discrepancy caused by an empty or nonexistent sub-account is not a reason to refuse startup (launching before a deposit is legitimate), but it warrants a loud warning; see ops.md §1.
2. Request format and response envelope
POST <gateway>/querywith body{type:'<query>', …params}— unsigned, idempotent read.POST <gateway>/executewith body{<action>: {…, signature}}— signed action.- Header
content-type: application/json. Set a timeout on every request (for example, 10 s throughAbortSignal.timeout).
Response envelope:
{"status":"success","data":{…},"request_type":"…"}
{"status":"failure","error":"Market is in post-only mode …","error_code":2117}
Parsing rules (verified 2026-07-24; codes as of 2026-09-11):
status === 'success'→ work withdata.status === 'failure'on execute is a sequencer response, and the order was not applied. Classify it as a final rejection (REJECTED) witherror_codeand text, and do not retry: there is no uncertainty about whether it was applied.status === 'failure'on a query usually means malformed parameters; propagate it as an error.- Any other shape (missing
status, non-JSON) is a transport error with unknown outcome. For execute, that means “reconcile the book,” not “repeat.” - An HTTP status outside 2xx is a transport error; cancel the body (
res.body.cancel()) to release the connection.
Transport details:
- Nado returns 403 to clients that do not negotiate compression. Node's
fetch(undici) sendsAccept-Encoding: gzip, deflate, brand decompresses automatically — verified live. Do not set the header manually: a manualAccept-Encodingcan disable automatic decompression. - For a syntactically malformed query, the gateway returns plain text, not a JSON envelope; a proxy can truncate a 200 response. A
res.json()failure is transient, not “the exchange said no.”
3. Query requests needed by trading code
type | Parameters | Contents of data | Weight (per documentation) |
|---|---|---|---|
contracts | — | chain_id, endpoint_addr | 1 |
symbols | product_type:'perp' (optional) | symbols — an object {'BTC-PERP': {...}}; see markets-and-numbers.md §1 | 2 |
subaccount_info | subaccount (bytes32) | exists, healths[], perp_balances[], perp_products[]; see account-and-fees.md §1 | 2 |
orders | sender (bytes32), product_ids: number[] | product_orders[{orders:[…]}]; see orders.md §8 | 2 per product ID |
market_price | product_id | bid_x18, ask_x18 | 1 |
market_prices | product_ids: number[] | market_prices[{product_id, bid_x18, ask_x18}] | ≈1 per product ID |
nonces | address | tx_nonce (for link_signer) | 2 |
linked_signer | subaccount (bytes32) | linked_signer (address; zero address = none) | 5 |
fee_rates | sub-account | maker/taker rates and tier (see account-and-fees.md §6) | not recorded |
all_products | — | (not verified) | 5 |
The weights come from docs.nado.xyz documentation (developer-resources/api/rate-limits); they were not verified by hitting the live limit (rate-limits.md).
4. Execute actions used by trading code
| Action | Body | Signed against | Response | Weight (not checked against documentation; rate-limits.md §2) |
|---|---|---|---|---|
place_order | {product_id, order:{sender, priceX18, amount, expiration, nonce, appendix}, signature} | address(productId) | {digest} | 1 |
cancel_orders | {tx:{sender, productIds[], digests[], nonce}, signature} | endpoint_addr | {cancelled_orders:[{digest,…}]} | 1 |
link_signer | {tx:{sender, signer, nonce}, signature} | endpoint_addr | — | 30 |
Every numeric field in the body (priceX18, amount, expiration, nonce, appendix) is sent as a decimal integer string. The same values are bigint in typed data.
The signing schema includes the Cancellation (by digest) and CancellationProducts (cancel everything for a list of product IDs) types; the latter was not verified.
5. EIP-712
Domain:
{ name: 'Nado', version: '0.0.1', chainId, verifyingContract }
place_order:verifyingContract = address(productId)— the product ID as a 20-byte, left-zero-padded address (product 18 →0x…0012).- Other execute actions (
cancel_orders,link_signer, …):verifyingContract = endpoint_addrfromcontracts.
Types (checked against the documentation; the signature was verified with verifyTypedData from viem, and orders signed this way executed on mainnet):
const ORDER_TYPES = {
Order: [
{ name: 'sender', type: 'bytes32' },
{ name: 'priceX18', type: 'int128' },
{ name: 'amount', type: 'int128' }, // sign = side: + buy, − sell
{ name: 'expiration', type: 'uint64' },
{ name: 'nonce', type: 'uint64' },
{ name: 'appendix', type: 'uint128' },
],
} as const;
const CANCELLATION_TYPES = {
Cancellation: [
{ name: 'sender', type: 'bytes32' },
{ name: 'productIds', type: 'uint32[]' },
{ name: 'digests', type: 'bytes32[]' },
{ name: 'nonce', type: 'uint64' },
],
} as const;
const LINK_SIGNER_TYPES = {
LinkSigner: [
{ name: 'sender', type: 'bytes32' },
{ name: 'signer', type: 'bytes32' },
{ name: 'nonce', type: 'uint64' },
],
} as const;
Domain separation works: the same signature under address(2) instead of address(18) fails verification (a viem test). Therefore, an error in the product ID during signing will not “land in another market”; it will be rejected — but only if the signature and the body’s product_id are built from the same source.
6. Sub-account as bytes32
/** bytes32 = 20 address bytes + up to 12 bytes of the ASCII name, right-padded with zeros. */
function subaccountBytes32(address: string, name: string): `0x${string}` {
const addr = address.trim().toLowerCase();
if (!/^0x[0-9a-f]{40}$/.test(addr)) throw new Error('bad address');
if (!/^[\x21-\x7e]{0,12}$/.test(name)) throw new Error('bad subaccount name');
let hex = '';
for (const ch of name) hex += ch.charCodeAt(0).toString(16).padStart(2, '0');
return `${addr}${hex.padEnd(24, '0')}` as `0x${string}`;
}
// subaccountBytes32('0xYOUR_ADDRESS', 'default') = <20 address bytes><64656661756c74><10 zero bytes>
defaultis the sub-account created by the UI upon deposit. The vector from the documentation matched the function above (verified 2026-07-24).- A different name is a different account. A typo in the name yields a valid
subaccount_inforesponse withexists:falseand zero equity: “equity UNKNOWN” forever without a single error. At startup, log the sub-account name and its bytes32 and compare them with the UI. - The name is at most 12 printable ASCII characters; an empty name is allowed when encoding a
signer(see §11), but not as the name of a trading sub-account. senderinplace_order,cancel_orders, andlinked_signeris always the master account's bytes32, even when a linked signer signs. Therefore, the executor needs both the signer key and the master account address.
7. Nonce: two different kinds
7.1. Order and cancellation nonce (recv_time + client bits)
According to Nado documentation: nonce = (recv_time_ms << 20) | client_bits, where recv_time is the millisecond deadline by which the request must reach the engine; it must be no farther than +100 s from the current server time. A reasonable window is now + 60 000.
const NONCE_RECV_WINDOW_MS = 60_000;
function buildCancelNonce(nowMs: number): bigint {
const random20 = BigInt(Math.floor(Math.random() * 0x100000)) & 0xfffffn;
return (BigInt(nowMs + NONCE_RECV_WINDOW_MS) << 20n) | random20;
}
Consequences:
- The machine clock must be correct (NTP): a clock too far ahead produces a nonce outside the window, while a clock too far behind produces an expired deadline. A nonce rejection was not observed, but this follows directly from the scheme.
- Two identical orders in the same millisecond must differ in the low bits, or their nonces will collide.
7.2. Tag in the low 20 bits (idea)
The orders query returns every resting order's nonce. This is the only value that makes a round trip (Nado has no cloid), so use the low bits as a tag:
- some bits are a “magic” mark meaning “this order was placed by this process” (orders without the mark are foreign: do not match or cancel them, but do count them as account exposure, including when concluding “there are no orders”);
- several bits are random so that identical orders in the same millisecond remain distinct;
- one bit records the reduceOnly intent of a resting limit order: a resting reduceOnly order is impossible on Nado (
orders.md§3), and the intent cannot be recovered after a restart without a tag.
The exact values and bit layout are implementation details for each bot; the scheme is what matters here. Do not change the tag scheme while orders with the old mark exist: they will become “foreign,” and the process will not cancel them. Cancel old orders manually before changing the scheme.
7.3. tx_nonce for link_signer
LinkSigner is signed by the master wallet with the incrementing tx_nonce from query {type:'nonces', address}, not a recv_time nonce. These are different counters; confusing them causes signature verification to fail.
8. Order appendix (uint128)
Bit layout from Nado documentation (order appendix); low-bit values confirmed from live orders in the book on 2026-07-24:
| value 127..64 | builder 63..48 | fee 47..38 | reserved 37..14 | trigger 13..12 |
| reduce-only 11 | order type 10..9 | isolated 8 | version 7..0 |
version= 1.order type: 0 = DEFAULT (GTC limit), 1 = IOC, 2 = FOK, 3 = POST_ONLY.reduce-only(bit 11) is allowed only with IOC/FOK: with DEFAULT/POST_ONLY, the exchange returns 2067 (orders.md§3). The appendix builder should reject this combination during construction rather than waiting for an exchange rejection.
Live values (confirmed 2026-07-24): DEFAULT = 1, IOC = 513, IOC + reduceOnly = 2561, POST_ONLY = 1537.
type NadoOrderType = 'default' | 'ioc' | 'fok' | 'post_only';
const ORDER_TYPE_BITS: Record<NadoOrderType, bigint> = { default: 0n, ioc: 1n, fok: 2n, post_only: 3n };
function buildAppendix(orderType: NadoOrderType, reduceOnly: boolean): bigint {
if (reduceOnly && orderType !== 'ioc' && orderType !== 'fok') {
throw new Error(`reduce-only requires a taker order type on Nado (got ${orderType})`);
}
return 1n | (ORDER_TYPE_BITS[orderType] << 9n) | (reduceOnly ? 1n << 11n : 0n);
}
The isolated, trigger, fee, builder, and value fields were not verified (see Open questions).
9. Expiration
expiration is a uint64, a plain timestamp; the order type is not encoded in it (unlike the old Vertex stack, where the type occupied the high bits of expiration; on Nado it is in the appendix). “Never expires” is represented by 2^64 − 1 = 18446744073709551615: this is exactly how live resting GTC orders appear in the book. Orders of every type, including IOC, are accepted with this value, and IOC orders execute (verified live).
10. Digest — the order identifier
place_orderreturns{digest}— 32 bytes (0x+ 64 hex digits). It is the sole order ID and is also returned by theordersquery and incancelled_orders.- The response contains no execution status and no fill size. A formally successful response with a digest but no observable position delta means “not applied” for IOC (
orders.md§7). - Treat success without a valid digest (not 64 hex digits) as a rejection.
- Cancellation requires both the digest and the order's product ID:
cancel_ordersacceptsproductIds[]anddigests[](§4). - After a restart, your order digests are known only from rereading the book (
orders): cancellation of an order whose digest was not reread is unconfirmed — readordersfirst.
11. Linked signer (“1-Click Trading”)
- There are no API keys. The master wallet signs
LinkSigner(§5) and authorizes another key to trade for the sub-account. - Restrictions according to Nado documentation: a signer may trade, but can withdraw only to the master-wallet address; 50 link/revoke operations per sub-account over a rolling 7 days; a new signer replaces the previous one (including one enabled through the 1-Click Trading button in the UI); revoke = link the zero address; the sub-account must already hold at least 5 USDT0 (deposit first, then link).
signerin typed data is bytes32: 20 signer-address bytes + 12 zero bytes (only the first 20 bytes are meaningful).nonceis thetx_noncefrom thenoncesquery for the master address (§7.3).- Confirmation: query
linked_signerfor the sub-account's bytes32 → address; zero address = no signer is linked. At bot startup, verify that the address derived from the signer's key equals the sub-account'slinked_signeror equals the master address (self-signing is allowed but less secure). Failure of the check itself (network error) meansok:false, not “not linked.” - Link through a wallet popup in the browser (
LinkSignertyped data is signed in MetaMask; switch the wallet to Ink withwallet_switchEthereumChain/wallet_addEthereumChain), not with a CLI script that holds the master's private key. The master key must never reach the server, chat, or environment variables. - Verified live (2026-07): after
link_signer, thelinked_signerquery shows the new key's address.
12. Snippet: typed data for place_order
import { privateKeyToAccount } from 'viem/accounts';
const account = privateKeyToAccount(process.env.SIGNER_KEY as `0x${string}`); // linked signer key, not the master key
const MASTER = '0xYOUR_ADDRESS'; // master account address — it is always the sender
const chainId = 57073; // check against the `contracts` query before the first execute
const productId = 2; // BTC-PERP as of 2026-07-24; read from `symbols`, do not hard-code
const order = {
sender: subaccountBytes32(MASTER, 'default'),
priceX18: decimalToX18('60000'), // price already aligned to the market tick
amount: -decimalToX18('0.0005'), // minus = sell; size aligned to the market lot
expiration: (1n << 64n) - 1n,
nonce: buildOrderNonce(Date.now(), { reduceIntent: false }),
appendix: buildAppendix('ioc', /* reduceOnly */ true),
};
const signature = await account.signTypedData({
domain: { name: 'Nado', version: '0.0.1', chainId, verifyingContract: productVerifyingContract(productId) },
types: ORDER_TYPES,
primaryType: 'Order',
message: order,
});
const body = {
place_order: {
product_id: productId,
order: {
sender: order.sender,
priceX18: order.priceX18.toString(),
amount: order.amount.toString(),
expiration: order.expiration.toString(),
nonce: order.nonce.toString(),
appendix: order.appendix.toString(),
},
signature,
},
};
// POST <gateway>/execute, body → { status:'success', data:{ digest } } | { status:'failure', error, error_code }
function productVerifyingContract(id: number): `0x${string}` {
return `0x${id.toString(16).padStart(40, '0')}` as `0x${string}`;
}
See markets-and-numbers.md §2 for decimalToX18; build buildOrderNonce using the §7.1 (recv_time) scheme plus the §7.2 tag in the low bits.
13. Error classification and retries
| Result | Class | Action |
|---|---|---|
failure envelope with error_code on execute | final sequencer rejection | REJECTED, do not retry; branch by code (2117 → resend POST_ONLY for resting, 2064 → use exact position size; see orders.md) |
| HTTP 429 | rejected before execution | safe to repeat with backoff |
| HTTP 5xx, timeout, disconnect, non-JSON body | unknown outcome | retry idempotent operations (cancel, full reduceOnly close, query); do not retry an open / partial reduction — reconcile position and book |
success without a valid digest | treat as rejection | same as REJECTED |
Queries are idempotent — retry them after any transient error.
14. Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| Every execute is rejected after switching networks | the gateway is for one network and the chain ID for another | preflight contracts; mismatch = refuse startup |
| 403 on every request | manual Accept-Encoding or a client without compression | do not touch the header; let undici handle it |
res.json() throws on a 200 | plain-text response to a malformed query or truncation by a proxy | classify as transient, not as an exchange rejection |
| The order is “wrong” or the signature is rejected | product ID in the signature and body came from different sources | use one metadata object for both the domain and product_id |
| The process no longer recognizes its orders after a version change | the nonce-tag scheme changed | do not change the scheme while orders are open; cancel old orders manually |
Equity remains UNKNOWN forever with no errors | the sub-account name is wrong (exists:false) | print the name and bytes32 at startup and compare them with the UI |
LinkSigner signature fails | a recv_time nonce was used instead of tx_nonce | nonces → tx_nonce |
| The master key is on the server | linking was done with a CLI script | use only a browser popup; only the signer key belongs on the server |
15. Open questions / not verified
- The exact error text and code for an expired or “future” nonce (outside the +100 s window) were not captured.
- Appendix fields
isolated,trigger,fee,builder, andvalue: the layout comes from the documentation, but their semantics and rejection codes are not verified. Trigger (stop) orders were not placed through the API. CancellationProducts(cancel everything for product IDs with one signature) exists in the schema but was not used.- Whether Nado has an official SDK (JS or another language) was not recorded; everything in this knowledge base is signed with
viem. The Vertex stack had an SDK, but its applicability to Nado was not verified. - The
fee_ratesweight and exact response shape were not recorded. - Direct hosts (
direct-gateway.…nado-backend.xyz): compatibility of their envelope and limits with the regular gateway was not verified. - Ordering of
healths[]elements insubaccount_info(account-and-fees.md§1): index 2 is used as unweighted health; the documentation calls indices 0 and 1 initial and maintenance, but this was not verified live. - WebSocket requests (subscriptions to orders, fills, and the order book) were not used; their availability and format were not studied.
Facts verified through 2026-09-16. The Nado API changes — recheck response shapes, codes, and limits with live requests.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this work, credit “markpaper — Nado knowledge base” and link to the original and the license.