# Nado — orders: types, reduceOnly, market modes, minimums, lifecycle, cancellations, retries

Everything about placing and cancelling orders on Nado: what the exchange accepts, what it never accepts, how to determine whether an order executed when the response contains only a digest, and what rules follow from this.

## TL;DR

1. **Four types: DEFAULT (GTC limit), IOC, FOK, POST_ONLY** — encoded in appendix bits. There is no market-order type: “at market” means an IOC limit order priced through the spread. → §2
2. **reduceOnly works only with IOC/FOK.** A resting (DEFAULT/POST_ONLY) order with reduceOnly is rejected with code **2067**. Therefore, a TP in the book is an ordinary limit order, and after the position closes it **will not stop at zero; it will open an opposite position**. Protect this in code: TP ≤ position + cancel your own resting orders for the coin before every IOC that changes the position. *Verified 2026-07-24.* → §3
3. **Market mode determines what may be placed.** `post_only` → POST_ONLY only (DEFAULT/IOC/FOK receive **2117** at any price); `not_tradable` → **2069** for everything. Use POST_ONLY for resting orders in a `post_only` market; defer partial IOC; do not gate a full close. *Observed 2026-09-10/11.* → §4
4. **The $100 minimum (`min_size`) effectively applies only to the book.** Maker orders below $100 were not observed, while taker IOC orders, including reduce-only, succeeded well below it. Keep **two** minimums: one for resting orders and one for IOC; lower the latter only after measurement. **Never gate a full reduceOnly close by a minimum.** → §5
5. **`place_order` returns only a `digest`.** GTC: place → appears in `orders` with your tag → cancel → disappears. IOC: position before → place → position after; delta = fill; no delta → `REJECTED` (the next tick recalculates). → §7
6. **A cancellation is confirmed only by the digest in `cancelled_orders`.** Everything else (transport error, unknown digest after restart, `OrderNotFound`) means “unconfirmed”: do not place the replacement until reconciling the book. → §9
7. **Retries:** a failure envelope is a final rejection; do not retry. Retry 429. A 5xx/timeout has unknown outcome; retry only idempotent operations (cancel, full close). → §10
8. **Orders are read by market** (`orders` with `product_ids`); a truncated read is indistinguishable from an empty account. → §8, `account-and-fees.md` §3

---

## 1. `place_order` payload

```json
{ "place_order": {
    "product_id": 2,
    "order": {
      "sender":     "<master-account sub-account bytes32>",
      "priceX18":   "60000000000000000000000",
      "amount":     "-500000000000000",
      "expiration": "18446744073709551615",
      "nonce":      "<recv_time<<20 | tag>",
      "appendix":   "2561"
    },
    "signature": "0x…"
} }
```

- `amount` is a signed x18 size: `+` = buy, `−` = sell. There is no separate side field.
- `priceX18` is an x18 price already aligned to the market tick; `amount` is already aligned to the lot (`markets-and-numbers.md` §3). Reject a price or size quantized to 0 before sending.
- `expiration` is `2^64 − 1` for every type (`api-and-signing.md` §9).
- `appendix` contains the type and reduceOnly (`api-and-signing.md` §8).
- The signature is against `address(product_id)`.
- Response: `{digest}`. Nothing else.

---

## 2. Order types

| Type | Bit | Appendix | Behavior | reduceOnly |
|---|---|---|---|---|
| DEFAULT | 0 | `1` | GTC limit, rests in the book | **not allowed** (2067) |
| IOC | 1 | `513` / with RO `2561` | executes what it can and discards the remainder | allowed |
| FOK | 2 | — | all or nothing | allowed (per documentation; not verified) |
| POST_ONLY | 3 | `1537` | maker only; the sole type accepted by a `post_only` market | **not allowed** |

