deepx-python-sdk (Python)
Python SDK that encodes ABI calls in Python and signs/submits transactions via Substrate ethereum.transact using a pure-Python implementation.
Clients:
AsyncChainClient— the recommended client for trading: async transaction tickets, self-healing subscriptions, and pool management. Use it for market making and anything latency-sensitive.ChainClient— synchronous on-chain client for scripts, onboarding, and low-frequency operations.ApiClient— REST client for market data and the operations the chain clients don't cover.SDKas a thin wrapper holding both clients.
The public testnet now uses the MVCC runtime (chain ID 4856). Current source
version 0.2.7 includes MVCC compatibility, updated REST queries, and explicit
STP-off order encoding. See the MVCC testing and rollout guide
for deployment and validation history.
Install
From source (development)
git clone https://github.com/deepxfinance/deepx-python-sdk.git
cd deepx-python-sdk
pip install -e ".[dev]"
Development changes land on the devnet Git branch for internal deployment
validation, then merge into main for the public testnet. Public SDK network
options remain testnet and mainnet; internal deployments use explicit
base_url, ws_base_url, substrate_ws, and evm_rpc_url overrides.
Alternatively, if you use uv, run the following to install all extras in a virtual environment:
git clone https://github.com/deepxfinance/deepx-python-sdk.git
cd deepx-python-sdk
uv sync --all-extras
Onboarding
DeepX uses a two-tier account model:
| Tier | Lives in | What it does |
|---|---|---|
| EOA (wallet) | Off-chain (your keys) | Holds token balances; signs transactions |
| Subaccount | On-chain | Trading identity — positions, orders, balances |
A single EOA can own multiple subaccounts; the SDK signs transactions with your EOA private key but performs every trading operation against a specific subaccount.
First-time setup
-
Get a private key. The SDK never generates wallets. Bring your own private key.
-
Create a subaccount.
import deepx_sdk as dx # No subaccount yet — that's fine for initialize_subaccount. chain = dx.ChainClient(private_key="0xYOUR_PRIVATE_KEY") res = chain.subaccount_client.initialize_subaccount(name="my-first-subaccount") new_subaccount = res.event["subaccount"] # read from the NewUserRecord event
-
Rebuild the client with the subaccount. Every trading method requires it (wallet-level calls —
no_op, delegate management, quota views,buy_quota— work without one):chain = dx.ChainClient( private_key="0xYOUR_PRIVATE_KEY", subaccount=new_subaccount, )
-
Deposit funds into the subaccount (EOA → subaccount) before trading:
chain.lending.deposit(subaccount=new_subaccount, asset="USDC", amount=1_000_000)
-
Trade. Now perp/spot/lending operations work normally — see Quick start below.
A runnable version of this flow is in examples/onboarding.py.
Common errors at this stage
| Chain code | Name | Cause |
|---|---|---|
19_0 |
SubaccountNotInit |
Tried to trade before calling initialize_subaccount |
19_9 |
DuplicateSubaccountName |
The chosen name is already used by this EOA |
19_8 |
DeleteSubaccountCheckFailed |
Tried to delete a subaccount that still holds positions / orders / balances |
See Error codes for the full registry.
Quick start
import deepx_sdk as dx
chain = dx.ChainClient(
private_key="0x...",
subaccount="0x...",
)
api = dx.ApiClient()
sdk = dx.SDK(chain=chain, api=api)
First time? The SDK does not create wallets. See Onboarding below for the full first-time setup (private key → subaccount → deposit → trade).
Both clients connect to the DeepX network by default — endpoints are auto-resolved, nothing else to configure. Precompile addresses likewise have built-in defaults (see Precompile defaults); you only pass them when targeting a custom deployment. Custom RPC endpoints (e.g. a self-hosted node or proxy) can be supplied via evm_rpc_url / substrate_ws or the ordered *_endpoints failover lists below.
For ordered Substrate WebSocket failover, provide substrate_ws_endpoints. The first endpoint is the primary; initial connection failures and later disconnects rotate to the next endpoint:
client = dx.AsyncChainClient(
substrate_ws_endpoints=[
"wss://rpc-a.example.com",
"wss://rpc-b.example.com",
"wss://rpc-c.example.com",
],
recovery_substrate_ws_endpoints=[
"wss://recovery-rpc-a.example.com",
"wss://recovery-rpc-b.example.com",
],
private_key=PRIVATE_KEY,
subaccount=SUBACCOUNT,
)
await client.connect()
print(client.active_rpc_endpoint)
After reconnecting, the client restores subscriptions, scans missed blocks, and reconciles tracked transactions. The async client uses three WebSocket connections: one for submission/watch traffic, one for recovery head subscriptions, and one for recovery block scans. This prevents a slow catch-up scan from blocking either transaction updates or head notifications. By default all three use substrate_ws_endpoints; provide recovery_substrate_ws_endpoints to route both recovery connections to dedicated nodes. The SDK verifies that their genesis hashes match. Block scans use bounded concurrency (eight blocks by default). A request whose bytes may already have been sent is not blindly resubmitted. All configured endpoints must serve the same chain. Endpoint order is preserved and duplicates are removed.
ChainClient accepts the same substrate_ws_endpoints option for both its transaction-ticket runtime and synchronous one-shot Substrate methods. A synchronous submission may switch endpoints only while establishing the connection. Once extrinsic submission starts, the SDK does not replay the transaction on another endpoint because the first result may be ambiguous.
Self-healing while connected. Reconnect recovery is not the only safety net. A watchdog (default every 5s) also covers failures that do not involve a disconnect: if the chain keeps producing blocks but no head notification arrives for ~15s — the heads subscription was dropped by a queue overflow or evicted server-side during a chain stall — the client re-subscribes and catches up via RPC. Recovery first compares extrinsic hashes from chain_getBlock; it reads and decodes block events only after a tracked hash matches. A transaction unresolved after 60s is classified as NOT_INCLUDED only when the canonical finalized range was scanned without gaps and author_pendingExtrinsics on the recorded submission endpoint confirms that the hash is absent. Incomplete scans, unavailable pool RPC, or inconsistent data sources move it to ACTION_REQUIRED instead. A wait timeout (executed() / finalized()) never mutates the ticket, so slow-but-successful inclusions are not falsely flagged.
EVM JSON-RPC reads and transaction-preparation calls support an ordered HTTP endpoint list:
chain = dx.ChainClient(
evm_rpc_endpoints=[
"https://evm-rpc-a.example.com",
"https://evm-rpc-b.example.com",
],
private_key=PRIVATE_KEY,
subaccount=SUBACCOUNT,
)
print(chain.active_evm_rpc_endpoint)
Transport failures and HTTP 5xx responses rotate to the next endpoint for eth_call, chain-id, account-nonce, and gas-estimation requests. JSON-RPC business errors are returned without retrying another node.
REST API reads use base_urls:
api = dx.ApiClient(
base_urls=[
"https://api-a.example.com",
"https://api-b.example.com",
],
)
print(api.active_api_endpoint)
GET, HEAD, and OPTIONS requests fail over on transport failures and HTTP 5xx responses. Mutating REST requests are sent only to the active endpoint and are never automatically replayed after an ambiguous transport failure. HTTP 429 and application-level 4xx responses also remain visible to the caller.
ApiClient likewise auto-resolves base_url / ws_base_url for the default network; you can override with a custom base_url or ws_base_url when needed.
Transaction ticket lifecycle
Transaction tickets work out of the box, including WebSocket connections routed through an HTTP/SOCKS proxy (http_proxy / https_proxy env vars).
Choosing a client for trading. For market making and other latency-sensitive, high-frequency strategies, use AsyncChainClient: it returns a ticket as soon as the node accepts the transaction (no waiting for a block), and a single client owns the event loop, timestamp-nonce allocation, pool-capacity management, and subscription recovery for you. ChainClient's synchronous methods are the better fit for scripts, onboarding, and low-frequency operations where blocking until inclusion (or finality) is acceptable.
Synchronous ticket workflow
ChainClient can return a ticket as soon as the node accepts the extrinsic. The SDK owns one background asyncio loop per client, so synchronous callers do not need to manage an event loop, listener, or tracker:
with dx.ChainClient(
substrate_ws="wss://...",
private_key="0x...",
subaccount="0x...",
print_state=True, # optional; print every state transition
) as client:
ticket = client.perp_market.submit_order(...)
print(ticket.state) # ExecutionState.ACCEPTED
result = ticket.executed() # block only when the business result is needed
ticket.finalized() # optional: wait for chain finality
The four synchronous hot-path methods are perp_market.submit_order(...), perp_market.submit_cancel(...), spot_market.submit_order(...), and spot_market.submit_cancel(...). They share one connection and one background event-loop thread owned by the ChainClient; they do not create a thread for every order. Existing place_order(...) and cancel_order(...) behavior is unchanged.
Use ChainClient as a context manager, or call client.close() when the process no longer needs transaction tickets. Runnable example: examples/sync_orders.py.
ticket.executed(timeout=...) and ticket.finalized(timeout=...) raise the same structured TransactionError subclasses as the async API. The exception contains the failed stage, outcome certainty, retryability, transaction identifiers, and suggested_action. A wait timeout does not mutate the ticket, so the caller can inspect ticket.state / ticket.snapshot() and continue tracking it.
Asynchronous ticket workflow
The normal market-maker path is a transaction ticket. place_order() returns as soon as the node accepts the extrinsic; it does not wait for a block:
ticket = await client.perp_market.place_order(...)
print(ticket.state) # ExecutionState.ACCEPTED
result = await ticket.executed()
await ticket.finalized()
The ticket is a PendingTransaction, so existing code remains compatible. state is the concise business state; status preserves the exact underlying transaction status. executed() returns the typed business result, while finalized() waits for chain finality.
The current chain runtime targets a 70 ms block slot. place_order() and cancel_order() do not wait for that slot: they return the ticket when the node reports that the transaction is accepted into its pool. executed() waits for successful execution in a block, so its latency includes pool wait, the remaining part of the current or next 70 ms slot, block processing, and network delivery. The 70 ms value is a scheduling target, not an SDK latency guarantee.
Runnable example: examples/async_orders.py.
If the caller only needs the typed result after execution, use the one-line wait helpers:
result = await client.perp_market.place_order_and_wait(
market_id=3,
side="buy",
size=123,
price=456,
nonce_ms=202607290001,
)
The four helpers are perp_market.place_order_and_wait(...), perp_market.cancel_order_and_wait(...), spot_market.place_order_and_wait(...), and spot_market.cancel_order_and_wait(...). They wait for EXECUTED, not FINALIZED; use the regular ticket plus ticket.finalized() when finality is required.
Advanced monitoring and operations (optional)
For centralized monitoring, AsyncChainClient also owns a process-local TransactionManager. A market maker can register one listener for every perp and spot transaction instead of writing an updates() loop for every order. Normal order submission does not require this API.
import asyncio
import os
import deepx_sdk as dx
async def on_transaction(event: dx.TransactionEvent) -> None:
# Called for every transaction owned by this client.
# Forward this event to metrics, alerts, or your strategy state machine.
if event.execution_state in {
dx.ExecutionState.FAILED,
dx.ExecutionState.NOT_INCLUDED,
dx.ExecutionState.ACTION_REQUIRED,
}:
print("transaction alert:", event.to_dict())
async def main() -> None:
async with dx.AsyncChainClient(
substrate_ws=os.environ["SUBSTRATE_WS"],
private_key=os.environ["PRIVATE_KEY"],
subaccount=os.environ["SUBACCOUNT"],
print_state=True, # optional structured JSON; defaults to False
transaction_listener=on_transaction,
) as chain:
ticket = await chain.perp_market.place_order(
market_id=int(os.environ["MARKET_ID"]), # direct hot-path id
side="buy",
size=int(os.environ["SIZE"]),
price=int(os.environ["PRICE"]),
nonce_ms=int(os.environ["NONCE_MS"]), # timestamp-derived order id
)
# The handle is already indexed; no per-order callback is required.
assert chain.transactions.get(ticket.tx_hash) is ticket
assert ticket.nonce == int(os.environ["NONCE_MS"])
print("accepted:", ticket.state, ticket.snapshot().to_dict())
executed_result = await ticket.executed()
print("executed:", executed_result)
finalized_result = await ticket.finalized()
print("finalized:", finalized_result)
asyncio.run(main())
Full monitoring example: examples/async_order_monitoring.py.
The manager exposes both business state and the exact Substrate-oriented raw status:
| Business state | Product meaning |
|---|---|
SUBMITTING |
Submission is in progress |
ACCEPTED |
The node accepted the transaction; waiting for inclusion; execution is not yet confirmed |
EXECUTED |
The transaction was included and executed successfully |
FINALIZED |
The transaction reached chain finality |
FAILED |
The transaction definitively failed; inspect the structured error, failed stage, and suggested action |
NOT_INCLUDED |
The transaction was absent through the fully scanned finalized head and the observed submission-node pool |
ACTION_REQUIRED |
The outcome is uncertain; reconcile it using tx_hash / cloid |
ticket.snapshot() returns the current immutable business view, while client.transactions.snapshots() returns the latest view of all transactions tracked by this client. Lookup is also available through client.transactions.get(tx_hash) and client.transactions.get_by_cloid(cloid). The snapshot's recovery object includes the reason code, scan range and completeness, finalized head, missing ranges, submission/recovery/pool endpoints, pool result, matched block/index, and bounded RPC error diagnostics.
SubmissionTimeout, InclusionTimeout, and FinalizationTimeout identify the failed wait stage. A timeout does not prove that the transaction failed: when certainty is UNKNOWN, or the handle reaches ACTION_REQUIRED, reconcile the node/API state using tx_hash or cloid before retrying. Only treat ticket.safe_to_retry as permission to retry; never blindly resubmit an unknown outcome.
For a perpetual order, the fastest business-level reconciliation lookup is api.v1.account.perp_order_by_tx(tx_hash=...) (GET /v1/account/perp/orders/tx/{tx_hash}). An indexer hit provides the order result; a miss is not proof of failure because indexing is eventually consistent. Combine a persistent miss with the finalized-scan and submission-node-pool evidence before deciding that the transaction was not included.
print_state defaults to False. When enabled, the manager's background worker prints one structured, redacted JSON object per state transition, so printing does not run on the submission callback hot path. It is convenient for local debugging and operations, but it is not a persistent audit log. The manager, its indexes, and its snapshots exist only in the current process; a multi-process or restart-safe strategy must persist/reconcile its own order identity and outcome data.
Completed transaction history and resolved block data are bounded to prevent long-running market-maker processes from retaining every transaction forever. By default, each client keeps the latest 10,000 completed transactions and 256 resolved blocks:
client = dx.AsyncChainClient(
...,
max_completed_transactions=10_000,
max_resolved_blocks=256,
)
The same options are available on ChainClient. Active transactions are never evicted. Evicting an old completed transaction from client indexes does not invalidate a ticket still held by application code. Persist audit history outside the SDK before relying on these bounded in-process indexes.
For AsyncChainClient, an IN_BLOCK_SUCCESS ticket no longer consumes submission capacity while it waits for finality. Finalization tracking has its own bounded backlog, so a stalled finalized-head RPC cannot stop new orders. RPC calls and recovery iterations also have deadlines; repeated recovery stalls force the scan connection to be rebuilt. Capacity and recovery diagnostics are available through client.health_snapshot() and are included in CLIENT_BACKPRESSURE error details:
client = dx.AsyncChainClient(
...,
rpc_request_timeout_s=10,
max_tracked_transactions=1_024,
max_finalization_transactions=4_096,
recovery_config=dx.RecoveryConfig(
iteration_timeout_s=30,
lock_wait_timeout_s=30,
no_progress_timeout_s=60,
),
)
The watchdog reports lock contention separately as last_error_phase="lock_wait"; only a timeout after it owns the recovery lock is treated as a stalled RPC/scan and may rebuild the scan transport. Best-head catch-up releases the lock after each bounded batch and yields to finalized-head recovery.
If the finalization backlog itself reaches its configured bound, the oldest unfinalized executed ticket becomes ACTION_REQUIRED with reason FINALIZATION_BACKLOG_LIMIT; its already observed execution result is not silently retried.
Timestamp-nonce allocation is unique only within the current client process. Coordinate explicit nonces when multiple processes or machines submit for the same account. For latency-sensitive paths, pass market_id for perp and the bytes32 pair for spot directly; the async order methods deliberately avoid a symbol-resolution RPC.
Getting a spot pair
A spot pair is the market's bytes32 identifier, not a value that should be derived locally from the symbol. Query the market API by symbol and read the identifier returned by the deployment:
import deepx_sdk as dx
api = dx.ApiClient()
response = api.v1.spot.market(symbol="ETH-USDC")
market = response.get("data", response)
# The resolver accepts these response-field aliases for compatibility across
# API deployments. Current deployments normally return one of them.
pair = next(
(
market[name]
for name in (
"pair",
"pairId",
"pair_id",
"marketId",
"market_id",
"id",
)
if market.get(name) not in (None, "")
),
None,
)
if pair is None:
raise RuntimeError("spot market response does not contain a pair identifier")
print(pair)
Use api.v1.spot.markets() to retrieve all listed spot markets. This is a REST/indexer lookup; the SDK does not currently expose a chain RPC that enumerates every spot pair. The on-chain get_spot_market_spec() view requires an existing pair as input.
Synchronous ChainClient methods can resolve the pair automatically through the market API, so callers may pass a symbol instead:
spec = chain.spot_market.get_spot_market_spec(symbol="ETH-USDC")
ticket = chain.spot_market.submit_order(
symbol="ETH-USDC",
side="buy",
quote_amount=100_000_000,
base_amount=50_000_000_000_000_000,
)
ChainClient caches the market mapping. Call chain.preload_markets() during startup if you want to load it before the first order, or chain.refresh_markets() after a market-listing change.
AsyncChainClient deliberately does not perform REST symbol resolution on the submission hot path. Resolve and cache the pair during bot startup, then pass it directly:
pending = await async_chain.spot_market.place_order(
pair=pair,
side="buy",
quote_amount=100_000_000,
base_amount=50_000_000_000_000_000,
)
The async client also protects urgent market-maker actions from ordinary quote traffic:
node_pool_limit_per_accountdescribes the connected node's per-account transaction-pool limit and defaults to 50.- Normal submissions use
max_pool_transactions_per_account, which defaults to 48. priority_pool_reservedefaults to 2, so fast cancels and explicit replacements can use up to 50 pool positions with the default configuration.- A cancel submitted with
fast_cancel=True, or an explicitawait ticket.replace()no-op, may use the configured priority reserve. - Urgent encoding overtakes normal encoding jobs that have not started. Encoding already in progress is allowed to finish, because the cached Substrate runtime encoder must remain serialized.
This SDK-side priority complements the chain transaction priority; it does not guarantee inclusion in the current block. The SDK validates that the normal limit plus the priority reserve does not exceed the configured node limit:
client = dx.AsyncChainClient(
...,
node_pool_limit_per_account=50,
max_pool_transactions_per_account=48,
priority_pool_reserve=2,
)
The defaults are appropriate for a node with a per-account pool limit of 50. When connecting to a custom node with a different limit, configure both the node capability and the amount the strategy should use:
client = dx.AsyncChainClient(
...,
node_pool_limit_per_account=100,
max_pool_transactions_per_account=96,
priority_pool_reserve=4,
)
ChainClient accepts the same three options for its synchronous transaction tickets. Setting a larger node limit alone does not increase strategy traffic; raise max_pool_transactions_per_account explicitly after considering stale quote risk.
API usage (v1)
api = dx.ApiClient(base_url="http://127.0.0.1:8080")
ping = api.v1.ping()
subaccounts = api.v1.account.wallet_subaccounts(address="0xYOUR_WALLET")
subaccount = api.v1.account.subaccount_info(address="0xYOUR_SUBACCOUNT")
delegates = api.v1.account.wallet_delegate_accounts(address="0xYOUR_WALLET")
delegators = api.v1.account.delegator_accounts(address="0xDELEGATE")
spot_markets = api.v1.spot.markets()
spot_candles = api.v1.spot.candles(
symbol="ETH-USDC",
interval="1m",
limit=100,
)
perp_market = api.v1.perp.market(symbol="ETH-USDC")
funding = api.v1.perp.funding_rate(symbol="ETH-USDC")
lending_status = api.v1.lending.market_status(asset="USDC")
stats = api.v1.stats()
asset_prices = api.v1.prices(asset="USDC")
perp_prices = api.v1.perp.prices() # all markets
eth_prices = api.v1.perp.prices(symbol="ETH-USDC")
bad_debts = api.v1.account.subaccount_bad_debts(address="0xYOUR_SUBACCOUNT")
bankruptcies = api.v1.account.subaccount_bankruptcy_records(
address="0xYOUR_SUBACCOUNT", limit=20,
)
Statistics and price amounts are decimal strings denominated in USDC. Preserve
these strings, or use Decimal for calculations. The statistics time is the
common data cutoff in milliseconds, which can lag the chain. Its finality follows
the backend's ingestor mode. Missing, incomplete, or stale snapshots raise the
backend's 503 error; the SDK does not substitute zero values. Statistics accept
no query parameters. AsyncApiClient uses the same methods with await.
History endpoints that support pagination accept a string cursor: pass the
previous response's nextCursor unchanged. Integer cursors and removed from_id
parameters raise ValueError. Order/trade history, position history, and OI
history reject sort; funding-payment history also rejects cursor. Borrowing
status history uses interval and a maximum limit of 500; its removed
time_frame, market_id, and sort arguments raise ValueError.
subaccount_liquidations(liquidation_type=...) sends the backend's liqType
filter. Current backend order-history endpoints do not accept status filters.
To reconcile an existing REST transaction without resubmitting, use
api.v1.chain_tx.status(tx_hash=...). This reports the backend's outcome; an
HTTP success or pending confirmation alone is not proof of order execution.
api.v1.ws.websocket_url() returns the v1 WebSocket endpoint URL. For request payload construction, deepx_sdk.ws_client also exposes v1_subscribe(...), v1_unsubscribe(...), v1_list(...), and v1_post(...).
Standalone order signing
Use the public signing functions when your application owns REST/WS submission and transaction tracking. They do not require ChainClient or ApiClient:
Spot and perpetual orders on both devnet and testnet explicitly disable STP.
The SDK encodes Some(StpMode::None) as stp_mode="None" internally, including
the placement inside atomic order modifications. Python None would select
the chain default, currently ExpireMaker, and would enable STP. There is no
public STP argument or trade-group API.
import os
from deepx_sdk import build_signed_perp_order
signed = build_signed_perp_order(
substrate_ws="wss://rpc-testnet.deepx.fi",
private_key=os.environ["DEEPX_PRIVATE_KEY"],
subaccount="0xYOUR_SUBACCOUNT",
market_id=3,
is_long=True,
size=10**16, # chain base units; use the market's base decimals
price=2_000_000_000, # perp price uses 1e6 precision
order_type="limit",
timeout_ms=15_000,
)
payload = {"signedExtrinsic": signed.signed_extrinsic}
# Send payload as JSON to POST /v1/chain/tx/placePerpOrder yourself.
print(signed.tx_hash, signed.nonce, signed.runtime_version)
All four functions are exported from both deepx_sdk and deepx_sdk.signing:
| Function | Order-specific arguments | REST/WS route |
|---|---|---|
build_signed_perp_order |
market_id, is_long, size, price, order_type |
placePerpOrder |
build_signed_spot_order |
pair, side, quote_amount, base_amount, order_type |
placeSpotOrder |
build_signed_perp_cancel |
market_id, order_id, optional fast_cancel |
cancelPerpOrder |
build_signed_spot_cancel |
pair, side, order_id, optional fast_cancel |
cancelSpotOrder |
Every function takes substrate_ws, private_key, and subaccount, plus optional nonce_ms and timeout_ms. REST paths are /v1/chain/tx/<route>. For WS, send v1_post(request_id, route=<route>, payload=payload) using the same signed payload. The sending endpoint and metadata RPC must belong to the same chain. Signing keeps the private key local.
The immutable SignedExtrinsic result contains signed_extrinsic (hex), tx_hash (extrinsic hash), nonce (actual timestamp nonce), and runtime_version (the metadata snapshot's specVersion). No order is submitted and no ticket, subscription, or recovery task is created. Your application must track acceptance and execution separately; a signed transaction is not an execution result.
Perp supports limit, market, ioc, and stop; spot supports limit, market, and ioc. Integer aliases are limit=0, market=1, stop=2, and ioc=3. Perp market orders use price=0 (or omit it); other perp types require price. Spot amounts use each token's base units and side is buy/sell (or a boolean). Slippage is in basis points. post_only is only allowed on limit orders. Cancellation IDs are u64. These functions do not accept the obsolete cloid field; use the returned nonce and transaction hash for correlation.
Each call reads current metadata and chain time, then uses the same frozen metadata encoder as the transaction-ticket path. The connection is closed after loading; encoding or signing errors propagate to the caller. This is synchronous I/O; async applications can use await asyncio.to_thread(build_signed_perp_order, ...).
Automatic timestamp nonces are coordinated across these standalone functions for each chain and signer within one Python process. Explicit nonce_ms must be within the chain's one-hour window. Explicit values, separate processes, and other SDK clients do not share this coordination: applications using them together must allocate unique nonces themselves. Do not reuse or rebuild an ambiguously submitted order without reconciling its outcome. Runtime metadata handles binary layout, but business-parameter changes can still require a new SDK version; an upgrade between signing and submission can invalidate a signed transaction.
Account balances, margin, and transfer limits
from decimal import Decimal
import deepx_sdk as dx
api = dx.ApiClient() # public testnet
address = "0xYOUR_SUBACCOUNT"
balances = api.v1.account.subaccount_balances(address=address, assets=["USDC"])
portfolio = api.v1.account.subaccount_portfolio(
address=address,
include=["balances", "transferLimits"],
assets=["USDC"],
auto_borrow=False,
)
limit = api.v1.account.subaccount_transfer_limit(
address=address, asset="USDC", auto_borrow=False,
)
if limit["status"] == "AVAILABLE":
quoted_qty = Decimal(limit["maxTransferableQty"])
print("Quoted transfer limit:", quoted_qty)
elif limit["status"] == "RESTRICTED":
print("Transfers out are blocked; the quantity is informational only")
else:
print("Transfer limit unavailable; do not substitute zero")
These methods also work with AsyncApiClient; await each call. auto_borrow defaults to False, which caps the quote at the asset's account balance. True allows the quote to include new borrowing capacity; querying it does not borrow or transfer funds. REST sends autoBorrow=true/false, while WS sends a JSON boolean. Each transfer-limit entry echoes autoBorrow.
status |
maxTransferableQty |
Meaning |
|---|---|---|
AVAILABLE |
Decimal string, including "0" |
Calculation succeeded and no transfer restriction was found. Zero is a valid result. |
RESTRICTED |
Decimal string, including "0" |
Transfers out are blocked; the quantity is informational, even when positive. |
UNAVAILABLE |
null (Python None) |
Calculation or a required state check failed. This is not a zero limit. |
Quantities use the asset's own unit, not USD or scaled chain integers. The SDK preserves strings and None; use Decimal, not float, for calculations. Limits for different assets share account margin capacity and are not additive. A quote is recalculated when a transfer or withdrawal is submitted and does not guarantee execution.
portfolio["availableMargin"] is available margin in USDC; transferLimits describes asset transfers and must not be used as opening-position margin. assets filters requested expansions, not account-wide risk calculations; supply include when filtering portfolio assets. REST uses comma-separated filters and WS uses arrays.
Migration: the backend removed /withdrawal-limits/{asset} in favor of /transfer-limits/{asset}. Replace subaccount_withdrawal_limit() with subaccount_transfer_limit(), maxWithdrawableQty with maxTransferableQty, and withdrawalLimits with transferLimits in REST/WS portfolio requests and response handling. The old SDK method raises a migration error without sending a request; the old include name is rejected locally.
On-chain usage
Precompile defaults
- Perp:
0x000000000000000000000000000000000000044E(1102) - Spot:
0x000000000000000000000000000000000000044D(1101) - Lending:
0x0000000000000000000000000000000000000450(1104) - Subaccount:
0x0000000000000000000000000000000000000451(1105) - System:
0x0000000000000000000000000000000000000452(1106)
ChainClient accepts module-specific precompile fields:
perp_precompile_addressspot_precompile_addresslending_precompile_addresssubaccount_precompile_addresssystem_precompile_addressevm_rpc_user_agentevm_rpc_headersevm_rpc_timeout
You can override the precompile address per call via precompile_address.
Notes:
pairis a bytes32 hex string (64 hex chars, with or without0x).- Runtime compatibility (new node):
MarketSpec.min_order_sizewas renamed tomin_qtyon-chain. SDK keepsmin_order_sizeand also exposesmin_qtyaliases in Python objects. - Optional per-tx overrides on timestamp-nonce tx paths (
chain.perp_market.place_*/cancel_order,chain.spot_market.*,chain.subaccount_client.no_op):chain_id,gas_limit,max_fee_per_gas,max_priority_fee_per_gas,use_legacy,nonce_ms,wait_for_finalized,timeout_ms. DeepX charges no gas (the EVM base fee is 0), so the fee fields default to0and are inert;gas_limitis still a required EVM transaction field — if RPC gas estimation fails for a precompile call, the SDK signs withgas_limit=500000; passgas_limitto override. - Optional per-tx overrides on transaction-count nonce tx paths (
chain.subaccount_client.*,chain.lending.*,chain.perp_market.set_profit_and_loss_point/close_position_limit/close_position/close_position_market/settle_pnl):chain_id,gas_limit,max_fee_per_gas,max_priority_fee_per_gas,use_legacy,nonce,wait_for_finalized,timeout_ms.
Perp market orders
res = chain.perp_market.place_perp_order_limit(
market_id=3,
is_long=True,
size=123,
price=456,
)
print(res.order_id, res.tx_hash)
res = chain.perp_market.place_perp_order_market(
market_id=3,
is_long=True,
size=123,
slippage=100, # bps, optional; 100 = tolerate 1% adverse move vs oracle
)
print(res.order_id, res.tx_hash)
place_perp_order_market always sends price=0 and ignores take_profit/stop_loss. slippage (bps, 1 bps = 0.01%) caps the max adverse price vs the oracle for a market order; None means no user cap (market max_deviation_bps still applies).
# IOC (Immediate or Cancel): fill what can be filled immediately,
# cancel the remainder. Never rests on the GTC order book.
# post_only is forced to None; take_profit/stop_loss are not supported.
res = chain.perp_market.place_perp_order_ioc(
market_id=3,
is_long=True,
size=123,
price=456,
reduce_only=False,
)
print(res.order_id, res.tx_hash)
Leverage is not a per-order parameter — it's a per-subaccount sizing cap (max_notional = available_margin × effective_leverage), set globally or per market before trading. Values are scaled by LEVERAGE_PRECISION (1000): 10x = 10000, and 0.1x steps are allowed — the value must be a multiple of LEVERAGE_STEP (100), e.g. 12.5x = 12500 (out-of-range/invalid values fail with 22_6 InvalidLeverage). The more conservative of global vs per-market wins; None clears an override.
chain.perp_market.set_global_leverage(max_leverage=10_000) # 10x global
chain.perp_market.set_per_market_leverage(market_id=3, max_leverage=12_500) # 12.5x for market 3
chain.perp_market.set_per_market_leverage(market_id=3, max_leverage=None) # clear override
chain.perp_market.global_max_leverage_for() # -> 10000
chain.perp_market.per_market_max_leverage_for(market_id=3) # -> 0 (no override)
chain.perp_market.effective_leverage_for(market_id=3) # -> min(global, override or global)
Each subaccount may hold at most max_active_orders open orders per market (currently 128; owner-configurable via update_max_active_orders_num, Stop orders exempt). Exceeding it fails with 22_4 TooManyActiveOrders. The limit is on-chain configuration, not a constant — read it from chain.perp_market.perp_markets(market_id=...).max_active_orders (or the REST maxOpenOrders field) instead of hardcoding it.
A runnable version is in examples/leverage.py.
On testnet runtime 371 and later runtimes retaining this permission, a valid delegate with trading permission may set or clear a single-market leverage override. Sign with the delegate key and pass the owner's target subaccount; do not substitute the delegate's address for the subaccount:
delegate_chain = dx.ChainClient(private_key="0xDELEGATE_PRIVATE_KEY")
delegate_chain.perp_market.set_per_market_leverage(
subaccount="0xTARGET_SUBACCOUNT", market_id=3, max_leverage=12_500,
)
# Use max_leverage=None to clear that market's override.
The chain checks delegate permissions and expiry. Global leverage (set_global_leverage) remains owner-only. The SDK uses the existing transaction format; no new delegate-specific transaction method is needed.
Orders can also be placed through the REST API — it builds a signed extrinsic client-side and submits via /v1/chain/tx/*: api.v1.chain_tx.place_perp_order_ioc(...). Both paths take the same order parameters. On-chain reverts surface as ChainError and REST rejections as APIError (see Error codes).
The high-level chain.perp_market.place_order(..., order_type="ioc") dispatcher also routes to place_perp_order_ioc. Accepted aliases: "ioc", "I", "IOC", 3.
On the current testnet runtime, perp order IDs are u64 values derived from the transaction's timestamp nonce. The nonce_ms argument lets callers choose that timestamp explicitly (within the chain's accepted window), which is useful when correlating an order with a submitted transaction. The legacy cloid argument is still accepted by SDK method signatures for source compatibility, but it is not serialized and does not become the on-chain order ID.
res = chain.perp_market.place_perp_order_ioc(market_id=3, is_long=True, size=123,
price=456, nonce_ms=202607290001)
assert res.order_id == 202607290001
chain.perp_market.cancel_order(market_id=3, order_id=res.order_id)
A runnable version is in examples/async_orders.py.
res = chain.perp_market.cancel_order(market_id=3, order_id=12345)
print(res.order_id, res.tx_hash)
cancel_order (and api.v1.chain_tx.cancel_perp_order) accepts an optional fast_cancel=True: the chain skips the OrderCancelled event and prioritizes the cancel, so the SDK waits for inclusion only and echoes the requested order_id instead of parsing it from the event.
modify_order atomically cancels an open order and places a new one in a single extrinsic (Subaccount.modify_orders, transactional — the old order survives if the new one fails any PlaceOrder check). The new order is a fresh order, so all parameters are explicit; success returns a NEW order_id (res.canceled_order_id is the old one). Perp additionally supports new_total_quantity (total size including the filled part: SDK places new_total - filled; equal → cancel-only; smaller → local ValueError):
res = chain.perp_market.modify_order(
order_id=old_oid, market_id=3, is_long=True,
price=1_400_000_000, size=10**15, nonce_ms=202607290002,
)
print(res.canceled_order_id, "->", res.order_id)
res = chain.spot_market.modify_order(
side="buy", order_id=old_oid, pair="0x...32bytes...",
quote_amount=1_400_000, base_amount=10**15,
)
A runnable version is in examples/modify_order.py.
res = chain.perp_market.close_position_limit(
market_id=3,
price=123,
slippage=None,
)
print(res.order_id, res.tx_hash)
PnL settlement converts a position's floating PnL + pending funding into real USDC deposit/borrow (borrows accrue interest, so floating losses get more expensive once settled). The platform cranker settles losing positions; settle profitable ones yourself. It's permissionless and idempotent:
res = chain.perp_market.settle_pnl(market_id=3)
print(res.unrealized, res.funding, res.total) # i128 USDC base units from the SettlePnl event
chain.perp_market.settle_pnl() # no market_id -> settle all markets (inclusion only)
A runnable version is in examples/settle_pnl.py.
res = chain.perp_market.close_position_market(
market_id=3,
slippage=None,
)
print(res.order_id, res.tx_hash)
res = chain.perp_market.set_profit_and_loss_point(
market_id=3,
take_profit_point=123,
stop_loss_point=100,
)
print(res.tx_hash, res.extrinsic_hash)
Spot market orders
res = chain.spot_market.subaccount_place_order_buy_b(
pair="0x...32bytes...",
quote_amount=1000,
base_amount=500,
post_only=0,
reduce_only=False,
)
print(res.order_id, res.tx_hash)
# IOC (Immediate or Cancel) spot orders: fill immediately, cancel the rest.
# post_only is forced to None (chain rejects it for IOC).
res = chain.spot_market.subaccount_place_order_buy_ioc_b(
pair="0x...32bytes...",
quote_amount=1000,
base_amount=500,
reduce_only=False,
)
print(res.order_id, res.tx_hash)
res = chain.spot_market.subaccount_place_order_sell_ioc_b(
pair="0x...32bytes...",
quote_amount=1000,
base_amount=500,
)
print(res.order_id, res.tx_hash)
The high-level chain.spot_market.place_order(..., order_type="ioc") dispatcher also routes to subaccount_place_order_{buy,sell}_ioc_b. Accepted aliases: "ioc", "I", "IOC", 3.
Spot order IDs follow the same timestamp-nonce u64 rule. The legacy cloid argument remains accepted for source compatibility but is not sent to the chain. Set nonce_ms when a deterministic order ID is needed. Note auto_cancel no longer exists on-chain; the kwarg is kept for compatibility but ignored.
res = chain.spot_market.subaccount_cancel_order_buy_b(
pair="0x...32bytes...",
order_id=12345,
)
print(res.order_id, res.tx_hash)
Spot cancels (subaccount_cancel_order_{buy,sell}_b, the cancel_order dispatcher, and api.v1.chain_tx.cancel_spot_order_{buy,sell}) accept fast_cancel=True with the same semantics as perp.
Intra-block action ordering (protocol guarantee)
Within a block, actions execute in a fixed category order: (1) no_op → (2) cancels → (3) order-book actions (place/modify/close) → (4) others. This is a protocol-level guarantee, so SDK users can rely on it:
- Cancel-then-place is safe without waiting. If you fire a cancel and a new place back-to-back and they land in the same block, the cancel executes first — the freed margin/order slot is available to the new order. You do not need to wait for the cancel's confirmation before re-placing. (
modify_orderis still the cleaner atomic primitive for price/size changes — it's a single transactional extrinsic in category 3.) fast_cancelpriority is mempool-selection, not block order. Afast_cancel=Truetx is more likely to be included in the current block; inside the block it executes in the same cancel category as regular cancels.- Within a category, actions run in proposer submission order — timestamp nonces do not reorder execution.
Perp market view calls
market = chain.perp_market.perp_markets(market_id=3)
print(market.base_symbol, market.mark_price)
positions = chain.perp_market.user_perp_positions(
user="0xYOUR_SUBACCOUNT",
market_ids=[3],
)
orders = chain.perp_market.user_active_orders(user="0xYOUR_SUBACCOUNT")
order = chain.perp_market.order_info(user="0xYOUR_SUBACCOUNT", order_id=12345)
liq = chain.perp_market.get_liquidate_price(account="0xYOUR_SUBACCOUNT", market_id=3)
oracle = chain.perp_market.get_oracle_price_all()
On MVCC runtimes, filled/cancelled orders are removed from current chain storage.
order_info() may therefore return an order-not-found error after successful
execution. Use transaction events or the matching network's indexer/API for
historical results. A missing order is not proof of a failed transaction and is
not permission to resubmit it.
Spot market view calls
orders = chain.spot_market.user_active_spot_orders(
user="0xYOUR_SUBACCOUNT",
pair="0x...32bytes...", # or omit/None for all pairs
)
spec = chain.spot_market.get_spot_market_spec(
pair="0x...32bytes...",
)
Subaccount calls
res = chain.subaccount_client.initialize_subaccount(name="demo")
print(res.tx_hash, res.event)
res = chain.subaccount_client.rename_subaccount(
subaccount="0xYOUR_SUBACCOUNT",
new_name="new-name",
)
print(res.tx_hash, res.event) # event can be None for non-event calls
no_op consumes a timestamp nonce with no state change. Its main use is replacing a stuck pending transaction: submit it with the same nonce_ms as the pending tx — no_op has the highest mempool priority, so it evicts the pending tx and permanently consumes that nonce slot (the old tx can never execute afterwards). With nonce_ms=None a fresh millisecond timestamp is used. A reused/expired nonce is rejected with a 1010 pool error.
res = chain.subaccount_client.no_op(nonce_ms=1781757000123) # replace pending tx with that nonce
print(res.tx_hash) # event is always None — no_op emits nothing
A runnable version is in examples/noop_replace_pending.py.
res = chain.subaccount_client.liquidate_perp_by_transfer(
market_index=3,
liquidator_max_base_amount=10**18,
limit_price=None, # None -> 0 (no limit)
target_subaccount="0xTARGET_SUBACCOUNT",
liquidator="0xLIQUIDATOR_SUBACCOUNT",
)
print(res.tx_hash, res.event)
res = chain.subaccount_client.liquidate_spot_by_transfer(
asset_symbol="eth",
liability_symbol="usdc",
target_account_addr="0xTARGET_SUBACCOUNT",
liquidator="0xLIQUIDATOR_SUBACCOUNT",
limit_price=None, # None -> 0 (no limit)
liquidator_max_liability_transfer=10**18,
lending_market_id=1,
)
print(res.tx_hash, res.event)
res = chain.subaccount_client.liquidate_by_market(
target_subaccount="0xTARGET_SUBACCOUNT",
liquidator="0xLIQUIDATOR_SUBACCOUNT",
)
print(res.tx_hash, res.event) # liquidation events are conditional
stats = chain.subaccount_client.user_stats(address="0xYOUR_OWNER")
info = chain.subaccount_client.subaccount_info(address="0xYOUR_SUBACCOUNT")
delegates = chain.subaccount_client.delegate_accounts_for(owner="0xYOUR_OWNER")
print(stats, info, delegates)
Delegate accounts are wallet-level operators: a delegate set on your EOA can act on every subaccount the wallet owns. They take a display name and an expiry (wall-clock ms; past values are rejected with 19_34 DelegateExpiry); re-setting the same delegate updates its name/expiry. Delegates are order-only: mode 0=PlaceOrCancelOrder (the default) places and cancels orders, 3=Disable suspends the delegate without removing it. (Modes 1/2 — deposit/withdraw and subaccount management — are disabled on-chain and rejected by both the SDK and the chain.)
chain.subaccount_client.set_delegate_account(
delegate="0xDELEGATE_ADDRESS",
name="mm-bot",
valid_until=int(time.time() * 1000) + 86_400_000, # +24h
)
chain.subaccount_client.update_delegate_mode(
delegate="0xDELEGATE_ADDRESS",
new_mode=3, # Disable; variant names also accepted
)
chain.subaccount_client.remove_delegate_account(
delegate="0xDELEGATE_ADDRESS",
)
A runnable version is in examples/delegate.py.
Chain return fields
chain.perp_market.perp_markets(market_id=...) decodes using the latest chain layout.
- Fields:
id,name,base_symbol,base_address,base_decimal,quote_market_id,quote_symbol,quote_address,quote_decimal,network,height,funding_rate,last_cacl_funding_rate_time,oracle_price,mark_price,max_deviation_bps,initial_margin_ratio,maintenance_margin_ratio,max_active_orders,is_quote_market,taker_fee_rate,maker_fee_rate,order_spec,open_interest,long_open_pos_num,short_open_pos_num,base_interest_rate,impact_margin_value,funding_rate_clamp_upper_bound,funding_rate_clamp_lower_bound,liquidation_spec. order_specfields:min_order_size,tick_size,step_size, optionalmin_notional; aliasmin_qtyis also available.liquidation_specfields:liquidation_duration,liquidity_bucket_slippage_step,liquidity_bucket_slippage_limit,liquidity_dust_value,liquidation_fee_rate.liquidation_fee_ratefields:liquidator_share_fee_rate,insurance_fund_share_fee_rate.
chain.market.get_perp_price_bounds(market_id=...) also returns min_order_size with alias min_qty, and optional min_notional.
chain.subaccount_client.subaccount_info(address=...) supports the latest AccountInfo layout, the delegates-vec layout, and the current external legacy User layout.
- Always available:
authority,delegate,name,spot_positions,borrow_positions,next_order_id,status,is_margin_trading_enabled. - Delegates-vec layout:
delegatesis a list ofDelegateInfo(delegate_address, delegate_name, valid_until);delegateis empty there (and on the legacy layoutdelegatesisNone). - Latest
AccountInfofields (legacyUserreturnsNone):address,liquidation_start_at,next_liquidation_id,margin_strategy. - On latest
AccountInfo,borrow_positionsis returned as an empty list because that layout does not include lending borrow details.
Lending calls
chain.lending.max_transfer_amount_for(...) queries the Lending precompile's maxTransferAmountFor(address,uint8,bytes,bool) (available on testnet runtime 370+, verified on runtime 371). It returns a Python integer in the asset's base units, unlike REST transfer-limit decimal strings:
from decimal import Decimal
import deepx_sdk as dx
chain = dx.ChainClient() # no signing key needed for this query
pool = next(p for p in chain.lending.asset_pools(market_id=1)
if p.asset.lower() == "usdc")
amount = chain.lending.max_transfer_amount_for(
account="0xYOUR_SUBACCOUNT",
lending_market=pool.market_id,
asset=pool.asset,
auto_borrow=False,
)
print("Token quantity:", Decimal(amount) / Decimal(10**pool.decimal))
Use the exact asset identifier from the pool (for example, usdc), or pass symbol="USDC" to resolve it through the configured API. auto_borrow=False quotes withdrawal capacity only. True may add borrowing capacity after the entire deposit can be withdrawn, sharing pool liquidity and respecting borrowing limits. Querying does not transfer funds or create debt.
The chain query returns only a quantity, with no AVAILABLE / RESTRICTED / UNAVAILABLE status. Zero can mean an inactive pool or no capacity; RPC errors and calculation reverts propagate as errors and must not be converted to zero. A successful quote is not a guarantee that a transfer will execute. The existing chain method max_withdraw_amount_for() remains supported; only the old REST withdrawal-limit route was removed.
market = chain.lending.lending_markets(market_id=1)
print("market:", market)
pools = chain.lending.asset_pools(market_id=1)
print("asset_pools:", pools)
# LendingAssetPoolState includes: supply_cap, borrow_cap
health = chain.lending.health_for(subaccount="0xYOUR_SUBACCOUNT")
print("health_for:", health)
max_borrow = chain.lending.max_borrow_amount_for(
account="0xYOUR_SUBACCOUNT",
lending_market=1,
asset="usdc", # bytes or hex string
)
print("max_borrow_amount_for:", max_borrow)
res = chain.lending.deposit(
subaccount="0xYOUR_SUBACCOUNT",
asset="usdc",
amount=10**6, # USDC has 6 decimals on-chain
)
print(res.tx_hash, res.event)
res = chain.lending.deposit_from_subaccount(
from_subaccount="0xFROM_SUBACCOUNT",
subaccount="0xYOUR_SUBACCOUNT",
asset="usdc",
amount=10**6,
auto_borrow=False,
)
print(res.tx_hash, res.event)
System account quota
info = chain.system.system_account(address="0xYOUR_ADDRESS")
print(info.quota, info.is_exist)
Note:
- The SDK is pure Python. No native build step is required.
Quota: query & claim
Every order/cancel costs 1 quota; trading volume earns claimable quota. Claiming is backend-executed: users can't add quota on-chain themselves (chain add_quota is restricted to authorized accounts) — the SDK signs a fixed message and the backend submits the chain tx asynchronously.
q = api.v1.account.wallet_quota(address="0xYOUR_WALLET") # {claimable, remaining}
s = api.v1.account.quota_summary(wallet="0xYOUR_WALLET") # earned/granted/pending + volumes (internal API)
# personal-signs with the client's private_key (or pass private_key=/wallet=)
res = api.v1.account.claim_quota(wallet="0xYOUR_WALLET") # POST /v1/account/wallets/{address}/quota/claims
claim_id = res["claim"]["id"] # raw object; status="noop" when nothing to claim
final = api.v1.account.wait_quota_claim(claim_id=claim_id, timeout_s=120) # polls to confirmed
idempotency_key makes retries safe (same key → same claim; one active claim per wallet). The on-chain view of the same quota is chain.system.system_account(address).quota (0 = not activated, 2**32-1 = frozen).
Quota can also be bought directly on-chain (Lending.buy_quota): cost is QuoteAmountPerQuota × quota in USDC (currently 500 base units = 0.0005 USDC per quota). Paid from the signer's wallet by default, or from a subaccount's spot balance via from_subaccount. Note the extrinsic itself costs 1 quota, so buying N quota nets N−1.
res = chain.lending.buy_quota(
account="0xYOUR_WALLET", # beneficiary (quota is wallet-level)
quota=100,
from_subaccount="0xYOUR_SUBACCOUNT", # optional: pay from subaccount spot USDC
)
A runnable version is in examples/quota.py.
EVM bridge (Bool Network)
Scope: the SDK currently supports EVM↔EVM bridging between Ethereum and DeepX only. For other chains and routes (e.g. Bitcoin, Solana), please use the official bridge on our website: deepx.fi.
The EVM bridge (MultiTokenBridge, a Bool Network consumer) moves tokens between DeepX and other EVM chains. The flow:
get_bridge_feequotes the relayer fee in the source chain's gas token.- An off-chain authorizer service issues an EIP-712 approval signature.
bridgeOutlocks/burns the tokens on the source chain (ERC-20 tokens need anapprovefirst — the SDK handles it automatically).- Bool Network's relayer delivers the message and the destination bridge releases/mints to the recipient — no destination-side action needed.
import os
from deepx_sdk.bridge import BridgeApi
# Configure the verified bridge pair for the current deployment.
src = BridgeApi(
private_key="0xYOUR_PRIVATE_KEY",
contract_address=os.environ["BRIDGE_SRC"],
)
result = src.bridge_out_with_sign(
dst_chain_id=11155111, # Ethereum Sepolia
amount=1_000_000, # 1 USDC (6 decimals)
dst_recipient="0xRECIPIENT_ON_SEPOLIA",
symbol="USDC", # or token_id=3
)
print(result["tx_hash"])
# Confirm arrival by watching the destination bridge's BridgeIn event:
dst = BridgeApi(
rpc_url="https://ethereum-sepolia-rpc.publicnode.com",
chain_id=11155111,
contract_address=os.environ["BRIDGE_DST"],
)
event = dst.wait_bridge_in(recipient="0xRECIPIENT_ON_SEPOLIA",
from_block=dst.latest_block() - 20)
print(event["amount"], event["tx_hash"])
The public testnet chain ID is 4856. The former bridge address is absent on
this chain, so no default bridge contract is configured. BridgeApi() raises a
clear error until you pass a verified contract_address. Confirm both bridges
belong to the intended chain pair before using BRIDGE_SRC / BRIDGE_DST in the
example. Explicit rpc_url / chain_id overrides remain available for other
verified deployments.
Token ids: ETH=1, USDT=2, USDC=3, DAI=4, BNB=5, OKB=6 (BRIDGE_TOKEN_MAP). A token must be registered on both chains' bridges before it can be bridged; get_token_info(token_id) returns the per-chain registration.
A runnable version is in examples/bridge_evm.py.
Error codes
For decoded transaction events, error names come from the execution runtime's metadata. Numeric pallet/error indexes can change between runtimes; the static registry below is a fallback for paths without metadata, not a cross-runtime contract. An unresolved metadata error preserves its raw code without assigning a potentially incorrect legacy name.
The SDK exposes two parallel error registries that mirror the upstream specs (https://github.com/deepxfinance/notes-and-specs/blob/main/specs/error-codes.md):
| Source | Code format | Range | When raised |
|---|---|---|---|
| On-chain (pallet errors) | "<pallet_index>_<error_index>" string, e.g. "22_17" |
Pallet 19/20/21/22/23/24/26 | Transaction was submitted and reverted on-chain |
| REST API | Sequential integer, e.g. 10010 |
10001+ |
Request was rejected by the API layer before reaching the chain |
Typed exceptions
from deepx_sdk import ChainError, APIError
# Raised when a transaction reverts on-chain. Carries pallet metadata.
try:
client.perp_market.place_perp_order_limit(
market_id=3, is_long=True, size=100, price=1000,
)
except ChainError as e:
print(e.code) # "22_48"
print(e.name) # "PerpOrderMarginExceed"
print(e.pallet) # "PerpMarket"
print(e.pallet_index) # 22
print(e.error_index) # 48
# Raised when the REST API rejects a request. Carries category metadata.
try:
api.v1.perp.market(symbol="DOES-NOT-EXIST")
except APIError as e:
print(e.code) # 10007
print(e.category) # "NOT_FOUND"
print(e.message) # "Market 'DOES-NOT-EXIST' does not exist or is not active."
Looking up codes directly
from deepx_sdk._error_codes import lookup_chain_error, lookup_api_error, format_msg
# Look up a chain error by its code
entry = lookup_chain_error("22_73")
print(entry.name, entry.pallet, entry.msg)
# UnsupportedTimeInForce PerpMarket Unsupported time-in-force (TIF) type.
# Look up an API error by its integer code
entry = lookup_api_error(10010)
print(entry.category, entry.format(retryAfter=30))
# RATE_LIMIT Rate limit exceeded. Retry after 30 seconds.
# Render a message template with placeholders
print(format_msg("Invalid parameter: {param}.", param="side"))
# Invalid parameter: side.
Error categories
| Category | Source | Meaning |
|---|---|---|
ON_CHAIN |
Chain | Transaction was executed on-chain and reverted |
VALIDATION |
API | Request parameters failed client-side validation |
AUTH |
API | Authentication / signature failure |
NOT_FOUND |
API | Requested resource (market, order, subaccount, channel) does not exist |
RATE_LIMIT |
API | Request rate limit exceeded |
CONFLICT |
API | Operation conflicts with current resource state |
INTERNAL |
API | Backend-side error or service unavailability |
Adding new error codes
When the upstream YAML registries change:
- Update the matching entry in
_error_codes.py(the_CHAIN_ENTRIES/_API_ENTRIEStuples at the top of the module). - The test suite (
tests/test_error_codes.py) enforces registry invariants — sequential numbering from10001, pallet-index alignment, name format, and category coverage. Any deviation fails the build.
WebSocket usage (v1)
The websocket client lives in deepx_sdk.ws_client.
Basic subscribe example:
import asyncio
from deepx_sdk.ws_client import WsClient, v1_sub_perp_ticker
async def main() -> None:
client = WsClient(base_url="http://127.0.0.1:8080")
async with await client.connect() as ws:
await ws.send_json(v1_sub_perp_ticker("ticker-1", symbol="ETH-USDC"))
msg = await ws.recv_json()
print("ws message:", msg)
asyncio.run(main())
Account channel example:
import asyncio
from deepx_sdk.ws_client import WsClient, v1_sub_account_perp_orders
async def main() -> None:
client = WsClient(base_url="http://127.0.0.1:8080")
async with await client.connect() as ws:
await ws.send_json(
v1_sub_account_perp_orders(
"orders-1",
wallet="0xYOUR_WALLET",
symbol="ETH-USDC",
)
)
print(await ws.recv_json())
asyncio.run(main())
To receive portfolio transfer limits:
async with await WsClient(base_url="https://ws-api-testnet.deepx.fi").connect() as ws:
await ws.subscribe(
"portfolio-1",
channel="account@portfolio",
subaccount="0xYOUR_SUBACCOUNT",
include=["balances", "transferLimits"],
assets=["USDC"],
auto_borrow=False,
)
while True:
message = await ws.recv_message()
if message.method == "ping":
await ws.pong()
elif message.channel == "error":
raise RuntimeError(message.raw)
elif message.channel == "account@portfolio":
print(message.data)
break
Run this block inside an async function. The v1_sub_account_portfolio(...) helper accepts the same account-view options. WsSession.subscribe() and v1_subscribe() omit autoBorrow when auto_borrow is None (the default), so the server uses false; an explicit False is sent as a JSON boolean. Subscription acknowledgements are separate from portfolio pushes.
Portfolio data may be an array for an initial snapshot or an object for an incremental update. The SDK preserves both shapes and the transfer-limit fields unchanged.
Notes:
- Client messages use
methodwith optionalid,params, orrequest. - Supported methods are
subscribe,unsubscribe,list,post,ping, andpong. - Use
headers={"Authorization": "Bearer <API_KEY>"}when your WS requires auth. - Server subscription acks use channel
subscriptionResponse. - Server errors use channel
error. - Server may send
{"method": "ping"}; reply with{"method": "pong"}.
WebSocket subscription catalog (v1)
Market channels:
| Channel | Required params | Optional params |
|---|---|---|
spot@orderbook |
symbol |
- |
spot@trades |
symbol |
- |
spot@ticker |
symbol |
- |
spot@candles |
symbol, interval |
- |
perp@orderbook |
symbol |
- |
perp@trades |
symbol |
- |
perp@ticker |
symbol |
- |
perp@prices |
symbol |
- |
perp@funding-rate |
symbol |
- |
perp@open-interest |
symbol |
interval |
perp@long-short-ratio |
symbol |
- |
perp@candles |
symbol, interval |
- |
lending@market-status |
- | asset |
Account channels:
| Channel | Required params | Optional params |
|---|---|---|
account@balances |
subaccount |
assets |
account@portfolio |
subaccount |
include, assets, autoBorrow |
account@perp-positions |
subaccount |
symbol |
account@perp-orders |
subaccount or wallet |
symbol |
account@spot-orders |
subaccount or wallet |
symbol |
account@perp-trades |
subaccount or wallet |
symbol |
account@spot-trades |
subaccount or wallet |
symbol |
ETH-USDC examples
Perp market (ETH-USDC): market_id = 3
from decimal import Decimal
import deepx_sdk as dx
chain = dx.ChainClient(
substrate_ws="ws://127.0.0.1:9944",
evm_rpc_url="http://127.0.0.1:8545",
private_key="0xYOUR_PRIVATE_KEY",
perp_precompile_address="0x000000000000000000000000000000000000044E",
subaccount="0xYOUR_SUBACCOUNT",
)
size = int(Decimal("0.01") * (10 ** 18)) # base (ETH) uses 1e18
price = int(Decimal("2200.50") * (10 ** 6)) # perp price uses 1e6 (quote USD)
res = chain.perp_market.place_perp_order_limit(
market_id=3,
is_long=True,
size=size,
price=price,
)
print(res.order_id, res.tx_hash)
Spot pair (ETH/USDC): pair = 0x950c1bb15508369148679bf2921417929f1465c068c4b22a980c3c23535846c0
pair = "0x950c1bb15508369148679bf2921417929f1465c068c4b22a980c3c23535846c0"
quote_amount = int(Decimal("100") * (10 ** 6)) # USDC decimals = 6
base_amount = int(Decimal("0.05") * (10 ** 18))
res = chain.spot_market.subaccount_place_order_buy_b(
pair=pair,
quote_amount=quote_amount,
base_amount=base_amount,
post_only=0,
reduce_only=False,
slippage=None, # None for limit orders
auto_cancel=False,
chain_id=None,
gas_limit=None,
max_fee_per_gas=None,
max_priority_fee_per_gas=None,
use_legacy=False,
nonce_ms=None,
wait_for_finalized=True,
)
print(res.order_id, res.tx_hash)
Metadata
Release files for deepx-python-sdk 0.2.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| deepx_python_sdk-0.2.7.tar.gz | 326.6 kB | Without provenance | Details for deepx_python_sdk-0.2.7.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| deepx_python_sdk-0.2.7-py3-none-any.whl | Python 3 | none | any | Details for deepx_python_sdk-0.2.7-py3-none-any.whl |
Total release size: 504.1 kB
Release files / deepx_python_sdk-0.2.7.tar.gz
| Download URL | deepx_python_sdk-0.2.7.tar.gz |
|---|---|
| Size | 326.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f3a35e75c7176212e38f9c9eac43ed44a9326debb823d38bfcae695f8daaaceb
|
|
BLAKE2b-256 checksum How to use checksums |
a0b65a902d16cd6f350c076c63ebdc465b755c09f1626cd413ed4d443ecf313c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / deepx_python_sdk-0.2.7-py3-none-any.whl
| Download URL | deepx_python_sdk-0.2.7-py3-none-any.whl |
|---|---|
| Size | 177.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
de5fb8aa8be9bfe96920025b476239d8b9382f4e2fa07757e5e914bf3d84dbff
|
|
BLAKE2b-256 checksum How to use checksums |
2b45b710bd527d706b6d4840a332189732d0417c0ad8fc6ed3a93a6fe9a2244f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for deepx-python-sdk 0.2.7
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.