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 isapi-test.native.org(chain 969696);from_bundlepicks the gateway from the bundle'snetwork.
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 theoid).wait_for_order(user, market, cloid)— for one you expect to finish (ioc / fok / market, or after a cancel); returns the terminal snapshot (withfilled_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.
acceptedmeans 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 nooid, fill, or lifecycle: callreconcile_by_cloid(orwait_for_openfor a resting order) once to read them. - Never resubmit an uncertain write. A wire timeout (
SubmissionUncertain, orsubmission_status: "timeout") means the order MAY be live. Reconcile bycloid; resubmitting under a fresh nonce risks a double-fill. The response says so:next_actionisRECONCILE_BY_CLOID. - Rejections are data. A business rejection comes back with an
error.code; fix the cause. OnlyRateLimitedis safe to resend (is_retryable). - Numbers are strings. Size with
min_order_sizeand validate withbuild_order(a dry run that sends nothing) to avoid precision / minimum-notional rejections. - Survive a restart. Generate the
cloidyourself withrandom_cloid(), persist{intent, cloid}durably before callingorder(..., 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_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_idis thex-trace-idthe gateway returned, orNone.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
005c77b8b61092bd2653357b94a7d6ae019eace08a8bf7e91f08dc9d544b120b
|
|
| MD5 |
8e9a680ee9671f6cb3d0626fbb314da5
|
|
| BLAKE2b-256 |
7e6f3e60cf14335446027c2a52c441bf38b956baaaded1414dfed29f17ccfdf1
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff18f71d3e902d7fbe2e38ddd81576f20d560c062cc56d4811e9537efd41cc76
|
|
| MD5 |
bdd7245991af17ca36bcc04063eff5b2
|
|
| BLAKE2b-256 |
a231701f3b09c295289d8f12ce2872acbc9e3d2d7c836f6f1cb40088fd5a74bc
|