- A “market” order is IOC with a price through the spread (the limit protects against slippage). IOC is always taker.
- The appendix builder should reject POST_ONLY + reduceOnly during construction.
- The rejection code for a POST_ONLY order that crosses the book was **not observed** (the checked POST_ONLY orders rested rather than crossed). Log the first such rejection in full.

---

## 3. reduceOnly is taker-only (2067), and what follows

**Fact.** A resting order with the reduceOnly bit is rejected with code **2067**. Reduce-only exists only for IOC/FOK. *Verified 2026-07-24 and repeated against the live book.*

**Consequence.** A TP in Nado's book is a **plain limit order**: the exchange does not cap it to the position. After the position is closed by something else (an IOC exit or another TP), it remains in the book and **reverses** the position.

**Safety invariants** (they must live in code, not in someone's memory):

1. **TP clamp: “no larger than the position.”** The sum of resting orders on the reducing side ≤ position.
2. **Cancel your resting orders for the coin before IOC:** before **every** IOC that changes the position (partial reduction, full close, increase), cancel all of your resting orders for that coin (including TP), then send the IOC, then replace orders only after a fresh position read. Without this, the race between “IOC close + TP limit filled at the same time” produces a reversed position.
3. **Every IOC reduction carries the real reduceOnly bit** — the exchange itself guarantees that the IOC cannot open or reverse a position.

Protective TPs and other resting orders for a coin must not remain in the book during a full close, and must not be replaced from a stale position snapshot after cancellation.

A Nado resting order does not itself indicate that it was intended to reduce a position: in the book it looks like any other limit order. `nado-kit` uses a neutral `reduceIntent` bit in the nonce's low bits (`api-and-signing.md` §7.2) and reads it back from `orders`; that bit does not select a trading strategy. Without a stable tag, you cannot prove that an order belongs to this client.

---

## 4. Market modes: `post_only`, `not_tradable`, `reduce_only`

### 4.1. What was observed (2026-09-10/11)

During a new market's pre-listing phase, while it is in `not_tradable`, every order, including IOC entries, receives **2069**. When the market moves to `post_only`, every DEFAULT and IOC order receives **2117** at any price — even bids far below the book; only POST_ONLY orders enter the book.

A typical code error: the POST_ONLY type is supported in the signature and `trading_status` is parsed from metadata, but the executor never sees the market mode (the field is read and discarded). Then every resting order gets rejection 2117 on every tick while the market is in `post_only`. This is the “field read but never consumed” class of bug.

### 4.2. Correct handling

| Situation | Action |
|---|---|
| Resting order (limit, TP-as-GTC) in a `post_only` market according to a **fresh** cache | use POST_ONLY (without reduceOnly, which is impossible there anyway) |
| DEFAULT receives 2117 (cache is stale or frozen) | resend **the same order as POST_ONLY** — new nonce; the first attempt was rejected by an envelope, so a duplicate is impossible. If this repeats across several coins, the cache is frozen and needs a restart |
| Partial IOC (position increase or partial reduction) in a `post_only` market | **defer it:** there is no taker flow in the market, IOC will receive 2117, and cancelling the coin's resting orders before that IOC (§3) would be pointless. Do not rewrite IOC as something else |
| Full close in a `post_only` market | **do not gate it:** send it to the exchange and capture the rejection code from logs on first observation |
| `reduce_only` / `soft_reduce_only` / `not_tradable` | reject with a code and retry on the next tick; do not begin trading a market in `not_tradable`, and do not remove an already traded market from the list because of status |
| Mode unknown (stale cache → `undefined`) | default behavior: DEFAULT + fallback on 2117; IOC is not gated |

Quick diagnosis: grep logs for `2117` / `2069`, then inspect `trading_status` in `symbols`.

### 4.3. Antipatterns

- **Replacing IOC with FOK/POST_ONLY** for partial position increases and reductions. There is no taker flow; POST_ONLY + reduceOnly is forbidden; the rejection must be visible in logs rather than hidden.
- **Turning resting-order rejections into “SKIPPED due to minimum.”** This suppresses real complaints, and the minimum explanation is false for orders above the minimum.
- **Treating a `post_only` market as unlisted.** The exchange accepts resting POST_ONLY orders; the market is listed.
- **Gating a full close on a cached flag.** A cache up to 5 minutes old is not current. If gating at all, do it only from an exchange rejection received in the same tick.
- **Extending the cache stability check to `trading_status`.** Opening a market would freeze the cache until restart, as with the rename on 2026-08-10.

---

## 5. Minimums

**Documentation:** `min_size` = $100 for every market (in `symbols`).

**Observation from live orders (2026-07):**
- maker orders below $100 were not observed;
- taker IOC orders, including reduce-only, succeeded well below $100.

**Conclusion:** the minimum applies to orders **in the book**. Do not gate IOC — market entries, partial reductions, and closes — with it. The risk of “unclosable dust below $100” is much lower in practice than the documentation suggests. Lesson: measure exchange minimums with live orders.

**Practice — two minimums:**

| Minimum | Applies to | Value |
|---|---|---|
| resting | limits, TP, any GTC/POST_ONLY | $100 (`min_size`) |
| IOC | market entries, partial reductions, closes | defaults to the resting minimum; lower it only after measuring the floor and with a buffer (for example, twice the measured floor) |

A value **below** the real floor turns every market action into an exchange rejection (the process survives and recalculates on the next tick, but execution lags).

To measure the floor, binary-search down from $100 (each step half the previous one) on testnet or with micro-orders on mainnet (fees are cents, and the response comes from mainnet matching).

**Never gate a full reduceOnly close by a minimum:** bump a below-minimum fullClose to the minimum (`ceilSz` + bump), and the exchange clips it to the live position — but see §6 regarding 2064. Gating a full close leaves unmanaged “crumbs.”

Openings and partial reductions below the minimum are `SKIPPED` (do not raise them to the minimum; that changes position size). For the rule that “a size not sent because of a minimum must propagate upward as a rejection rather than be marked covered,” see `pitfalls.md`.

---

## 6. Oversized reduceOnly (2064)

Whether Nado clips a reduce-only IOC **larger** than the live position to that position, or rejects it, is **not verified**. Code **2064** is named in the implementation as “reduce-only would increase position”; that semantic is an assumption.

Working sequence for a full close:
1. bump a below-minimum size above the minimum;
2. on 2064, make **one** retry with the exact position size (`ceilSz(position)`);
3. after a second rejection, emit a loud alert: “dust may be unclosable”; continue close attempts (the position must not be silently abandoned).

This closes everything that can be closed under either semantic. Verify on testnet: is an RO IOC larger than the position clipped, or does it return 2064?

---

## 7. Lifecycle: digest without status

### 7.1. GTC / POST_ONLY

place → `digest` → the next `orders` read shows an order with your nonce tag → cancel when needed → the order disappears from `orders`. Whether POST_ONLY actually rested is also visible only from the next book read.

### 7.2. IOC — fill from position delta

The response contains no fill. The sequencer is strictly consistent for your own state: a `subaccount_info` query immediately after execute already reflects execution. Pattern:

```ts
/** IOC fill on an exchange whose response has no status: position delta.
 *  Underestimating a fill is safe (the next tick recalculates the gap); overestimating is not. */
async function placeIocMeasured(
  readAmountX18: (productId: number) => Promise<bigint>,   // signed amount from subaccount_info
  sendIoc: () => Promise<{ digest: string } | { rejection: { code: number; message: string } }>,
  productId: number, isBuy: boolean, sentX18: bigint,
): Promise<{ status: 'FILLED' | 'REJECTED'; fillX18: bigint; error?: string }> {
  let before: bigint;
  try { before = await readAmountX18(productId); }
  catch (e) { return { status: 'REJECTED', fillX18: 0n, error: `pre-write read failed: ${(e as Error).message}` }; }

  const sent = await sendIoc();
  if ('rejection' in sent) return { status: 'REJECTED', fillX18: 0n, error: `${sent.rejection.code}: ${sent.rejection.message}` };

  for (let attempt = 0; attempt < 3; attempt++) {
    let after: bigint;
    try { after = await readAmountX18(productId); } catch { continue; }
    const delta = after - before;
    const directional = isBuy ? delta : -delta;
    if (directional > 0n) return { status: 'FILLED', fillX18: directional > sentX18 ? sentX18 : directional };
    await new Promise((r) => setTimeout(r, 150));
  }
  return { status: 'REJECTED', fillX18: 0n, error: 'ioc accepted but no position delta observed — treated as not applied' };
}
```

Rules:
- if the position read **before** the write fails, do not send the order; return `REJECTED` (otherwise there is no baseline for measuring the delta);
- if no delta is observed after 3 attempts (≈450 ms), return `REJECTED` even though a digest was received. If the bot recalculates the desired position against the actual position on every step, underestimating a fill does not cause a duplicate action, while overestimating would leave the discrepancy unrepaired;
- cap the fill at the sent size (a concurrent foreign trade must not be attributed to your order);
- average fill price is absent from the response — accounting must use the order price; only account history or a change in `v_quote_balance` (`account-and-fees.md` §2) provides the actual price, and that was not verified.

---

## 8. Reading your orders

Query `{type:'orders', sender:<bytes32>, product_ids:[…]}` (weight 2 per ID) → `product_orders[{orders:[…]}]`. Order shape (fields as in a live response, synthetic values):

```json
{ "product_id": 2, "sender": "0x…", "price_x18": "60000000000000000000000",
  "amount": "-10000000000000000", "expiration": "18446744073709551615",
  "order_type": "default", "nonce": "1870659646914634565", "appendix": "1",
  "unfilled_amount": "-10000000000000000", "digest": "0x…", "placed_at": 1784000000 }
```

- Side = sign of `unfilled_amount` (`> 0` buy, `< 0` sell); size = `|unfilled_amount|`; `unfilled_amount === 0` is a fully filled remainder that no longer rests in the book, so skip it.
- `nonce` is a string around 1e18: `BigInt` only. Recover “ours/foreign” and role from it (`api-and-signing.md` §7.2). Do not match or cancel foreign orders (without the tag, such as manual orders or orders placed by another process), but **do count them as exposure** when deciding whether “the account is empty.”
- Request the **working set** of products (`markets-and-numbers.md` §8) in explicitly sized chunks: weight 2×N must not exceed the configured query burst. Run a complete survey of every market separately at a frequency chosen by the application. Only a complete survey proves “there are no orders anywhere” (`account-and-fees.md` §3).
- After a restart, recover the digests of your orders (together with product IDs) from this read before the first cancellation.

---

## 9. Cancellation

Execute `cancel_orders: {tx:{sender, productIds:[…], digests:[…], nonce}, signature}` (signed against `endpoint_addr`, recv_time nonce) → `{cancelled_orders:[{digest,…}]}`.

- Cancellation is **confirmed** only when your digest is present in `cancelled_orders`. Otherwise it is “unconfirmed”: transport error, response without the digest, or unknown digest (saved digests are not yet available after restart, or it is a foreign order).
- `2020 OrderNotFound` (per documentation) means the order was already cancelled **or filled**; the latter means the snapshot is stale. Treat it the same way: unconfirmed; do not place a replacement until a fresh read.
- Cancellation is idempotent: it may be retried after a transient error.
- Perform all cancellations before placements (cancel the coin's resting orders before IOC; §3).
- Cancellation by whole product ID (`CancellationProducts`) exists in the schema but was not verified.

---

## 10. Retries and idempotency

| Class | Example | Retry |
|---|---|---|
| failure envelope (`error_code`) | 2117, 2069, 2067, 2064 | no; branch by code (§4.2, §6) |
| 429 | exchange throttle | yes, with backoff |
| 5xx / timeout / disconnect / non-JSON | proxy, network | yes for cancel and full reduceOnly close; **no** for opening and partial reduction — reconcile position |
| “success” without a valid digest | — | treat as rejection |

In the shared throttle, mark execute operations `idempotent`; only idempotent operations retry after any transient error, while others retry only after 429 (rejected before matching). See `rate-limits.md` §6.

---

## 11. Error codes

| Code | Text / meaning | Source |
|---|---|---|
| 2020 | `OrderNotFound` — already cancelled or filled | documentation |
| 2064 | assumed “reduce-only exceeds position / would increase it” | not observed |
| 2067 | resting reduceOnly is forbidden (RO only with IOC/FOK) | verified 2026-07-24 |
| 2069 | `Trading is blocked for this market` (`not_tradable`) | observed 2026-09-10 |
| 2117 | `Market is in post-only mode … Only post-only orders are accepted` | observed 2026-09-11 |

Log every new code in full (text, price, size, and type) once per coin and code, so that it becomes a fact rather than a guess.

---

## 12. Smoke test before using real money (testnet or micro-orders on mainnet)

1. RO IOC larger than the position: clipped or 2064?
2. Find the IOC floor with binary search → set the IOC minimum with a buffer.
3. GTC: place → visible in `orders` with your tag → cancel → disappears, with digest in `cancelled_orders`.
4. IOC entry: position delta is read as the fill.
5. Full cycle at small size: entry, protective TP, partial reduction, full close to zero.
6. In a `post_only` market (stock perpetual on a weekend): resting order is sent as POST_ONLY and rests; IOC receives 2117 and is not rewritten.

---

## 13. Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| “Order executed” from a successful response, but position did not change | the response contains only a digest | fill = position delta; no delta → `REJECTED` |
| TP with reduceOnly is rejected (2067) | RO only with IOC/FOK | TP as GTC; clamp to position; cancel the coin's resting orders before IOC |
| TP opened an opposite position after close | a plain limit does not stop at zero | cancel the coin's resting orders before closing; after cancellation, place TP only from a fresh position read |
| Resting orders do not enter the book; every attempt gets 2117 | market is `post_only`, but DEFAULT is sent | POST_ONLY from fresh `trading_status`; fallback on 2117 |
| Stream of 2069 rejections | market is `not_tradable`, although metadata exists | do not begin trading a `not_tradable` market until its status changes |
| $100 minimum blocks closing dust | minimum came from documentation | IOC succeeds well below $100; separate measured IOC minimum; never gate a full close |
| Every market action is rejected | IOC minimum is below the real floor | measure → add a buffer |
| Replacement was placed while the old order is still live | cancellation was considered “confirmed” merely because there was no error | require the digest in `cancelled_orders` |
| Cancellations are “unconfirmed” after restart | saved digests for your orders are not yet available | reread the book, save digests, then cancel |

---

## 14. Open questions / not verified

- **Oversized reduceOnly IOC:** clipped or 2064; the meaning of 2064 is an assumption.
- **Exact IOC floor:** IOC succeeded well below $100 (2026-07-24), but no binary search was performed, and one estimate contradicts this observation. The contradiction is unresolved; keep a conservative minimum until measurement.
- **Rejection code for a POST_ONLY order crossing the book** was not observed.
- **Full close in a `post_only` market with a position:** the exchange's rejection code was not captured.
- **`reduce_only` / `soft_reduce_only`:** semantics and codes.
- **FOK** was not verified.
- **Average IOC fill price** is absent from the response; the account-history method was not verified.
- **Partial IOC execution** (fill smaller than sent): the delta covers it, but it was not tested separately.
- **Duplicate placement when repeating the same nonce:** nonce deduplication was not verified (tested repetitions used a new nonce).

---

Facts verified through 2026-09-16. Nado changes: verify codes, minimums, and market modes against the live API.

---

<!-- 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._
