Metadata-Version: 2.4
Name: native-core-python-sdk
Version: 0.2.0
Summary: Python SDK for trading on Native Core via the public gateway (testnet only for now).
Project-URL: Homepage, https://app.native.org
Author: Native
License-Expression: MIT
License-File: LICENSE
Keywords: dex,native-core,orderbook,sdk,trading
Classifier: Development Status :: 3 - Alpha
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
Provides-Extra: mcp
Requires-Dist: mcp>=1.9; extra == 'mcp'
Description-Content-Type: text/markdown

# native-core-python-sdk

> **Testnet only.** This release connects to the Native Core **testnet** (the UAT gateway) and nothing else. Mainnet is not supported yet: approving an API wallet is currently open on the testnet site only, so a mainnet key cannot be created or used through the SDK. Treat this as an early, pre-1.0 release whose surface may still change; mainnet support will land in a later version.

A Python SDK for trading on [Native Core](https://native.org). It reads market data, places and cancels orders, and runs trading bots against the public gateway.

There are two classes. `Info` handles reads, `Exchange` handles writes. Calls are synchronous (built on `requests`) and return the gateway's JSON as plain dicts, annotated with `TypedDict`.

The SDK trades with an API wallet: a key scoped to placing and cancelling orders, never to moving funds (section 2 covers why that makes it safe in a bot). Deposits and withdrawals stay in the web app with your main wallet.

You need Python 3.10 or newer. The runtime dependencies are `requests`, `eth-account`, and `eth-utils`.

**Scope.** Spot order-book markets only (no perpetual futures). Testnet only for now. Calls are synchronous and blocking: a `/trade` write returns its settled outcome inline (no polling to learn whether it landed), and there is no async API and no websocket or streaming feed (wrap calls in your own executor if you need concurrency). Deposits and withdrawals are done in the web app, not through the SDK.

## 1. Install

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

While the SDK is pre-1.0, any release may change the API. Pin an exact version and review the changes before upgrading:

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

## 2. Get an API wallet

Before the SDK can trade, you need a funded account and an API wallet (your bot's key). You set this up once in the Native web app. The SDK never handles funds itself.

1. Open the [Native app](https://app-uat.native.org/markets/2?network=testnet) and connect your main wallet.
2. Fund the account by depositing through the app. There is no faucet: bring your own **Arbitrum Sepolia** testnet assets and deposit them from your main wallet. Your trading account is created on the first deposit.
3. Open the [API wallets panel](https://app-uat.native.org/markets/2?agentWallets=api&network=testnet) and click Create API wallet. Your main wallet signs one approval, and the app shows you a connection bundle that contains the private key. It shows the key once, so copy it now.

The bundle is a small JSON object. Save the whole thing to a file (e.g. `bundle.json` — that is what the quickstart loads), and hand it to the SDK as a file path, a dict, or a JSON string:

```jsonc
{
  "network": "testnet",              // which gateway to use
  "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 it can never withdraw or move funds off Native, so it is safe to run in a bot. Revoke it in the app any time to rotate it.

## 3. Quickstart

Load the bundle, place a resting limit order, confirm it rests, then cancel it. This runs on testnet:

```python
from decimal import Decimal

from native_core import Exchange, is_accepted, order_state

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)

# place() submits the order and waits for it to rest. It returns the handle and the state.
order = exchange.place(MARKET, is_buy=True, sz=sz, limit_px=px, tif="gtc")
print(order["cloid"], order["state"])     # 0x…  open

# Cancel it, then confirm it left the book.
cancel = exchange.cancel_by_cloid(MARKET, order["cloid"])
assert is_accepted(cancel)
final = info.wait_for_order(exchange.effective_account, MARKET, order["cloid"])
print(order_state(final))                 # cancelled
```

Two ideas this quickstart leans on — a write returns its **settled** outcome directly, and every order carries a reconcilable **`cloid`** — are the heart of the next section.

## 4. Core concepts

Reads versus writes. `Info` wraps `POST /info` (market data, balances, order status). `Exchange` wraps `POST /trade` (orders). Both return the gateway's raw JSON.

The write returns the settled outcome. The gateway is synchronous: a write's `submission_status` is exactly one of three values — **`accepted`** (the tx landed and executed, which INCLUDES a benign no-fill/no-rest cancel like `ioccancel` or `marketordernoliquidity`), **`rejected`** (an admission reject or a genuine execution failure, with the reason on `error.code`), or **`timeout`** (indeterminate). You do not poll to learn *whether* the write landed. What the response does **not** carry is the `oid`, the fill amount, or the fill/rest/cancel lifecycle — those live only on `/info` order status, so read the order once when you need them:

- `wait_for_open(user, market, cloid)` for an order you expect to rest (gtc / alo). It returns the resting snapshot (with the `oid`).
- `wait_for_order(user, market, cloid)` for one you expect to finish (ioc / fok / market, or after a cancel). It returns the terminal snapshot (with `filled_qty`).
- `exchange.place(...)` submits and does that one enrichment read for you, returning `{cloid, submission, status, state}`.

Do not call `wait_for_order` on a resting order. A resting order has no terminal state, so the call just times out. That is what `wait_for_open` is for.

Every order is reconcilable. `order()` and `market_order()` return the `cloid` and the `nonce` they used; `batch()` returns `cloids` (one per order 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 it with `wait_for_order`. Do not resubmit under a fresh nonce, or the order may land twice.

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

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 (decimal places, significant figures, a minimum notional). `info.snap_price(market, price)` and `info.min_order_size(market, price)` round a value to what the market accepts. The SDK validates before signing and raises `LocalValidationError` rather than round your number down without telling you.

Errors live in the response body. A business rejection is data, not an exception: `{"submission_status": "rejected", "error": {"code": …}}`. Read it with the helpers `is_accepted`, `is_rejected`, `is_timeout`, `error_code`, `retry_after_ms`, and `is_retryable`. The SDK raises only for a transport failure (`NetworkError`), a response that is not a trade response (`ClientError` / `ServerError`), or a problem it catches before signing (`LocalValidationError`).

Markets by symbol or id. Anywhere a `market` argument appears, pass `"ETH/USDT"` or its integer id. Symbols and precision are fetched once when you construct `Info`.

Testnet only, for now. This release runs on testnet. The bundle's `network` must be `testnet`, and `from_bundle` picks the gateway. Building by hand against `constants.MAINNET_API_URL` (or a bundle with `network: "mainnet"`) raises `LocalValidationError` — mainnet is not usable through the SDK yet, because an API wallet can only be approved on the testnet site. An API wallet is bound to one network.

## 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>" }
```

Read-only tours live under `examples/info/`. They place no orders, but still read your key from `config.json` (the account address is derived from the key when `account_address` is blank), so a valid `secret_key` must be present:

```bash
python examples/info/query_markets_info.py     # tradable markets and their precision
python examples/info/query_orderbook_info.py   # the L2 order book for a market
python examples/info/query_balances_info.py    # your spot balances
python examples/info/query_open_order_info.py  # your open orders in a market
```

The trading examples sit at the top level. They place real orders on testnet:

```bash
python examples/basic_order.py          # resting limit order: place, confirm it rested, cancel
python examples/basic_market_order.py   # market order with an explicit protection price
python examples/basic_batch.py          # several orders and cancels under one nonce
```

They default to testnet and `ETH/USDT`. Edit the `MARKET` constant to trade a different market.

## 6. Using it from an AI agent

The SDK is built to be driven by an AI agent, not just a person. If you are wiring an agent (or an autonomous bot) to trade, start here: one safety contract, two integration paths.

Pointing an agent at mainnet raises `LocalValidationError` at construction (testnet only for now — see the top of this README).

### The safety contract

An agent that places money-moving orders must hold to a few rules. The SDK returns each outcome as structured fields, so the agent branches on a field instead of parsing prose or remembering the rule. (Section 4 explains each rule; this is the agent-facing summary.)

- **Read the settled status; a fill is not implied.** A successful write's `submission_status` is `accepted` — the tx landed, but `accepted` INCLUDES a benign no-fill/no-rest cancel, so it does not mean the order rested or filled. The response carries no `oid`, fill amount, or lifecycle — call `reconcile_by_cloid` (or `wait_for_open` for a resting order) once to read them.
- **Never resubmit an uncertain write.** A wire timeout — `SubmissionUncertain`, or `submission_status: "timeout"` — means the order MAY still be live. Reconcile by `cloid` and act on the result; resubmitting under a fresh nonce risks a double-fill. The response says so directly: `next_action` is `RECONCILE_BY_CLOID`, with the cloid attached.
- **Rejections are data.** A business rejection comes back in the body with an `error.code`; fix the cause. Only `RateLimited` is safe to resend (`is_retryable`).
- **Numbers are strings.** Size with `min_order_size` and validate with `build_order` (a dry run that sends nothing) to avoid a precision or minimum-notional rejection.
- **Survive a restart.** Generate the `cloid` yourself with `random_cloid()`, persist `{intent, cloid}` durably *before* calling `order(..., cloid=cloid)`, and on restart reconcile every persisted cloid with `reconcile_by_cloid` before placing anything new. An agent that crashes after sending but before recording an SDK-generated cloid cannot reconcile and may double-fill — this is the idempotency-key pattern.

`next_action(response)` collapses any trade response into one verdict to branch on (it returns `None` for a non-trade response):

| `next_action` | Situation | What the agent does |
| --- | --- | --- |
| `READ_ORDER_STATUS` | `accepted` — landed & executed | Read `order_status` **once** for the `oid` / fill amount (the outcome itself is already settled — a single read, not a poll) |
| `RECONCILE_BY_CLOID` | `timeout` — indeterminate | `reconcile_by_cloid`; **never** resubmit |
| `BACKOFF_AND_RETRY` | `RateLimited` — never admitted | Sleep `retry_after_ms`, then resend the same order |
| `FIX_AND_RESUBMIT` | `rejected` — admission or execution failure | Read `error.code`, fix the input or account state, submit fresh |
| `None` | not a trade response | — |

`as_problem_details(failure)` renders any exception or rejected/timeout body into one flat `{type, title, retryable, next_action, cloids, …}` envelope (see the response helpers in section 7).

### 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, without you writing any glue code. Install it with the `mcp` extra:

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

Or, from a clone: `pip install -e ".[mcp]"`.

The server reads its configuration from the environment, never from the assistant. Reads are always available; the order-placing and cancelling tools are registered only when you explicitly turn trading on, so a read-only setup physically cannot place an order.

| Variable | Purpose |
| --- | --- |
| `NATIVE_CORE_BUNDLE` | Path to a connection bundle JSON (`{network, accountAddress, agentPrivateKey}`). Supplies everything on its own. |
| `NATIVE_CORE_NETWORK` | `testnet` (default) when not using a bundle. Mainnet is not supported yet. |
| `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: without it the server is read-only even if a key is present. |

Run it over stdio with `native-core-mcp` (or `python -m native_core.mcp`). To connect it to Claude Desktop, add this to its MCP config, pointing `NATIVE_CORE_BUNDLE` at your bundle file:

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

The read tools are always available: `whoami`, `list_markets`, `get_orderbook`, `get_balances`, `list_open_orders`, `get_order` (poll one order by cloid), `get_fills` (recent executions), `get_min_order_size`, and `reconcile_order` (the uncertain/timeout recovery). The write tools are registered only when trading is enabled: `preview_order` (a dry run that places nothing), `place_limit_order`, `place_market_order`, `cancel_order`, and `cancel_all_orders`. Every result is normalized so the assistant sees the order's state and what to do next; a timed-out or uncertain order comes back telling it to reconcile by cloid, never to resubmit.

Use a dedicated, limited API wallet here, never your main wallet. These tools are convenience, not a security boundary: the real protection is that the API wallet is scoped and revocable in the app.

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

If your agent does its own tool-calling (not MCP), call the SDK directly and hand the model the same decision-ready fields. A minimal place-then-reconcile loop:

```python
from native_core import Exchange

exchange = Exchange.from_bundle(BUNDLE)   # a testnet 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"])
if verdict["undetermined"]:
    ...   # not confirmed yet: keep reconciling by cloid, do not re-place
elif verdict["is_filled"]:
    ...   # fully filled
elif verdict["filled_qty"] != "0":
    ...   # partially filled and still resting — verdict["filled_qty"] is how much
else:
    ...   # resting, unfilled (verdict["state"], e.g. "open")
```

The response helpers and `reconcile_by_cloid` (section 7) are the agent-facing surface. The MCP tools above are thin wrappers over exactly these SDK calls: `get_order` ≈ `reconcile_by_cloid`, `get_min_order_size` ≈ `min_order_size`, `preview_order` ≈ `build_order`.

## 7. API reference

### Info (reads)

Build with `Info(base_url)` or `Info.from_bundle(bundle)`. 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)` | Resolve a `"BASE/QUOTE"` symbol to its integer market id |
| `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 |
| `open_orders_all(address, markets=None)` | Open orders across markets, each tagged with its market id |
| `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 orders at once |
| `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, …)` | Iterate all fills since a height, paged and deduped |
| `tx_status(user, cloid)` | Status of a deposit / withdraw / settle / repay by cloid |
| `account_status(address)` | Whether an account exists and its state |
| `spot_credit_account(address)` / `spot_credit_positions(address)` | Credit-account details |
| `user_agents(address)` / `agent_status(owner, agent_address)` | Approved API wallets; whether one is approved |
| `snap_price(market, price)` / `min_order_size(market, price)` | Round a price or size to what the market accepts |
| `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`) for the uncertain/timeout recovery path |

### Exchange (writes)

Build with `Exchange.from_bundle(bundle)`, or `Exchange(wallet, base_url, owner=<account_address>)` where `wallet` is an `eth_account` account made 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}`. `confirm` and `timeout` are keyword-only |
| `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` (worst acceptable price) 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 |
| `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), and `nonce`.

