Metadata-Version: 2.4
Name: native-core-python-sdk
Version: 1.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
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
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`). Calls are synchronous (built on `requests`) and return the gateway's JSON as plain dicts, annotated with `TypedDict`. **Spot order-book markets only** — no perpetuals, no async, no websocket. A `/trade` write returns its settled outcome inline, so you never poll to learn whether it landed. Deposits and withdrawals stay in the web app. Requires Python 3.10+ (`requests`, `eth-account`, `eth-utils`).

## 1. Install

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

Pin a version for reproducible installs: `pip install native-core-python-sdk==1.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 — the SDK never handles funds. 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_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, returning the handle and 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 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 vs 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.** A write's `submission_status` is exactly one of three values: **`accepted`** (the tx landed and executed — this INCLUDES a benign no-fill/no-rest cancel like `ioccancel` or `marketordernoliquidity`), **`rejected`** (an admission reject or a genuine execution failure, reason on `error.code`), or **`timeout`** (indeterminate). You never 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); 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); returns the terminal snapshot (with `filled_qty`).
- `exchange.place(...)` does that one enrichment read for you, returning `{cloid, submission, status, state}`.

Do not call `wait_for_order` on a resting order — it 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 `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 `wait_for_order`, and never resubmit under a fresh nonce or the order may land twice.

**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 it with `is_accepted`, `is_rejected`, `is_timeout`, `error_code`, `retry_after_ms`, `is_retryable`. The SDK raises only for a transport failure (`NetworkError`), a non-trade response (`ClientError` / `ServerError`), or a pre-sign problem (`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`.

**Networks.** An API wallet is bound to one network: the chain id is part of every signed payload, so signing with a key from the wrong network is rejected with `WrongChainId`. `from_bundle` matches the gateway to the bundle's `network` for you.

## 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`. `config.json` has 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. A blank or malformed key fails with a clear `LocalValidationError`.

```bash
# read-only tours (examples/info/) — place no orders, but still need a valid
#   secret_key in config.json (account_address is derived from it when blank)
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

# trading tours — place real orders on testnet, ETH/USDT (edit MARKET to change)
python examples/basic_order.py                 # resting limit order: place, confirm, cancel
python examples/basic_market_order.py          # market order with a protection price
python examples/basic_batch.py                 # several orders and cancels under one nonce
```

## 6. Using it from an AI agent

The SDK is built to be driven by an AI agent, not just a person: it returns each outcome as structured fields, so the agent branches on a field instead of parsing prose. One safety contract, two integration paths.

### The safety contract

- **Read the settled status; a fill is not implied.** `accepted` means the tx landed, but it INCLUDES a benign no-fill/no-rest cancel — it does not mean the order rested or filled. The response carries no `oid`, fill, 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 be live. Reconcile by `cloid`; resubmitting under a fresh nonce risks a double-fill. The response says so: `next_action` is `RECONCILE_BY_CLOID`.
- **Rejections are data.** A business rejection comes back 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 precision / minimum-notional rejections.
- **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 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 (`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 (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 |

`as_problem_details(failure)` renders any exception or rejected/timeout body into one flat `{type, title, retryable, next_action, cloids, …}` envelope.

### 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_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 the assistant sees the order's state and what to do next; a timed-out order comes back telling it 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
from native_core import Exchange

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"])
if verdict["undetermined"]:
    ...   # not confirmed yet: keep reconciling, do not re-place
elif verdict["is_filled"]:
    ...   # fully filled
elif verdict["filled_qty"] != "0":
    ...   # partially filled and still resting
else:
    ...   # resting, unfilled (verdict["state"], e.g. "open")
```

The MCP tools are thin wrappers over these 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`, 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}` |
| `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 |
| `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, else the wallet), and the static `Exchange.random_cloid()`.

### Response helpers

`is_accepted`, `is_rejected`, `is_timeout`, `error_code`, `retry_after_ms`, `is_retryable` classify a trade response, and `next_action` turns one into a single verdict. `order_state` pulls the status out of an `order_status` response; `is_terminal` / `is_undetermined` classify it, and `is_filled` / `filled_quantity` read the fill. `as_problem_details` renders any failure into one flat envelope; `retry_on_rate_limit(action)` wraps a write to resend only on `RateLimited`. `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 (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` |

`ErrorCode` is a convenience enum of known codes, all CamelCase: `RateLimited`, `ExpiredTx`, `WrongChainId`, `DirectSignerIsActiveAgent`, `AgentEpochMismatch`, `InsufficientSpotBalance`. Execution-stage failures arrive as raw lowercase strings that are *not* enum members — `insufficientspotbalance`, `badalopx`, `tick`, `lotsize`, `marketclosed` — and the set is open-ended, so `error_code(resp)` returns anything outside the enum as a raw string. One case to know: a post-only (`alo`) order that would cross comes back **`accepted`** (the tx succeeded) with `badalopx` on its `orderStatus` lifecycle — the order did not rest. The sharpest case of `accepted` ≠ rested: read `orderStatus` for the real outcome.

### 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 — so construction and read bursts stay resilient. Pass `rate_limit_retries=0` to surface a 429 at once; when retries are exhausted a read raises `ClientError`. The exact limits are set by the platform and may change; the SDK does not hard-code them.

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` is never retryable. Under load two other write-plane families appear: `PlaceOrderSuspended` (503, a clean `rejected` before the node when the chain is degraded and only cancels are admitted — back off with `retry_after_ms` and resubmit once it clears), and submit-routing failures (`HandoffTimeout`, `HandoffBufferFull:*`, `HandoffMultipleActive` at 503; `node_unreachable` at 504) that arrive as a `timeout` — indeterminate, so 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 — the SDK never generates one — via `trace_id_factory` (once per request) or `trace_id=` (per call to `post`).

```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 → gateway URL). The chain id is derived from the URL — testnet `969696`, mainnet `696969` — and is what binds a key to its network (see Networks in §4 for `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 `wait_for_order(user, market, e.cloid)`. Do not resubmit under a new nonce. |
| `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`. |
| `insufficientspotbalance` (rejected) | The account is not funded for that market's quote asset. |
| `submission_status: "rejected"` | A hard write failure — admission reject or execution failure. Read `error_code(resp)`. Data, not an exception. |
| `submission_status: "timeout"` | Indeterminate: the order may still land. Reconcile with `wait_for_order`; never resubmit with a new nonce. |
| `RateLimited` / HTTP 429 | The gateway is throttling you. The SDK auto-retries with backoff (`rate_limit_retries`); if it still surfaces, slow down, raise `rate_limit_retries`, or wrap writes in `retry_on_rate_limit`. |
| `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
```
