Metadata-Version: 2.4
Name: deepx-python-sdk
Version: 0.2.7
Summary: Python SDK for the DeepX exchange chain — direct substrate extrinsics, async transaction engine, and REST API client
Author-email: DeepX <support@deepx.fi>
License-Expression: MIT
Project-URL: Homepage, https://github.com/deepxfinance/deepx-python-sdk
Project-URL: Repository, https://github.com/deepxfinance/deepx-python-sdk
Project-URL: Issues, https://github.com/deepxfinance/deepx-python-sdk/issues
Keywords: deepx,substrate,perp,dex,trading,blockchain
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: eth-account<1.0.0,>=0.11.0
Requires-Dist: eth-abi<6.0.0,>=4.0.0
Requires-Dist: eth-utils<7.0.0,>=2.0.0
Requires-Dist: hexbytes<2.0.0,>=0.3.1
Requires-Dist: scalecodec<2.0.0,>=1.2.0
Requires-Dist: substrate-interface<2.0.0,>=1.7.0
Requires-Dist: websockets<18.0,>=11.0
Requires-Dist: python-socks>=2.0
Provides-Extra: dev
Requires-Dist: pytest<10.0.0,>=8.0.0; extra == "dev"
Requires-Dist: pytest-cov<8.0.0,>=5.0.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# 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](docs/mvcc-rollout.md)
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](https://github.com/astral-sh/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.**

    ```python
    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):

    ```python
    chain = dx.ChainClient(
        private_key="0xYOUR_PRIVATE_KEY",
        subaccount=new_subaccount,
    )
    ```

4. **Deposit funds** into the subaccount (EOA → subaccount) before trading:

    ```python
    chain.lending.deposit(subaccount=new_subaccount, asset="USDC", amount=1_000_000)
    ```

5. **Trade.** Now perp/spot/lending operations work normally — see [Quick start](#quick-start) below.

A runnable version of this flow is in [`examples/onboarding.py`](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](#error-codes) for the full registry.

## Quick start

```python
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](#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](#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:

```python
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:

```python
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`:

```python
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:

```python
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`](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:

```python
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`](examples/async_orders.py).

If the caller only needs the typed result after execution, use the one-line wait helpers:

```python
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.

```python
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`](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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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)

```python
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.

```python
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

```python
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

```python
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)
```

```python
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).

```python
# 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.

```python
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`](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:

```python
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](#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.

```python
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`](examples/async_orders.py).

```python
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`):

```python
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`](examples/modify_order.py).

```python
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:

```python
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`](examples/settle_pnl.py).

```python
res = chain.perp_market.close_position_market(
    market_id=3,
    slippage=None,
)
print(res.order_id, res.tx_hash)
```

```python
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

```python
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)
```

```python
# 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.

```python
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

```python
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

```python
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

```python
res = chain.subaccount_client.initialize_subaccount(name="demo")
print(res.tx_hash, res.event)
```

```python
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.

```python
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`](examples/noop_replace_pending.py).

```python
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
```

```python
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.)

```python
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`](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:

```python
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.

```python
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

```python
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.

```python
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.

```python
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`](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](https://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**.

```python
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`](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

```python
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

```python
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:

```python
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:

```python
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:

```python
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`

```python
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`

```python
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)
```
