Skip to main content

Python SDK for trading on Native Core via the public gateway (mainnet and testnet).

Project description

native-core-python-sdk

A Python SDK for trading on Native Core — 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

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 or testnet): 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:

{
  "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:

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:

cp examples/config.json.example examples/config.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.

# 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 (such as Claude Desktop) read the market and place orders through the SDK. Install the extra:

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:

{
  "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:

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_orderreconcile_by_cloid, get_min_order_sizemin_order_size, preview_orderbuild_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).

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

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

native_core_python_sdk-1.0.0.tar.gz (83.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

native_core_python_sdk-1.0.0-py3-none-any.whl (61.0 kB view details)

Uploaded Python 3

File details

Details for the file native_core_python_sdk-1.0.0.tar.gz.

File metadata

  • Download URL: native_core_python_sdk-1.0.0.tar.gz
  • Upload date:
  • Size: 83.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for native_core_python_sdk-1.0.0.tar.gz
Algorithm Hash digest
SHA256 005c77b8b61092bd2653357b94a7d6ae019eace08a8bf7e91f08dc9d544b120b
MD5 8e9a680ee9671f6cb3d0626fbb314da5
BLAKE2b-256 7e6f3e60cf14335446027c2a52c441bf38b956baaaded1414dfed29f17ccfdf1

See more details on using hashes here.

File details

Details for the file native_core_python_sdk-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: native_core_python_sdk-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 61.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for native_core_python_sdk-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ff18f71d3e902d7fbe2e38ddd81576f20d560c062cc56d4811e9537efd41cc76
MD5 bdd7245991af17ca36bcc04063eff5b2
BLAKE2b-256 a231701f3b09c295289d8f12ce2872acbc9e3d2d7c836f6f1cb40088fd5a74bc

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page