Also on `Exchange`: the `agent_address` property (the signing wallet), `effective_account` (the account orders act on: the owner in agent mode, otherwise the wallet), and the static `Exchange.random_cloid()`.

### Response helpers

`is_accepted`, `is_rejected`, `is_timeout`, `error_code`, `retry_after_ms`, and `is_retryable` classify a trade response, and `next_action` turns one into a single verdict (`READ_ORDER_STATUS`, `BACKOFF_AND_RETRY`, `RECONCILE_BY_CLOID`, or `FIX_AND_RESUBMIT`). `order_state` pulls the status string out of an `order_status` response; `is_terminal` and `is_undetermined` classify it, and `is_filled` / `filled_quantity` read the fill. `as_problem_details` renders any failure — an exception or a rejected/timeout body — into one flat envelope, and `retry_on_rate_limit(action)` wraps a write to resend only on `RateLimited`, honoring `retry_after_ms`. `random_cloid` returns a fresh client order id.

### Exceptions

Everything inherits from `native_core.Error`. Business rejections are not exceptions; they arrive in the response body (see above).

| Exception | Raised when |
| --- | --- |
| `LocalValidationError` | Before signing: bad precision, below the minimum notional, an unknown tif or 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 with `wait_for_order` |
| `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` |

`ErrorCode` is a convenience enum of known codes. Admission-layer codes are CamelCase — `RateLimited`, `ExpiredTx`, `WrongChainId`, `DirectSignerIsActiveAgent`, `AgentEpochMismatch`; execution-stage codes are lowercased — e.g. `insufficientspotbalance`, `badalopx`, `tick`, `lotsize`, `marketclosed`. An execution code lands on `error.code` of a `rejected` body when the transaction fails outright. But an order rejected purely at the order level — most notably a post-only (`alo`) order that would cross — comes back **`accepted`** (the transaction itself succeeded) with the code on its `orderStatus` lifecycle instead; the order did not rest. This is the sharpest case of `accepted` ≠ rested: read `orderStatus` for the real outcome. The code set is open-ended, so `error_code(resp)` returns unknown codes as raw strings.

