Skip to main content

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.
  • SDK as 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

  1. Get a private key. The SDK never generates wallets. Bring your own private key.

  2. 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
    
  3. 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,
    )
    
  4. Deposit funds into the subaccount (EOA → subaccount) before trading:

    chain.lending.deposit(subaccount=new_subaccount, asset="USDC", amount=1_000_000)
    
  5. 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_account describes 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_reserve defaults 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 explicit await 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_address
  • spot_precompile_address
  • lending_precompile_address
  • subaccount_precompile_address
  • system_precompile_address
  • evm_rpc_user_agent
  • evm_rpc_headers
  • evm_rpc_timeout

You can override the precompile address per call via precompile_address.

Notes:

  • pair is a bytes32 hex string (64 hex chars, with or without 0x).
  • Runtime compatibility (new node): MarketSpec.min_order_size was renamed to min_qty on-chain. SDK keeps min_order_size and also exposes min_qty aliases 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 to 0 and are inert; gas_limit is still a required EVM transaction field — if RPC gas estimation fails for a precompile call, the SDK signs with gas_limit=500000; pass gas_limit to 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_order is still the cleaner atomic primitive for price/size changes — it's a single transactional extrinsic in category 3.)
  • fast_cancel priority is mempool-selection, not block order. A fast_cancel=True tx 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_spec fields: min_order_size, tick_size, step_size, optional min_notional; alias min_qty is also available.
  • liquidation_spec fields: liquidation_duration, liquidity_bucket_slippage_step, liquidity_bucket_slippage_limit, liquidity_dust_value, liquidation_fee_rate.
  • liquidation_fee_rate fields: 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: delegates is a list of DelegateInfo(delegate_address, delegate_name, valid_until); delegate is empty there (and on the legacy layout delegates is None).
  • Latest AccountInfo fields (legacy User returns None): address, liquidation_start_at, next_liquidation_id, margin_strategy.
  • On latest AccountInfo, borrow_positions is 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:

  1. get_bridge_fee quotes the relayer fee in the source chain's gas token.
  2. An off-chain authorizer service issues an EIP-712 approval signature.
  3. bridgeOut locks/burns the tokens on the source chain (ERC-20 tokens need an approve first — the SDK handles it automatically).
  4. 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:

  1. Update the matching entry in _error_codes.py (the _CHAIN_ENTRIES / _API_ENTRIES tuples at the top of the module).
  2. The test suite (tests/test_error_codes.py) enforces registry invariants — sequential numbering from 10001, 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 method with optional id, params, or request.
  • Supported methods are subscribe, unsubscribe, list, post, ping, and pong.
  • 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)

Source distribution for deepx-python-sdk 0.2.7
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)

Table of built distributions (wheels) for deepx-python-sdk 0.2.7
File Interpreter ABI Platform Actions
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.

Release history Release notifications | RSS feed

This release

0.2.7 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page