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
- 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
- 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
- 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 apost_onlymarket; defer partial IOC; do not gate a full close. Observed 2026-09-10/11. → §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 place_orderreturns only adigest. GTC: place → appears inorderswith your tag → cancel → disappears. IOC: position before → place → position after; delta = fill; no delta →REJECTED(the next tick recalculates). → §7- 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 - 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
- Orders are read by market (
orderswithproduct_ids); a truncated read is indistinguishable from an empty account. → §8,account-and-fees.md§3
1. place_order payload
{ "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…"
} }
amountis a signed x18 size:+= buy,−= sell. There is no separate side field.priceX18is an x18 price already aligned to the market tick;amountis already aligned to the lot (markets-and-numbers.md§3). Reject a price or size quantized to 0 before sending.expirationis2^64 − 1for every type (api-and-signing.md§9).appendixcontains 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):
- TP clamp: “no larger than the position.” The sum of resting orders on the reducing side ≤ position.
- 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.
- 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_onlymarket 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:
- bump a below-minimum size above the minimum;
- on 2064, make one retry with the exact position size (
ceilSz(position)); - 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:
/** 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
REJECTEDeven 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):
{ "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(> 0buy,< 0sell); size =|unfilled_amount|;unfilled_amount === 0is a fully filled remainder that no longer rests in the book, so skip it. nonceis a string around 1e18:BigIntonly. 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)
- RO IOC larger than the position: clipped or 2064?
- Find the IOC floor with binary search → set the IOC minimum with a buffer.
- GTC: place → visible in
orderswith your tag → cancel → disappears, with digest incancelled_orders. - IOC entry: position delta is read as the fill.
- Full cycle at small size: entry, protective TP, partial reduction, full close to zero.
- In a
post_onlymarket (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_onlymarket 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.
© 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.