### Rate limits and retries

The SDK surfaces rate limiting but does not throttle or retry on its own. A `RateLimited` rejection comes back in the body with `error.retry_after_ms`. Use `is_retryable(resp)` (true only for `RateLimited`, which is never admitted, so resending the same order is safe) and `retry_after_ms(resp)` to back off. A `timeout` is never retryable. A read that hits an HTTP 429 or 503 with no trade body raises `ClientError` / `ServerError`, so pace your own requests. The gateway's real limits are not exposed here; get them from the platform.

Under load the gateway can return two other write-plane families. `PlaceOrderSuspended` (HTTP 503, with `retry_after_ms`) is a clean `rejected` body emitted *before* the order reaches the node when the chain is degraded (only cancels are allowed); it is not `is_retryable`, so back off with `retry_after_ms(resp)` and resubmit yourself once it clears — the order did not land. A submit-routing failure (`HandoffTimeout`, `HandoffBufferFull:*`, `HandoffMultipleActive` at 503; `node_unreachable` at 504) instead arrives as a `timeout` body — indeterminate, so `next_action` is `RECONCILE_BY_CLOID`: reconcile by cloid, never resubmit.

### Transport controls

`Info` and `Exchange` (and their `from_bundle`) both accept `timeout` (per-request deadline in seconds, default 30; pass `None` for no deadline), `pool_maxsize` (connection pool size, default 100), and hooks for logging, latency capture, and tracing:

