Metadata-Version: 2.5
Name: native-core-python-sdk
Version: 2.0.0
Summary: Python SDK for trading on Native Core via the public gateway (mainnet and testnet).
Project-URL: Homepage, https://app.native.org
Author: Native
License-Expression: MIT
License-File: LICENSE
Keywords: dex,native-core,orderbook,sdk,trading,websocket
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: eth-account>=0.11
Requires-Dist: eth-utils>=2.3
Requires-Dist: requests>=2.31
Requires-Dist: websocket-client>=1.7
Provides-Extra: mcp
Requires-Dist: mcp>=1.9; extra == 'mcp'
Description-Content-Type: text/markdown

# native-core-python-sdk

A Python SDK for trading on [Native Core](https://app.native.org/markets/ETH-USDT) — read market data, place and cancel orders, and run trading bots against the public gateway.

> **Mainnet moves real funds.** Develop against testnet first, use a scoped API wallet (never your main wallet), and start with small sizes. Mainnet is `api.native.org` (chain 696969), testnet is `api-test.native.org` (chain 969696); `from_bundle` picks the gateway from the bundle's `network`.

`Info` handles reads (`POST /info`), `Exchange` handles writes (`POST /trade`), and `WsClient` streams live data over a WebSocket. Everything is synchronous (built on `requests` and `websocket-client`) and returns the gateway's JSON as plain dicts annotated with `TypedDict`; stream frames arrive on a background thread, so no part of the SDK asks you to write async code. **Spot order-book markets only** — no perpetuals — and **trading and market data only**: deposits, withdrawals and account administration stay in the web app. Requires Python 3.10+ (`requests`, `eth-account`, `eth-utils`, `websocket-client`).

> **Rate limits are counted per client IP, not per address, and they are tighter than most people expect.** On mainnet an IP gets **one order per second and one read per second**; those are two separate budgets, and each is shared between HTTP and the WebSocket. Two bots on one machine, or two behind one office NAT, share a single budget no matter how many addresses they sign with. See [§7 Rate limits](#rate-limits-and-retries).

## 1. Install

```bash
pip install native-core-python-sdk
```

Pin a version for reproducible installs: `pip install native-core-python-sdk==2.0.0`.

## 2. Get an API wallet

You need a funded account and an API wallet (your bot's key), set up once in the app. In the Native app ([mainnet](https://app.native.org/markets/ETH-USDT?agentWallets=agents) or [testnet](https://app-uat.native.org/markets/ETH-USDT?agentWallets=agents)): connect your main wallet, deposit to fund it (testnet has no faucet — bring Arbitrum Sepolia; mainnet takes real assets), then Create API wallet. You sign one approval and the app shows the connection bundle **once** — copy it.

Save it (e.g. `bundle.json`, what the quickstart loads) and pass it to the SDK as a file path, dict, or JSON string:

```jsonc
{
  "network": "testnet",       // "mainnet" or "testnet" — picks the gateway
  "accountAddress": "0x…",    // your main wallet, the account the bot trades on
  "agentPrivateKey": "0x…"    // the API wallet key the SDK signs with
}
```

A leaked API wallet key can trade your balance but can never withdraw or move funds off Native, so it is safe in a bot; revoke it in the app to rotate.

## 3. Quickstart

Place a resting limit order, confirm it rests, then cancel it. Point `BUNDLE` at a testnet bundle to try it risk-free:

```python
from decimal import Decimal

from native_core import Exchange, is_order_failed, leaf_error_code, order_oid

BUNDLE = "bundle.json"   # the file you saved, or the dict / JSON string itself
MARKET = "ETH/USDT"

exchange = Exchange.from_bundle(BUNDLE)   # picks the gateway, loads the key, sets owner
info = exchange.info

# Check the API wallet is approved before you trade.
print(exchange.agent_info())              # {"approved": True, "slot_id": 0, "epoch": 10}

# Price a bid well below the market so it rests instead of filling.
book = info.l2_book(MARKET, depth=1)
reference = book["asks"] or book["bids"]  # whichever side has liquidity
px = info.snap_price(MARKET, Decimal(reference[0]["price"]) / 2)
sz = info.min_order_size(MARKET, px)

resting = exchange.order(MARKET, is_buy=True, sz=sz, limit_px=px, tif="gtc")

# An accepted write can still carry a rejected order, so check before trusting it.
if is_order_failed(resting):
    raise SystemExit(f"rejected: {leaf_error_code(resting)}")

oid = order_oid(resting)                  # the oid comes back on the write itself
cancel = exchange.cancel(MARKET, oid)
print("cancelled", order_oid(cancel))     # the write reports what it removed
```

## 4. Core concepts

**A write's `submission_status` is exactly one of three values**: **`accepted`**, **`rejected`** or **`timeout`**.

**`accepted` is a verdict on the transaction, not on your order.** Only two kinds of problem reject at the top level: the six envelope-level codes (bad nonce, bad signature, expired tx, malformed tx, bad batch length, feature disabled), and the node's admission refusals (`RateLimited`, `PlaceOrderSuspended`, `WrongChainId` and friends). A tick-size violation, a lot-size violation, an ALO that would cross, an insufficient balance found at execution all come back `accepted`, with the reason on the failing action's leaf inside the response. Checking `submission_status` alone will report those as successful orders:

```python
resp = exchange.order(MARKET, is_buy=True, sz="0.01", limit_px="1700.005", tif="alo")
is_accepted(resp)       # True — the transaction landed
is_order_failed(resp)   # True — but the order did not: the price is off-tick
leaf_error_code(resp)   # "tick"
```

The response carries the outcome itself, so the `oid`, the fill amount and each batch leg's result are all available without a second call: `order_oid(resp)` is the assigned order id, `fill(resp)` is `{total_sz, avg_px, oid}` in the same display format `/info` uses, `batch_legs(resp)` is one entry per leg in request order, and `next_action(resp)` folds all of them into one verdict string to branch on. Reading these off the response instead of `/info` saves a read, and reads are the scarce resource ([§7](#rate-limits-and-retries)).

When you do need to poll, `wait_for_open` is for an order you expect to rest (gtc / alo) and `wait_for_order` for one you expect to finish (ioc / fok / market, or after a cancel); a resting order never reaches a terminal state, so `wait_for_order` on one just times out. `exchange.place(...)` picks the right one by tif and skips the read entirely when the response already settled the order.

**Every order is reconcilable.** `order()` and `market_order()` return the `cloid` and `nonce` they used; `batch()` returns `cloids` (one per leg) plus the shared `nonce`. Pass your own `cloid` or let the SDK generate one — either way the response carries it. If a write times out on the wire, the SDK raises `SubmissionUncertain` with the cloid attached: reconcile with `reconcile_by_cloid`, and do not resubmit under a fresh nonce unless `is_safe_to_resend` says the transaction never left the gateway ([§7](#rate-limits-and-retries)).

**One Exchange per API wallet.** The nonce is a per-instance, lock-guarded monotonic counter, so a single `Exchange` is safe to share across threads. Two instances — or two processes — signing with the same key hand out colliding nonces and draw seemingly random rejections. Construct one and share it.

**Numbers are strings, never floats.** Pass `sz` and `limit_px` as `str` or `Decimal` (`"0.01"`, not `0.01`). Each market has a fixed precision; `info.snap_price(market, price)` and `info.min_order_size(market, price)` round a value to what it accepts. The SDK validates before signing and raises `LocalValidationError` rather than silently round your number down.

**Errors live in the response body.** A business rejection is data, not an exception: `{"submission_status": "rejected", "error": {"code": …}}`, read with the [response helpers](#response-helpers). The SDK raises only for transport failures, non-trade responses, pre-sign problems, unanswered writes and refused subscriptions — see [Exceptions](#exceptions).

## 5. Running the examples

The examples ship in the source distribution (the `.tar.gz` on PyPI), under `examples/`. They read `examples/config.json` — copy the template and fill in your key:

```bash
cp examples/config.json.example examples/config.json
```

```json
{ "secret_key": "0x<agentPrivateKey>", "account_address": "0x<accountAddress>" }
```

This is the connection bundle flattened: `secret_key` is the bundle's `agentPrivateKey`, `account_address` its `accountAddress`. There is no `network` field — the examples hardcode `constants.TESTNET_API_URL`, so they always run on testnet; edit that argument to `constants.MAINNET_API_URL` only to trade real funds.

Run one with `python <path>`. The read-only tours place no orders but still need a valid `secret_key` (`account_address` is derived from it when blank); the trading tours place real orders on testnet in ETH/USDT (edit `MARKET` to change); `ws_feeds.py` needs no key at all and takes an optional gateway URL, e.g. `python examples/ws_feeds.py https://api.native.org`.

| Script | What it does |
| --- | --- |
| `examples/info/query_markets_info.py` | Tradable markets and their precision |
| `examples/info/query_orderbook_info.py` | The L2 order book for a market |
| `examples/info/query_balances_info.py` | Your spot balances |
| `examples/info/query_open_order_info.py` | Your open orders in a market |
| `examples/basic_order.py` | Resting limit order: place, confirm, cancel |
| `examples/basic_market_order.py` | Market order with a protection price |
| `examples/basic_batch.py` | Several orders and cancels under one nonce |
| `examples/ws_feeds.py` | Stream the book, top of book, and trades |

## 6. Using it from an AI agent

The SDK returns each outcome as structured fields, so an agent branches on a field instead of parsing prose. One safety contract, two integration paths.

### The safety contract

- **`accepted` is a verdict on the transaction, not on your order** ([§4](#4-core-concepts)). Check `is_order_failed(resp)`, read `leaf_error_code(resp)` for the cause, and use `is_benign_cancel(resp)` to tell a routine unfilled IOC from a real fault — both arrive on the same error leaf.
- **A failed order barely exists anywhere else.** It is never assigned an oid and writes nothing to the fills feed; a lookup by cloid may surface a null-oid early-failure row, but the leaf in the `/trade` response is the evidence to rely on.
- **Never resubmit an indeterminate write.** `submission_status: "timeout"` or a raised `SubmissionUncertain` means the order MAY be live; reconcile by `cloid`. The one exception is the handoff family (`HandoffTimeout`, `HandoffBufferFull:*`, `HandoffMultipleActive`), which the gateway guarantees never reached the chain — `is_safe_to_resend(resp)` tests exactly that set.
- **Numbers are strings.** Size with `min_order_size` and validate with `build_order` (a dry run that sends nothing).
- **Survive a restart.** Generate the `cloid` yourself with `random_cloid()`, persist `{intent, cloid}` durably *before* calling `order(..., cloid=cloid)`, and reconcile every persisted cloid on restart. An agent that crashes after sending but before recording an SDK-generated cloid cannot reconcile and may double-fill.

`next_action(response)` collapses any trade response into one verdict (`None` if it is not a trade response):

| `next_action` | Situation | What the agent does |
| --- | --- | --- |
| `USE_RESPONSE_OUTCOME` | Succeeded | Nothing more to fetch — `order_oid` and `fill` are already on the response |
| `ORDER_CLOSED_UNFILLED` | Benign cancel (unfilled IOC, STP) | Order is over, nothing filled, nothing wrong |
| `FIX_AND_RESUBMIT` | Rejected, at either level | Read `leaf_error_code` or `error.code`, fix, submit fresh |
| `BACKOFF_AND_RETRY` | Never reached the chain | Sleep `retry_after_ms`, resend the same order |
| `RECONCILE_BY_CLOID` | Indeterminate timeout | `reconcile_by_cloid`; **never** resubmit |
| `READ_ORDER_STATUS` | Accepted, no outcome envelope | A gateway older than read-node 119d935; read `order_status` once |

`as_problem_details(failure)` renders any exception, any rejection, and any accepted-but-failed order into one flat `{type, title, retryable, next_action, cloids, …}` envelope, and returns `None` when what you passed is not a failure — so `if (p := as_problem_details(x)) is not None:` is the intended shape.

### Option A — the MCP server (no glue code)

An optional MCP server lets an AI assistant that speaks the [Model Context Protocol](https://modelcontextprotocol.io) (such as Claude Desktop) read the market and place orders through the SDK. Install the extra:

```bash
pip install "native-core-python-sdk[mcp]"
```

Configure it from the environment (never from the assistant). Reads are always available; the write tools are registered only when you turn trading on, so a read-only setup physically cannot place an order. If `NATIVE_CORE_ENABLE_TRADING=1` but the agent key is missing, malformed, or unapproved, the server starts read-only and prints the reason instead of crashing.

| Variable | Purpose |
| --- | --- |
| `NATIVE_CORE_BUNDLE` | Path to a connection bundle JSON. Supplies everything on its own. |
| `NATIVE_CORE_NETWORK` | `testnet` (default) or `mainnet`, when not using a bundle. |
| `NATIVE_CORE_BASE_URL` | A gateway URL to use directly. Takes precedence over `NATIVE_CORE_NETWORK`. |
| `NATIVE_CORE_ACCOUNT` | Your account address, for account-scoped reads. |
| `NATIVE_CORE_AGENT_KEY` | The API wallet's private key, required for trading. |
| `NATIVE_CORE_ENABLE_TRADING` | Set to `1` to register the write tools. Off by default. |

Run it over stdio with `native-core-mcp`. To connect it to Claude Desktop, add to its MCP config:

```json
{
  "mcpServers": {
    "native-core": {
      "command": "native-core-mcp",
      "env": {
        "NATIVE_CORE_BUNDLE": "/path/to/your/bundle.json",
        "NATIVE_CORE_ENABLE_TRADING": "1"
      }
    }
  }
}
```

Read tools: `whoami`, `list_markets`, `get_orderbook`, `get_balances`, `list_open_orders`, `get_order`, `get_fills`, `get_min_order_size`, `reconcile_order`. Write tools (trading only): `preview_order`, `place_limit_order`, `place_market_order`, `cancel_order`, `cancel_all_orders`. Every result is normalized, so a timed-out order comes back telling the assistant to reconcile by cloid, never to resubmit. Use a dedicated, revocable API wallet — these tools are convenience, not a security boundary.

### Option B — drive the SDK from your agent code

If your agent does its own tool-calling, call the SDK directly and hand the model the same fields:

```python
exchange = Exchange.from_bundle(BUNDLE)
order = exchange.place(MARKET, is_buy=True, sz=sz, limit_px=px, tif="gtc")

# Resolve the real outcome by cloid — never resubmit on an uncertain one.
verdict = exchange.info.reconcile_by_cloid(exchange.effective_account, MARKET, order["cloid"])
# verdict["undetermined"] — not confirmed yet: keep reconciling, do not re-place.
# verdict["is_filled"] — fully filled; verdict["filled_qty"] != "0" — partially filled, still resting.
# otherwise the order is resting and unfilled, at verdict["state"] (e.g. "open").
```

## 7. API reference

### Info (reads)

Build with `Info(base_url)` or `Info.from_bundle(bundle)`; symbols and precision are fetched once at construction. Any `market` argument takes a `"BASE/QUOTE"` symbol or an integer market id.

| Method | Returns |
| --- | --- |
| `markets()` / `assets()` / `quote_assets()` | Tradable markets and their assets, with precision |
| `resolve_market_id(market)` | The integer market id for a `"BASE/QUOTE"` symbol |
| `l2_book(market, depth=20)` | Order book, up to 100 levels |
| `mark_prices(asset_ids=None)` / `oracle_status()` | Mark prices; oracle health |
| `query_status()` | Current query height and the available block-height window |
| `user_balances(address)` | Spot balances |
| `open_orders(address, market)` | Resting orders in one market. The gateway caps the list and sets `truncated` when it cut one, and there is no offset to page past that, so check the flag |
| `open_orders_all(address, markets=None, *, per_market=False)` | Open orders across markets, each tagged with its market id. One read by default; `per_market=True` falls back to one read per market, and truncation is logged as a warning |
| `order_status(oid=None, user=None, market=None, cloid=None)` | One order, by oid or by (user, market, cloid) |
| `batch_order_status(orders)` | Up to 20 order lookups in one read, answered in request order |
| `user_fills(address, from_height, to_height, limit)` | Fills in a raw block-height window (≤10,000 blocks) |
| `recent_fills(address, blocks=10000)` | Every fill in roughly the last N blocks, window resolved for you |
| `iter_user_fills(address, start_height=None, end_height=None, window=10000, page_limit=500, *, clamp=False)` | Iterate all fills since a height, paged and deduplicated by `tid`. `page_limit` is clamped to the server's cap of 500, and `clamp=True` starts at the oldest retained height instead of raising when the advancing window has already pruned the blocks it reached for |
| `account_status(address)` | Whether an account exists and its state |
| `spot_credit_account(address)` / `spot_credit_positions(address)` | Credit-account details |
| `credit_trading_allowed(account, market)` | Whether that account may trade that market on credit |
| `user_agents(address)` / `agent_status(owner, agent_address)` | Approved API wallets; whether one is approved |
| `snap_price(market, price, rounding=ROUND_DOWN)` / `min_order_size(market, price, margin="1.1")` | Round a price or size to what the market accepts. `margin` is the headroom over the quote asset's minimum notional, so `"1.1"` asks for 10% more than the bare minimum |
| `protection_price(market, is_buy, slippage_bps, ref_price=None)` | Worst acceptable price for a market order, derived from the book |
| `wait_for_open(user, market, cloid, timeout=5.0)` | Poll until the order is resting or terminal |
| `wait_for_order(user, market, cloid, timeout=5.0)` | Poll until the order is terminal |
| `reconcile_by_cloid(user, market, cloid, timeout=5.0)` | Did an order land? A one-call verdict (`state`, `undetermined`, `is_filled`, `filled_qty`, plus the raw `status`) |

### Exchange (writes)

Build with `Exchange.from_bundle(bundle)`, or `Exchange(wallet, base_url, owner=<account_address>)` where `wallet` is an `eth_account` account from your API wallet key.

| Method | Description |
| --- | --- |
| `place(market, is_buy, sz, limit_px, tif, cloid=None, *, confirm=True, timeout=5.0)` | Submit an order and wait for the outcome that matches its tif; returns `{cloid, submission, status, state, oid}` |
| `order(market, is_buy, sz, limit_px, tif, cloid=None)` | Limit order. `tif` is `gtc` / `ioc` / `fok` / `alo` |
| `market_order(market, is_buy, sz, protection_px=None, tif="ioc", cloid=None, *, slippage_bps=None)` | Market order (ioc / fok). Pass `protection_px` or `slippage_bps` to derive it from the book |
| `build_order(market, is_buy, sz, limit_px, tif, cloid=None, order_type="limit")` | Dry run: validate and build an order without signing or sending it |
| `sign_action(action)` | Sign a built action into a ready-to-send `/trade` body. Consumes a nonce, so the body is single-use |
| `cancel(market, oid)` / `cancel_by_cloid(market, cloid)` | Cancel one order |
| `cancel_all(market)` | Cancel every open order in a market |
| `cancel_open(markets=None)` | Cancel every open order across markets (only where orders rest) |
| `modify(market, oid_or_cloid, replacement)` | Atomically cancel and replace one order |
| `batch(items)` | Up to 10 mixed order / cancel / cancelAll / modify actions under one nonce |
| `set_expires_after(expires_after_ms)` | Attach an expiry to every signed action |
| `agent_info()` | Whether this API wallet is approved on the owner: `{approved, slot_id, epoch}` |

Each write returns the raw gateway response with the client handle echoed in: `submission_status`, `tx_hash`, `error`, `cloid` (or `cloids` for a batch), `nonce`, and `response` — the settled outcome envelope holding the oid, the filled size, the average price and one leaf per batch action. Reach into it with `trade_envelope`, `order_oid`, `fill` and `batch_legs` rather than by hand. Also on `Exchange`: the `agent_address` property (the signing wallet), `effective_account` (the account orders act on: the owner in agent mode, else the wallet), and the static `Exchange.random_cloid()`.

### WsClient (live data)

Build with `WsClient(base_url)`, or `Info.ws()` / `Exchange.ws()` to reuse a market table already loaded. Call `connect()` before subscribing, and `close()` when done (it is also a context manager). Nine feeds stream over one connection, and `examples/ws_feeds.py` is a runnable tour that needs no key.

```python
ws = WsClient("https://api.native.org")
ws.connect()

ws.subscribe_l2_book("ETH/USDT", lambda data: print(data["levels"]))  # callback style

ws.subscribe_trades("ETH/USDT")                                       # iterator style
for message in ws.stream():
    print(message["channel"], message["data"])
```

| Method | Feed |
| --- | --- |
| `subscribe_trades(market, cb=None)` | Every trade printed on a market |
| `subscribe_l2_book(market, cb=None)` | Full book snapshot, ≤10 levels a side, ≤2/s |
| `subscribe_bbo(market, cb=None)` | Top of book, only when it changes |
| `subscribe_all_mids(cb=None)` | Mid price for every market that has liquidity on both sides, ≤1 per 5s |
| `subscribe_user_fills(address, cb=None)` | An account's fills; first message replays the recent 100 |
| `subscribe_order_updates(address, cb=None)` | Order lifecycle: open / filled / canceled |
| `subscribe_open_orders(address, cb=None)` | All resting orders, as a full replacement |
| `subscribe_spot_state(address, cb=None)` | Spot balances, as a full replacement |
| `subscribe_spot_credit_state(address, cb=None)` | Credit positions and credit line |
| `subscribe(body, cb=None)` / `unsubscribe(sub)` | Raw subscription body; stop a feed |
| `stream(timeout=None)` | Iterate feeds subscribed without a callback |
| `post_info(payload)` / `post_action(signed_body)` | Run an `/info` read or submit a signed order over the socket |

A callback receives the payload and runs on the reading thread, so it must return quickly. What a slow one costs depends on the feed: the six snapshot feeds are conflated, so you silently miss intermediate frames but stay connected, while the three event feeds (`trades`, `userFills`, `orderUpdates`) queue and the server drops the connection once that queue fills. Staying connected is therefore not evidence you are keeping up. Subscribing without a callback routes the feed to `stream()`, which buffers (`queue_maxsize`, default 10,000, dropping oldest and counting them in `dropped_messages`).

**The socket names the same data differently than HTTP does, and one pair is a trap.** These are the gateway's Hyperliquid-compatible names, passed through rather than renamed. Check this table before swapping a polling loop for a feed:

| | HTTP (`Info`) | socket (`WsClient`) |
| --- | --- | --- |
| Book level | `price`, `quantity`, `order_count` | `px`, `sz`, `n` |
| Balance | `asset_id`, `symbol`, `available`, `locked` | `token`, `coin`, **`total`**, `hold` |

`available` and `total` are **not the same quantity**. `available` is what you can spend; `total` is everything you hold, `available + locked`. Reading `total` where you used to read `available` overstates your free balance by exactly the amount sitting in resting orders — silently, and only once you have orders on the book. Use `total - hold`:

```python
# Info.user_balances                    # spotState feed
free = Decimal(row["available"])        free = Decimal(row["total"]) - Decimal(row["hold"])
```

Subscribing blocks until the server answers, so a refusal raises `SubscriptionError` at the call rather than surfacing later. Reconnects and resubscribes are automatic; `on_reconnect` fires once subscriptions are restored. Snapshot feeds repair themselves and `userFills` replays on resubscribe, but **`trades` and `orderUpdates` gap across a disconnect** — re-read those through `Info` from `on_reconnect`. **Event frames can also go missing without a disconnect:** when read-node's broadcast thread falls behind the block ring it drops that gap's `trades`, `userFills` and `orderUpdates` outright and re-pushes only the snapshot feeds. The connection stays up and `on_reconnect` never fires, so `userFills` does not self-heal here the way it does on resubscribe. Anything that needs complete fills must poll `Info.recent_fills` periodically and reconcile by `tid`. Note that `orderUpdates` frames carry no address, so subscribing several accounts on one connection delivers every account's updates to every callback; tell them apart by `oid`.

To submit an order over the socket, `Exchange.build_order` validates and builds it, `Exchange.sign_action` signs it (consuming a nonce, so the body is single-use), and `post_action` sends it:

```python
built = exchange.build_order("ETH/USDT", True, "0.01", "1900", "gtc")
response = ws.post_action(exchange.sign_action(built["action"]))
```

**A write reports less here than over HTTP.** When the gateway answers a write with anything other than HTTP 2xx, the server renders the reply as a bare string and discards the trade response, so `submission_status`, `tx_hash` and `retry_after_ms` are gone on exactly the paths where you would want them. `post_action` turns those into exceptions instead: a refusal before the node (400 bad signature or nonce, 429 rate limit) raises `ClientError`, and everything else — including the routing failures HTTP reports as a `timeout` (`HandoffTimeout`, `HandoffBufferFull`, `node_unreachable`) — raises `SubmissionUncertain`, because the order may still have landed. Reconcile those by cloid. A write is also never retried automatically, including on a rate limit. The socket caps a message at 64 KiB where HTTP `/trade` accepts 256 KiB, so an oversized request is refused locally rather than silently closing the connection. None of this is faster: the socket shares its rate budget with HTTP and the round trip is the same either way, so prefer `Exchange.order`.

### Response helpers

**The order's own outcome**, from the write response, no extra read: `order_oid` (assigned id), `fill` (`total_sz` / `avg_px` / `oid`), `batch_legs` (one entry per batch leg, in request order), `is_order_failed` (did any leg genuinely fail), `leaf_error_code` and `leaf_errors` (the lowercase per-action codes), `is_benign_cancel` (is that code a normal no-fill rather than a fault), `trade_envelope` and `trade_outcomes` for the raw shapes.

**The transaction's status**: `is_accepted`, `is_rejected`, `is_timeout`, `error_code`, `retry_after_ms`, `is_retryable`, plus `is_safe_to_resend` for the handoff timeouts that never reached the chain. `next_action` folds all of it into one verdict.

**An `order_status` snapshot**: `order_state`, `is_terminal`, `is_undetermined`, `is_filled`, `filled_quantity`. **Everything else**: `as_problem_details` renders any failure into one flat envelope, `info_error` reads the error object off an `/info` response that returned HTTP 200, `retry_on_rate_limit(action)` resends a write only on `RateLimited`, and `random_cloid` returns a fresh client order id.

### Exceptions

Everything inherits from `native_core.Error`, and the WebSocket raises the same set the HTTP path does (its write-path mapping is under [WsClient](#wsclient-live-data)), so one `except` block covers both transports. Business rejections are not exceptions; they arrive in the response body (above).

| Exception | Raised when |
| --- | --- |
| `LocalValidationError` | Before signing: bad precision, below minimum notional, unknown tif/market, or (at construction) an API wallet that is not an approved agent |
| `NetworkError` | A transport failure (timeout, connection, DNS) before any response arrived |
| `SubmissionUncertain` | A write was signed and sent, then timed out. Carries `cloid` and `nonce` to reconcile |
| `ClientError` | An HTTP 4xx whose body is not a trade response. Has `status_code`, `error_code`, `error_message` |
| `ServerError` | An HTTP 5xx whose body is not a trade response. Has `status_code` |
| `SubscriptionError` | A WebSocket subscribe or unsubscribe was refused or went unanswered. Has `reason` and `subscription` |

**Two vocabularies live at two levels.** The top-level `error.code` is CamelCase, and `ErrorCode` is a convenience enum of the known ones: `RateLimited`, `ExpiredTx`, `WrongChainId`, `DirectSignerIsActiveAgent`, `AgentEpochMismatch`, `InsufficientSpotBalance`. Read it with `error_code(resp)`, which returns anything outside the enum as a raw string. Per-action failures live one level down, on the leaf inside the `response` envelope, and are lowercase: `tick`, `lotsize`, `badalopx`, `insufficientspotbalance`, `mintradespotntl`, `missingorder`. Read those with `leaf_error_code(resp)`, never with `error_code`. The case worth memorizing: a post-only (`alo`) order that would cross comes back **`accepted`** with `badalopx` on its leaf, meaning the transaction succeeded and the order never rested, so `is_order_failed(resp)` is True with no second call.

### Rate limits and retries

The gateway can rate-limit requests: an HTTP 429 with `error.code` `RateLimited` and a suggested retry delay. Because a 429 never reached the node, the SDK retries it automatically — a bounded `rate_limit_retries` (default 3), backing off by the server's delay. Pass `rate_limit_retries=0` to surface a 429 at once; when retries are exhausted a read raises `ClientError`.

**Budget your bot against these numbers, and note the unit: client IP.** The gateway keys every limit on the source IP it sees, never on the signing address. Writes and reads have separate budgets, and each is shared between HTTP and the WebSocket, so sending a request through the socket buys no extra headroom. Mainnet, as deployed:

| | Per client IP |
| --- | --- |
| Orders per second | **1** |
| Reads per second | **1** |
| Subscriptions per connection | 10 |
| Posts outstanding at once | 1 |
| Connections | 1 |
| New connections per minute | 30 |

Two consequences people get wrong in both directions. Running several addresses from one host does NOT multiply your budget — they contend for the same one, and the second bot starts collecting 429s. Running one address from several hosts is NOT limited to the single-IP rate — each host gets its own budget. The 30-connections-per-minute ceiling matters if you reconnect aggressively: `WsClient` backs off from 2 seconds and doubles, which by itself tops out at exactly 30 attempts a minute, so a second client on the same IP can push you over.

The platform sets these and may change them, so the SDK does not hard-code the per-second rates; it reads the delay out of each rejection. `WsClient` defaults `max_subscriptions=10` and `max_inflight_posts=1` to match, and both are constructor arguments an integration granted higher limits can raise.

A `RateLimited` on a `/trade` write can instead arrive as a trade body (`rejected` with `error.retry_after_ms`); branch on it with `is_retryable(resp)` (true only for `RateLimited`, safe to resend) and back off with `retry_after_ms(resp)`, or wrap the write in `retry_on_rate_limit(...)`. A `timeout` never satisfies `is_retryable`, so read it with `is_safe_to_resend` instead. Under load, two other write-plane families appear:

| Response | What it means | What to do |
| --- | --- | --- |
| `PlaceOrderSuspended` (503, a clean `rejected` before the node) | The chain is degraded and only cancels are admitted | Back off by `retry_after_ms` and resubmit once it clears |
| `HandoffTimeout`, `HandoffBufferFull:*`, `HandoffMultipleActive` (503, arriving as a `timeout`) | The write plane never handed the transaction to a node, so it definitely did not land | Safe to resend after backing off; `is_safe_to_resend(resp)` tests exactly this set |
| Any other `timeout`, including `node_unreachable` (504) and a plain wait-budget expiry carrying no error code | Indeterminate: the order may be live | Reconcile by cloid, never resubmit |

### Transport controls

`Info` and `Exchange` (and their `from_bundle`) accept `timeout` (per-request deadline in seconds, default 30; `None` for no deadline), `pool_maxsize` (connection pool, default 100), `rate_limit_retries` (default 3; `0` to disable), and hooks:

- `on_request(url_path, body, trace_id)` — before each request.
- `on_response(url_path, status, body, elapsed_ms, trace_id)` — after each; `trace_id` is the `x-trace-id` the gateway returned, or `None`.

The gateway accepts an `x-trace-id` request header and echoes it back, so you can line up a request with its logs. You supply the id via `trace_id_factory` (once per request) or `trace_id=` (per call to `post`); the SDK never generates one.

```python
import uuid

exchange = Exchange.from_bundle(bundle, trace_id_factory=lambda: str(uuid.uuid4()))
```

### Constants

`constants.TESTNET_API_URL`, `constants.MAINNET_API_URL`, and `constants.NETWORK_URLS` (network name → gateway URL). The chain id is derived from the URL, and it is part of every signed payload, so an API wallet is bound to one network: signing against the other gateway is rejected with `WrongChainId`.

## 8. Troubleshooting

| You see | Meaning and fix |
| --- | --- |
| `LocalValidationError: … is not an active agent for owner` (at construction) | The API wallet is not approved on that account. Check the owner, or create a fresh one — the old key may have been revoked. `info.agent_status(owner, agent)` shows the current slots. |
| `SubmissionUncertain` | A write timed out on the wire. Reconcile with `reconcile_by_cloid(user, market, e.cloid)`. Do not resubmit under a new nonce, unless it is a handoff timeout ([§7](#rate-limits-and-retries)). |
| `NetworkError` | A transport failure on a read. Retry the read. |
| `DirectSignerIsActiveAgent` | You built `Exchange` with an API wallet key but no `owner`. Pass `owner=<accountAddress>`, or use `from_bundle`. |
| `WrongChainId` | The key's network does not match the gateway. `from_bundle` avoids this. |
| `LocalValidationError: … decimal places / significant figures` | `sz` or `limit_px` exceed the market's precision. Snap with `info.snap_price` / `info.min_order_size`. |
| `submission_status: "accepted"` but the order is not on the book | A per-action failure. `is_order_failed(resp)` is True and `leaf_error_code(resp)` names it; `is_benign_cancel(resp)` separates a routine unfilled IOC or STP cancel from a real fault. |
| `insufficientspotbalance` (on the leaf, under an `accepted` write) | The account is not funded for that market's quote asset. `is_order_failed(resp)` is True. |
| `submission_status: "rejected"` | A hard write failure — admission reject or execution failure. Read `error_code(resp)`. Data, not an exception. |
| `submission_status: "timeout"` | The order may still land. Reconcile by cloid, and resubmit only when `is_safe_to_resend(resp)` says so ([§7](#rate-limits-and-retries)). |
| `RateLimited` / HTTP 429 | The gateway is throttling you. The SDK already retried with backoff, so slow down; see [§7](#rate-limits-and-retries). |
| `AgentEpochMismatch` | The SDK refreshes and retries once. If it persists, the API wallet was revoked or re-approved; create a new one. |

## 9. Development

```bash
make install   # uv sync
make lint      # ruff + mypy --strict
make test      # pytest
```
