Skip to content
markpaper

knowledge/nado/api-and-signing.md

vregistry-c914171 · 23.8 KB

Download file
# Nado — API and signing: gateway, EIP-712, linked signer, nonce, digest

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

1. **Two paths on the gateway:** `POST …/v1/query` (reads, unsigned) and `POST …/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
2. **Verify the network with the exchange itself.** The `contracts` query returns `chain_id` and `endpoint_addr`. If `chain_id` does 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
3. **EIP-712 domain: `{name:'Nado', version:'0.0.1', chainId, verifyingContract}`.** For `place_order`, `verifyingContract = address(productId)` (the product ID encoded as a 20-byte address); for every other execute action, use `endpoint_addr` from `contracts`. A signature made under another product ID does not verify: the domains are separated. *Verified 2026-07-24.* → §5
4. **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
5. **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. `default` is the UI sub-account. A different name is a different account, and it looks like a perfectly valid empty account. → §6
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 no `cloid`). → §7
7. **The `place_order` response contains only a `digest`** (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
8. **Every number on the wire is an integer x18 string** (value × 1e18; prices use the `_x18` suffix). Read values as `BigInt`, not `Number`. → `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:

1. Compare the response's `chain_id` with 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.
2. Store `endpoint_addr` (normalized to lowercase, 40 hex digits) as the `verifyingContract` for every execute action except `place_order`.
3. 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>/query` with body `{type:'<query>', …params}` — unsigned, idempotent read.
- `POST <gateway>/execute` with body `{<action>: {…, signature}}` — signed action.
- Header `content-type: application/json`. Set a timeout on every request (for example, **10 s** through `AbortSignal.timeout`).

Response envelope:

```json
{"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 with `data`.
- `status === 'failure'` on **execute** is a sequencer response, and the order **was not applied**. Classify it as a final rejection (`REJECTED`) with `error_code` and 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) sends `Accept-Encoding: gzip, deflate, br` and decompresses automatically — *verified live*. **Do not** set the header manually: a manual `Accept-Encoding` can 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:

```ts
{ 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_addr` from `contracts`.

Types (checked against the documentation; the signature was verified with `verifyTypedData` from `viem`, and orders signed this way executed on mainnet):

```ts
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

```ts
/** 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>
```

- `default` is 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_info` response with `exists:false` and 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.
- `sender` in `place_order`, `cancel_orders`, and `linked_signer` is 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`.

```ts
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`.

```ts
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_order` returns `{digest}` — 32 bytes (`0x` + 64 hex digits). It is the sole order ID and is also returned by the `orders` query and in `cancelled_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_orders` accepts `productIds[]` and `digests[]` (§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 — read `orders` first.

---

## 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).
- `signer` in typed data is bytes32: 20 signer-address bytes + 12 zero bytes (only the first 20 bytes are meaningful).
- `nonce` is the `tx_nonce` from the `nonces` query for the master address (§7.3).
- Confirmation: query `linked_signer` for 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's `linked_signer` **or** equals the master address (self-signing is allowed but less secure). Failure of the check itself (network error) means `ok:false`, not “not linked.”
- **Link through a wallet popup in the browser** (`LinkSigner` typed data is signed in MetaMask; switch the wallet to Ink with `wallet_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`, the `linked_signer` query shows the new key's address.

---

## 12. Snippet: typed data for `place_order`

```ts
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`, and `value`: 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_rates` weight 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 in `subaccount_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.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this work, credit “markpaper — Nado knowledge base” and link to the original and the license._
All files