- `on_request(url_path, body, trace_id)` — called before each request; `trace_id` is the id sent with it (see below), or `None`.
- `on_response(url_path, status, body, elapsed_ms, trace_id)` — called after each response; `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 the gateway's own logs. You supply the id — the SDK never generates one:

- `trace_id_factory` (constructor) — called once per request for the id to send; e.g. `lambda: str(uuid.uuid4())` for a unique id, or a fixed value to group requests. Omit it to send no header.
- `trace_id=` (per call to `post`) — overrides the factory for one request.

To only capture the id the gateway itself generates, send none and read the `trace_id` argument of `on_response`.

```python
import uuid

exchange = Exchange.from_bundle(
    bundle,
    trace_id_factory=lambda: str(uuid.uuid4()),
    on_response=lambda path, status, body, ms, trace_id: log(path, trace_id),
)
```

### Constants

`constants.TESTNET_API_URL`, `constants.MAINNET_API_URL`, and `constants.NETWORK_URLS` (network name to gateway URL). The chain id is derived from the URL. The mainnet values are defined but not usable yet: constructing `Info` or `Exchange` against the mainnet gateway raises `LocalValidationError` (testnet only for now).

## 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 API wallet: the old key may have been revoked or replaced in the app. Call `info.agent_status(owner, agent)` to see the current slots. |
| `SubmissionUncertain` | A write timed out on the wire. Reconcile with `wait_for_order(user, market, e.cloid)`. Do not resubmit under a new nonce. |
| `NetworkError` | A transport failure (timeout, connection, DNS) 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; if you build by hand, match the endpoint to the bundle's `network`. |
| `LocalValidationError: … decimal places / significant figures` | `sz` or `limit_px` exceed the market's precision. Snap them with `info.snap_price` / `info.min_order_size`. |
| `insufficientspotbalance` (rejected) | The account is not funded for that market's quote asset. |
| `submission_status: "rejected"` | A hard write failure — an admission reject OR a genuine execution failure. Read `error_code(resp)`. It is data, not an exception. |
| `submission_status: "timeout"` | Indeterminate: the order may still land. Reconcile with `wait_for_order`; never resubmit with a new nonce. |
| `AgentEpochMismatch` | The SDK refreshes and retries once on its own. 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
```
