Ault SDK for Python
Python SDK for the Ault blockchain. Gives you high-level transaction methods and REST/WebSocket query clients for the Ault modules currently on chain: CLOB, Perpetual, Lending, Margin, Vault, Agent, License, Miner, Reward, Staking and Bank, plus read-only clients for Market, Oracle, Authority, RateLimit and TokenRegistry.
The SDK builds only the messages a regular account can send. Messages restricted to governance or chain authorities are not included.
Features
- High-level async client with simple transaction methods (no manual message building).
- Signer support for private keys, EIP-1193 providers, or any object implementing the
TypedDataSignerprotocol. - Automatic address format conversion (
0x⇄ault1). - DEX API client for market data, orders, trades, and UDF charting.
- DEX WebSocket client for real-time streaming with auto-reconnect and topic subscriptions.
- REST query clients for every Ault module.
- EIP-712 typed-data signing, pinned by a byte-exact signature regression test against a known-good fixture.
- Agent (SIGN_MODE_DIRECT) transactions for bot/delegated signing.
- EVM client for ERC-20, native AULT, and the bridge precompile on top of
web3.py. - Automatic retry with exponential backoff on REST calls.
Installation
pip install ault-sdk
Or with uv:
uv add ault-sdk
The distribution name on PyPI is ault-sdk; the import name is ault_sdk.
Requirements
- Python 3.12 or newer.
Quick start
import asyncio
from ault_sdk import create_client, ClientOptions, get_network_config, PrivateKeySigner
async def main() -> None:
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=PrivateKeySigner("0x..."),
)
)
# Query data.
licenses = await client.license.get_owned_by(client.address)
epoch = await client.miner.get_current_epoch()
# Execute a transaction (no manual message building required).
result = await client.miner_tx.delegate_mining(
license_ids=[1, 2, 3], # accepts ints, strs, or decimal strings
operator="0xOperator...", # accepts 0x or ault1 format
)
print(
f"TX Hash: {result.tx_hash}, Confirmed: {result.confirmed}, Success: {result.success}"
)
asyncio.run(main())
Note: client.<module> returns the REST query object; transaction methods live under client.<module>_tx. The split keeps REST queries and tx methods in separate namespaces so they can't silently shadow each other.
Creating a client
create_client() accepts several signer input shapes and resolves the signer's bech32 address from the on-chain public key.
With a private key
from ault_sdk import create_client, ClientOptions, PrivateKeySigner, get_network_config
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=PrivateKeySigner("0x..."),
)
)
With an EIP-1193 provider
Adapter for any provider-style request({method, params}) callable. Works for hosted signing services, browser-wallet bridges, or custom RPC signers.
from ault_sdk import Eip1193Signer, create_client, ClientOptions, get_network_config
async def provider_request(args: dict) -> str:
# Your provider transport — e.g. forward to an HSM or Trezor bridge.
...
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=Eip1193Signer(provider_request, "0xYourEvmAddress"),
)
)
With a custom TypedDataSigner
Any object with an async sign_typed_data(typed_data) -> str method satisfies the protocol.
from ault_sdk import TypedDataSigner, Eip712TypedData
class MySigner: # structurally satisfies TypedDataSigner
address: str
def __init__(self, address: str) -> None:
self.address = address
async def sign_typed_data(self, typed_data: Eip712TypedData) -> str:
# Hand off to your signing backend; return a 0x-prefixed 65-byte hex signature.
...
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=MySigner("0xYourEvmAddress"),
)
)
Client options
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from ault_sdk import Manifest, NetworkConfig, FetchOptions
from ault_sdk.eip712.signers import SignerInput
@dataclass
class ClientOptions:
network: NetworkConfig # required
signer: SignerInput # required
signer_address: str | None = None # optional override
client: httpx.AsyncClient | None = None # optional shared client
fetch_options: FetchOptions | None = None # retries / timeout / backoff
default_gas_limit: str | None = None
default_memo: str = ""
default_confirmation_timeout_ms: int | None = None
manifest_override: Manifest | Mapping[str, Any] | None = None # see "SDK manifest"
SDK manifest
create_client resolves network endpoints and gas/EIP-712 defaults from
three layers, in increasing precedence:
- The embedded baseline shipped with the SDK (
NetworkConfigplusGAS_CONSTANTS). - The manifest fetched from
<dex_api_url>/api/v1/sdk-manifestat bootstrap and refreshed every five minutes in the background. The operator rotates URLs, EIP-712 fee defaults, and per-module gas constants by editing the dex-apiSDK_MANIFEST_JSONenv var; older SDK versions pick up the new values within one refresh window without a package bump. The refresh runs on the caller's event loop vialoop.call_later, so any customhttpx.AsyncClienttransport, auth, or proxy configuration carries over to every tick. - An optional
manifest_overrideyou pass tocreate_client. The override sits on top of both other layers, is frozen for the lifetime of the client, and is not touched by background refreshes.
The override layer covers cases the fetched manifest cannot: pointing the client at a private RPC during local development, pinning a deterministic manifest in tests, surviving a URL rotation cutover by keeping the old endpoint until you redeploy, or tuning gas constants without waiting on a manifest deploy.
from ault_sdk import ClientOptions, Manifest, create_client, get_network_config, PrivateKeySigner
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=PrivateKeySigner("0x..."),
manifest_override=Manifest(
schema_version=1,
urls={"rpc": "https://rpc.internal.example.com"},
gas={"LIMIT_ORDER": 250_000},
),
)
)
A plain mapping in the on-the-wire shape (camelCase keys including
schemaVersion) works too:
client = await create_client(
ClientOptions(
network=get_network_config("ault_10904-1"),
signer=PrivateKeySigner("0x..."),
manifest_override={
"schemaVersion": 1,
"urls": {"rpc": "https://rpc.internal.example.com"},
"gas": {"LIMIT_ORDER": 250_000},
},
)
)
The override is sparse — every field is optional, only the keys you set
take effect, and disjoint keys flow through from the fetched manifest.
Passing {"gas": {"LIMIT_ORDER": 250_000}} does not erase a
MARKET_ORDER value the dex-api emits. Unknown top-level keys pass
through untouched for forward compatibility with future manifest fields.
Supported fields
The Manifest dataclass has four top-level fields. schema_version is
required; the three sub-mappings are optional and each one is itself
sparse. On the wire-shape dict, the same keys appear as camelCase
(schemaVersion).
@dataclass(frozen=True, slots=True)
class Manifest:
schema_version: int # required, integer >= 1
gas: Mapping[str, Any] | None = None
eip712: Mapping[str, Any] | None = None
urls: Mapping[str, Any] | None = None
urls — endpoint overrides. Each value is a non-empty string. The
dex_api_url itself is not overridable here (it is the trust root for
the fetched manifest).
| Key | Maps to NetworkConfig field |
|---|---|
rpc |
rpc_url |
rest |
rest_url |
evm |
evm_rpc_url |
explorer |
explorer_url |
dexWs |
dex_ws_url |
URL rotations propagate to the tx executor on the next call but not to
the REST/WS contexts captured at create_client time. Re-create the
client to pick up new endpoints across the board.
eip712 — fee defaults applied to every transaction unless per-call
options override them.
| Key | Type | Notes |
|---|---|---|
feeDenom |
non-empty string | replaces GAS_CONSTANTS.DENOM |
feeAmount |
non-negative integer string | raw aault, "0" allowed |
gasLimit |
strictly positive integer string | gas units, "0" rejected |
gas — per-message-type gas constants and the fee-free multiplier.
Every value is a positive integer. Keys outside the supported list are
dropped silently and the embedded baseline keeps applying.
The keys most consumers will touch are the CLOB ones plus the fee-free multiplier:
| Key | Used by |
|---|---|
FEE_FREE_GAS_MULTIPLIER |
fee-free gas computation across all msgs |
SIMULATED_GAS_MULTIPLIER |
headroom over simulated gas (staking msgs) |
LIMIT_ORDER |
place_limit_order |
MARKET_ORDER |
place_market_order |
SCALE_ORDER_BASE |
place_scale_order (base cost) |
SCALE_ORDER_PER_SUB |
place_scale_order (per sub-order) |
CANCEL_ORDER_BASE |
cancel_orders (base cost) |
CANCEL_ORDER_PER_ID |
cancel_orders (per order id) |
The schema also accepts per-message gas constants for the license,
miner, agent, reward, and accountx modules (PER_LICENSE,
PER_DELEGATE_MINING, APPROVE_AGENT, SET_REFERRAL_CODE,
OPEN_ACCOUNT, and the rest). These exist for operator-side
deploy-cycle bypass and are rarely needed in consumer code. The full
list lives in GasConstants in src/ault_sdk/core/network.py.
The gas mapping never accepts EIP712_FEE_AMOUNT, EIP712_GAS_LIMIT,
or DENOM — those belong on eip712 as feeAmount, gasLimit, and
feeDenom. Smuggling them through gas is dropped because the
broadcast layer never reads them off the gas mapping.
Both shapes route through parse_manifest, which validates the schema
version and EIP-712 numeric fields synchronously at create_client time.
A malformed override raises ValueError straight away instead of falling
back to the baseline the way a broken fetched manifest does, since
override data comes from your code and a typo is a bug worth surfacing
loudly. The override is deep-cloned at construction, so mutating the
dict or the dataclass's sub-mappings after create_client returns does
not affect resolver state.
When a non-empty override is present, the resolver emits one
logger.info line at boot listing the override key paths (never values,
since urls may carry private endpoints).
The package also re-exports the low-level helpers if you need them:
Manifest, merge_manifests, fetch_manifest, apply_network_overrides,
apply_gas_overrides, create_manifest_resolver, create_static_resolver,
parse_manifest.
Query methods
Every REST module is available as a namespace on the client. Methods are async and return Python dict objects with the chain's snake_case JSON keys.
# License queries
license_info = await client.license.get_license("1")
licenses = await client.license.get_owned_by(client.address)
balance = await client.license.get_balance(client.address)
# Miner queries
epoch = await client.miner.get_current_epoch()
operators = await client.miner.get_operators()
delegation = await client.miner.get_license_delegation("123")
emission = await client.miner.get_emission_info()
# Market + CLOB queries
markets = await client.market.get_markets()
market = await client.market.get_market(1)
orders = await client.clob.get_orders(market_id=1)
# Reward queries
reward_params = await client.reward.get_params()
referral_code = await client.reward.get_referral_code(client.address)
builder_allowances = await client.reward.get_builder_allowances(user=client.address)
# TokenRegistry queries
pair = await client.tokenregistry.get_token_pair("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B")
pairs = await client.tokenregistry.get_token_pairs()
# Bank (cosmos SDK)
balance = await client.bank.get_balance(client.address, "aault")
balances = await client.bank.get_all_balances(client.address)
# Lending, margin, vault and oracle queries (chain v3), read from the chain
shares = await client.lending.get_shares(1, client.address, "ausdc")
health = await client.lending.get_health(1, client.address)
margin_account = await client.margin.get_margin_account(client.address, market_id)
vaults = await client.vault.get_vaults()
feed = await client.oracle.get_oracle("BTC-USD")
# Perpetual queries (chain v3)
collateral_denom = await client.perpetual.get_collateral_denom()
cross_account = await client.perpetual.get_account(client.address)
risk = await client.perpetual.get_account_risk(client.address)
position = await client.perpetual.get_position(client.address, market_id, cross=True)
funding = await client.perpetual.get_funding(market_id)
prices = await client.perpetual.get_prices(market_id)
impact_prices = await client.perpetual.get_impact_prices(market_id)
Perpetual markets
Perpetual markets (chain v3) trade on the same order book as spot. An order
on a perpetual market is a regular place_limit_order, and the chain needs
reduceOnly on every one (False for a plain order). Since v3.0.0-rc.3 a
perpetual market is quoted in "USD" and its base is the id of its index price
feed (BTC/USD), so neither side is a bank denom: prices are in USD and
quantities in base units with 18 decimals, while margin and fees settle in the
collateral denom. The chain accepts limit orders only, so a "market order" is
an immediate-or-cancel limit priced through the book:
from ault_sdk import ioc_limit_price
dex = client._low_level.rest.dex
book = await dex.get_perp_orderbook(market_id_hex, depth=5)
quote = ioc_limit_price(
"buy",
best_ask=book.asks[0].price if book.asks else None,
best_bid=book.bids[0].price if book.bids else None,
slippage="0.005",
price_decimals=2,
)
await client.clob_tx.place_limit_order(
marketId=market_id,
isBuy=True,
price=quote.price,
quantity="1",
deadline=deadline,
timeInForce="ioc",
reduceOnly=False,
)
Collateral lives in a per-owner cross account in the chain's single collateral denom; isolated markets get their own derived account. Margin mode and leverage are set per market and cannot change while a position or resting order exists:
await client.perpetual_tx.deposit_perp_collateral(amount="1000000000") # base units
await client.perpetual_tx.set_margin_config(market_id=market_id, leverage=10, cross=True)
await client.perpetual_tx.modify_position_margin(market_id=market_id, margin_delta="-500000")
await client.perpetual_tx.withdraw_perp_collateral(amount="250000000")
A perpetual TWAP (place_twap_order with reduceOnly) is sized in base and
needs ten times the market's minimum order (min_order_quote_amount from
dex.get_perp_params()). A perpetual market takes conditional orders as limit
orders only, and group orders only as an OTOCO; see
Conditional and group orders. Liquidation is
open to any account: client.perpetual_tx.liquidate_perp_accounts(accounts=[...])
is a paid transaction, so its gas is simulated. The accounts are perpetual
accounts, not owners: perp_account_address(owner) gives an owner's cross
account and perp_account_address(owner, market_id) an isolated one.
The same four collateral and margin methods exist on the agent client. Read models (markets,
tickers with mark and funding, positions, fills, ledger) come from the DEX
API's /api/v1/perp endpoints, list_perp_markets through
list_perp_closeouts on DexApi (*_detailed variants feed paginate).
ault_sdk.perp ports the chain's margin-tier, funding and position math
(maintenance_margin, initial_margin, equity, funding_rate, ...) with the
chain's rounding for client-side previews.
Lending
Lending pools take supplies, collateral and borrows per denom. Every amount is a coin in the denom's base units, and the chain converts it to shares. These methods move the signer's own funds, so a wallet signs them; an agent can't.
usdc = {"denom": "erc20:0xd157...", "amount": "1000000000"} # base units
weth = {"denom": "erc20:0xcC4a...", "amount": "500000000000000000"}
await client.lending_tx.lending_supply(pool_id=1, coin=usdc) # earns the supply rate
await client.lending_tx.lending_supply_collateral(pool_id=1, coin=weth) # backs borrows, earns nothing
await client.lending_tx.lending_borrow(pool_id=1, coin=usdc)
await client.lending_tx.lending_repay(pool_id=1, coin=usdc) # borrower= repays someone else's debt
await client.lending_tx.lending_withdraw_collateral(pool_id=1, coin=weth)
await client.lending_tx.lending_withdraw(pool_id=1, coin=usdc)
# Liquidating an unhealthy position repays part of its debt and seizes
# collateral plus the pool's bonus.
await client.lending_tx.lending_liquidate(
pool_id=1, borrower="ault1...", repay=usdc, seize_denom=weth["denom"]
)
# Donations to the insurance fund are not fee-free; the SDK simulates the gas.
await client.lending_tx.fund_lending_insurance_fund(coin=usdc)
Supply shares convert back to assets rounding down, so withdrawing exactly what you supplied can come up one unit short. Withdraw a little less, or read the position first.
Reads come from two places. The DEX API (get_lending_params through
get_lending_protocol_revenue on DexApi) serves pools, assets, rates, APYs,
positions with health, history and liquidations. client.lending queries the
chain directly. Use it right before a withdrawal, and for scheduled asset
updates (get_pending_asset_updates), which only the chain has.
Two things to handle in a UI. Health and valuation fields are None when
health_error is set: the chain fails the whole health read if one oracle
price is stale, so show "unavailable" rather than zero. Debt also includes
accrued interest, and the chain has no separate interest figure.
Margin
A margin order is an ordinary spot order funded from a margin account instead of the bank balance. Each owner has one cross account and one isolated account per market. Both are derived from the owner's address, and their collateral and debt live in the margin Lending pool.
from datetime import UTC, datetime, timedelta
from ault_sdk import margin_account_address
# Fund the isolated account of a market (leave market_id out for the cross account).
await client.margin_tx.deposit_margin(amount=[usdc], market_id=market_id)
# Open a long: buy with borrowed quote. auto_borrow borrows the shortfall
# between the order's escrow and the account's free collateral.
await client.clob_tx.place_limit_order(
marketId=market_id,
isBuy=True,
price="3500",
quantity="0.5",
deadline=datetime.now(UTC) + timedelta(hours=1),
margin={"auto_borrow": True},
)
# Close it: the sell's proceeds come in as quote, so auto_repay pays the quote
# debt back out of them.
await client.clob_tx.place_limit_order(
marketId=market_id,
isBuy=False,
price="3600",
quantity="0.5",
deadline=datetime.now(UTC) + timedelta(hours=1),
margin={"auto_repay": True},
)
# Manual borrow/repay and withdrawal work on the same account.
await client.margin_tx.repay_margin(amount=usdc, market_id=market_id)
await client.margin_tx.withdraw_margin(amount=[usdc], market_id=market_id)
# Orders and fills of a margin account are read with its derived address.
# The SDK derives it the same way the chain does; nothing is queried.
account = margin_account_address(client.address, market_id)
orders = await dex.list_orders(address=account, status="open")
margin takes cross, auto_borrow, auto_repay and auto_repay_at_cancel
(camelCase keys work too). They all default to false, and only limit and market
orders accept them; reduceOnly and margin can't be combined.
auto_repay pays same-denom debt out of a fill's proceeds. A buy that
borrowed quote gets base back, so the flag does nothing on that buy; put it on
the sell that closes the position, whose proceeds come in as quote.
auto_repay_at_cancel goes on the order that borrows: when that order is
cancelled or shrunk, the chain repays what the order itself borrowed, so an
auto-borrowed order that never filled leaves no debt behind. Without it the
borrow stays until you repay it.
With cross: True the order is funded from the owner's cross account. There
is one per owner, margin_account_address(owner) with no market id, and it is
the account deposit_margin funds when you leave market_id out. An isolated
account holds only its market's two denoms. The cross account can hold any
collateral the margin pool accepts and backs orders on every margin market. A
cross borrow_margin also has no receive-asset cap, which an isolated one
does.
A margin account is liquidated like any other Lending borrower once its health
factor drops below 1: call lending_liquidate with the derived account as
borrower. The chain cancels the account's resting orders before it seizes
collateral. The pool's minimum residual debt (1000 USD in the e2e setup) only
matters here. If a liquidation would leave less debt than that, it has to
repay all of it, or the chain rejects it with "residual debt below minimum".
The DEX API's get_margin_position reports max_transferable per denom, and
that, not free, is what a withdrawal can take. is_margin_account_address and
margin_account_owner tell a derived account apart and recover its owner.
Vaults
The vault messages follow v3.0.0-rc.3, which replaced the rc.2 vault model: the upgrade retired every rc.2 vault.
Depositors put a vault's denom in and get shares. The vault's manager trades with the funds (trading vaults) or spreads them across Lending pools (lending vaults).
await client.vault_tx.deposit_to_vault(vault_address=vault_address, amount=usdc)
# Shares are raw units, 10^18 per share. The redemption settles at once when
# the vault has idle funds; otherwise the chain queues it.
await client.vault_tx.redeem_from_vault(vault_address=vault_address, shares="1000000000000000000")
A vault you own is created and reconfigured from the wallet (client.vault_tx)
or through the agent client. A lending vault names its denom and a management
fee; a trading vault holds the chain's perpetual collateral denom, takes a
performance and an exit fee, and starts with your initial deposit:
from ault_sdk import LendingPoolCap, LendingVaultConfigInput, TradingVaultConfigInput
await client.vault_tx.create_vault(
vault_denom=usdc["denom"],
management_fee_bps=200,
config=LendingVaultConfigInput(
pools=[LendingPoolCap(pool_id=1, supply_cap="5000000000")],
supply_queue=[1],
withdrawal_queue=[1],
),
)
await client.vault_tx.create_vault(
config=TradingVaultConfigInput(
allowed_actions=["spot", "perpetual"], spot_market_ids=[market_id]
),
# Without a fund window the exit fee must be positive; with one it is zero.
exit_fee_bps=50,
performance_fee_bps=1000,
initial_deposit={"denom": collateral_denom, "amount": "10000000000000000000"},
)
fund_window (a VaultFundWindow with deposit_open_time,
deposit_close_time and redemption_open_time) limits when deposits and
redemptions happen; a vault without one takes both at any time. Lending,
parent and child vaults start empty, and the chain rejects them with a
deposit. A normal vault's manager is its owner at creation, and a child
vault (relationship="child") takes no manager and no fund_window.
vault_address_kind(address) reads a vault's type and relationship from its
address.
Some messages go out from the vault's own account. attach_vault_manager
builds a surface for them, signed by whoever controls the vault (or that
key's registered agent): the manager for a normal or parent vault, the
parent's manager for a child vault. What x/vault accepts depends on the kind
of vault:
- A normal trading vault or a child vault can place, modify and cancel
orders on its configured spot markets and, with the
perpetualaction, on perpetual markets, where it also manages its collateral and margin. TWAP, conditional and group orders are rejected. - A normal lending vault can move supply between its pools with
reallocate_vault. - A parent vault can create child vaults, update or cancel their config, set
their status, move capital to and from them with
transfer_child_vault, and hand a perpetual account that liquidation cannot close (the derived account,perp_account_address(owner)) to a child holding thebackstopaction withbackstop_transfer. It can't trade.
Parent vaults belong to x/gov, so only a governance proposal can create one.
lending_vault = client.attach_vault_manager(vault_address=vault_address, agent_private_key=manager_key)
await lending_vault.reallocate_vault(source_pool_id=1, destination_pool_id=2, amount=usdc)
# The parent's manager runs the parent and its children.
parent = client.attach_vault_manager(vault_address=parent_vault, agent_private_key=manager_key)
await parent.transfer_child_vault(child_vault=child_vault, amount=usdc, is_deposit=True)
child = client.attach_vault_manager(vault_address=child_vault, agent_private_key=manager_key)
await child.place_limit_order(marketId=market_id, isBuy=True, price="3500", quantity="1", deadline=deadline)
Status changes and config updates come from the vault's owner, not its
manager: set_vault_status and cancel_vault_config_update are on
client.vault_tx (EIP-712) and on the agent client. The builders reject what
x/vault would reject before anything is signed: fees the vault kind doesn't
take, an exit fee that doesn't match the fund window, a fund window out of
order, bad pool or queue lists, actions the vault's relationship doesn't
allow, spot markets quoted in different denoms, and addresses that can't be
vault accounts (or must be).
The DEX API serves the vault directory, details with the caller's position
(get_vault(address, user=...)), activity, redemptions and a user's positions
across vaults (get_portfolio_vaults). TVL, share price, APR and APY stay
None until the vault worker has run. These feeds wrap their rows in an
object, so walk them with a row selector:
from ault_sdk import paginate
async for page in paginate(dex.list_vaults_detailed, rows_of=lambda body: body.vaults):
... # page.rows
Conditional and group orders
Since chain v3.0.0-rc.3 an order can wait for a price. A conditional order
reserves nothing until the market's last traded price (not the mark or the
oracle) reaches its trigger, then places its limit or market order as an
ordinary order with a new id; its cloid moves to that order. The order goes
in order as the usual order params plus "type", without a cloid (the
conditional carries it) and, for a limit, without a deadline (it takes the
conditional's, which must be a datetime):
# A stop loss on a long: sell when the last trade falls to 65,000.
await client.clob_tx.place_conditional_order(
triggerPrice="65000",
triggerAbove=False,
deadline=deadline,
order={
"type": "limit",
"marketId": market_id,
"isBuy": False,
"price": "64900",
"quantity": quantity,
"reduceOnly": True,
},
)
The chain refuses a trigger the market already sits at and a market with no
trade yet. modify_conditional_orders(orders=[...]) replaces the trigger and
order of waiting orders (each entry names its order with orderRef, the id
or the cloid), and cancel_orders cancels them. Each takes a slot of the
account's open order limit.
Group orders link legs on one market. An OCO pairs a take profit and a stop
loss of the same side and size, and the first to fire cancels the other. An
OTO places a limit entry whose fills wake a conditional exit, and an OTOCO
wakes an OCO pair; the chain resizes the exits to what the entry filled.
Perpetual markets take only an OTOCO. A leg is {"limit": ...},
{"conditional": ...} or, for the primary only, {"existingOrder": ref}, and
takes its market from the group. tp_sl_legs builds the two exits:
from ault_sdk import tp_sl_legs
take_profit, stop_loss = tp_sl_legs(
is_buy=False, # the exits sell: they close a long
quantity=quantity,
deadline=deadline,
take_profit={"triggerPrice": "75000", "price": "74900"},
stop_loss={"triggerPrice": "65000", "price": "64600"},
reduce_only=True,
)
await client.clob_tx.place_group_order(
marketId=market_id,
type="otoco",
primary={
"limit": {
"isBuy": True,
"price": "70000",
"quantity": quantity,
"deadline": deadline,
"reduceOnly": False,
}
},
secondary=take_profit,
tertiary=stop_loss,
)
These sign from the wallet over EIP-712 and from the agent client. Over
EIP-712, every entry of a modify_conditional_orders batch must have the same
shape, since typed data types a list by its first element. With wait=True
the result carries the new order ids. The DEX API lists waiting orders with
the rest (type stop_limit or stop_market, their info.conditional and
the info.links of a group), and the chain lists them with
client.clob.get_conditional_orders(orderer=...). client.spot.get_params()
returns the minimum spot order.
DEX API
The SDK includes a client for the Ault DEX API: market data, order books, trades, balances, and UDF charting.
from ault_sdk import create_ault_client, get_network_config
import dataclasses
network = get_network_config("ault_10904-1")
network = dataclasses.replace(network, dex_api_url="https://test-dex-api.cloud.aultblockchain.xyz")
client = create_ault_client(network=network)
dex = client.rest.dex
# Markets and currencies
markets = await dex.list_markets()
currencies = await dex.list_currencies()
# Ticker + order book + trades
ticker = await dex.get_ticker("AULT/USDT")
orderbook = await dex.get_orderbook("AULT/USDT", limit=50)
trades = await dex.list_trades("AULT/USDT", limit=100)
trade = await dex.get_trade("12345-00010000000000003039000000000002-999")
# OHLCV
candles = await dex.get_ohlcv("AULT/USDT", timeframe="1h", limit=100)
# User data (by address)
orders = await dex.list_orders(address="ault1...", status="open", symbol="AULT/USDT")
my_trades = await dex.list_my_trades(address="ault1...", symbol="AULT/USDT")
balances = await dex.get_balance(address="ault1...")
# TradingView UDF
config = await dex.get_udf_config()
history = await dex.get_udf_history(symbol="AULT/USDT", resolution="60", from_=1773500000, to=1773600000)
DEX WebSocket
Real-time streaming. Auto-reconnects with exponential backoff; re-subscribes all active topics after reconnect.
from ault_sdk import create_dex_ws, SubscriptionHandlers
ws = create_dex_ws("wss://test-dex-api.cloud.aultblockchain.xyz/ws")
await ws.connect()
def on_orderbook(data, msg):
# v2 l2Book: {coin, levels: [bids, asks]}, each level {px, sz, n}.
print(data["levels"][0][0], data["levels"][1][0])
sub = ws.subscribe(
{"type": "l2Book", "coin": "AULT/USDT"},
SubscriptionHandlers(on_data=on_orderbook),
)
# Lifecycle listeners
ws.on("open", lambda: print("connected"))
ws.on("close", lambda code, reason: print("closed", code, reason))
ws.on("reconnecting", lambda attempt: print("reconnecting", attempt))
# TTL: auto-expire subscription after 300s
ws.subscribe(
{"type": "trades", "coin": "AULT/USDT"},
SubscriptionHandlers(
on_data=lambda data, msg: print(data),
on_subscribed=lambda ack: print("subscribed!", ack),
on_expired=lambda: print("expired"),
),
ttl=300,
)
# Unsubscribe
sub.unsubscribe()
# Teardown
await ws.disconnect()
User channels ({"type": "orderUpdates", "user": "ault1..."}, spotState, userFills) are read-only projections of public on-chain state, so no authentication is needed.
Transaction methods
All transaction methods are async, and most return a single TxResult. The exception is staking_tx.withdraw_rewards, which sends one transaction per validator and returns a list[TxResult] (one entry per validator, in input order — see Staking):
@dataclass(slots=True)
class TxResult:
tx_hash: str # transaction hash
code: int # result code (0 = success)
success: bool # true only when DeliverTx success was confirmed
raw_log: str | None # raw log
confirmed: bool | None # true = definitive, false = timed out, None = lookup unavailable
License transactions
await client.license_tx.transfer_license(license_id=123, to="0x...") # EVM or ault1 address
Miner transactions
from ault_sdk import base64_to_bytes
# Delegate licenses to an operator
await client.miner_tx.delegate_mining(
license_ids=[1, 2, 3],
operator="0xOperator...",
)
# Cancel / redelegate
await client.miner_tx.cancel_mining_delegation(license_ids=[1, 2, 3])
await client.miner_tx.redelegate_mining(license_ids=[1, 2, 3], new_operator="0xNew...")
# VRF key + work submission (bytes accepted as raw or base64 string)
await client.miner_tx.set_owner_vrf_key(
vrf_pubkey=base64_to_bytes("..."),
possession_proof=base64_to_bytes("..."),
nonce=1,
)
await client.miner_tx.submit_work(
license_id=123,
epoch=456,
y=base64_to_bytes("..."),
proof=base64_to_bytes("..."),
)
await client.miner_tx.batch_submit_work(
submissions=[
{"licenseId": 1, "epoch": 100, "y": base64_to_bytes("..."), "proof": base64_to_bytes("...")},
{"licenseId": 2, "epoch": 100, "y": base64_to_bytes("..."), "proof": base64_to_bytes("...")},
],
)
# Operator management
await client.miner_tx.register_operator(commission_rate=5, commission_recipient="0x...")
await client.miner_tx.unregister_operator()
await client.miner_tx.update_operator_info(new_commission_rate=6)
CLOB transactions
# Limit order with an absolute deadline and an 8-byte client order id (cloid).
# deadline accepts a datetime, an RFC 3339 string, or a {seconds, nanos} dict.
from datetime import UTC, datetime, timedelta
cloid = b"\x01\x02\x03\x04\x05\x06\x07\x08"
await client.clob_tx.place_limit_order(
marketId=bytes([1]), # 16-byte market id
isBuy=True,
price="1.5",
quantity="100",
deadline=datetime.now(UTC) + timedelta(hours=1),
cloid=cloid,
)
# Market order
await client.clob_tx.place_market_order(
marketId=bytes([1]),
isBuy=True,
deposit="100",
)
# Cancel by cloid (each order id may be an 8-byte cloid or a 16-byte order_id).
await client.clob_tx.cancel_orders(order_ids=[cloid])
# Resolve an order by cloid: chain-first (live orders), dex-api fallback (history).
found = await client.find_order_by_cloid(client.address, cloid)
Reward transactions
# Referral codes
await client.reward_tx.set_referral_code(code="RW18A001")
await client.reward_tx.approve_builder(
builder="0x0000000000000000000000000000000000000001",
max_fee_rate="0.005",
)
Staking
# Delegate / undelegate / redelegate
await client.staking_tx.delegate(
validator_address="aultvaloper1...",
amount={"denom": "aault", "amount": "1000000"},
)
await client.staking_tx.undelegate(
validator_address="aultvaloper1...",
amount={"denom": "aault", "amount": "500000"},
)
# Withdraw rewards. The chain only accepts one message per transaction, so
# the SDK sends one tx per validator and returns a TxResult for each, in
# input order.
results = await client.staking_tx.withdraw_rewards(
validator_addresses=["aultvaloper1...", "aultvaloper2..."]
)
If a later transaction fails after an earlier one has already committed,
withdraw_rewards raises WithdrawRewardsPartialError instead of returning.
Reconcile from partial_results rather than retrying the whole call, or you
will double-claim the validators that already succeeded:
from ault_sdk import WithdrawRewardsPartialError
try:
results = await client.staking_tx.withdraw_rewards(validator_addresses=validators)
except WithdrawRewardsPartialError as err:
# err.partial_results: [{"tx_hash": ..., "validator_address": ...}] for
# each claim that landed on-chain before the failure.
# err.failed_validator_address: where the fan-out stopped.
# err.cause / err.failed_result: why it stopped. cause = the broadcast
# raised. failed_result = the executor returned a failed TxResult, which
# is either a definitive chain rejection (confirmed) or a tx whose
# finality is unknown (unconfirmed — it may still have landed, so check
# failed_result.confirmed rather than code, and don't blindly retry it).
handle_partial_withdrawal(err.partial_results)
Transaction options
Every tx method accepts optional gas_limit, fee_denom, fee_amount, memo, and confirmation_timeout_ms:
await client.miner_tx.delegate_mining(
license_ids=[1, 2, 3],
operator="0x...",
gas_limit="300000",
memo="Delegating via SDK",
)
fee_denom overrides the fee coin for a single transaction. Without it the
client falls back to network.eip712_fee_denom, then to aault. You need it
when the network default can't pay for a particular call: on mainnet, order
placement has to use the ERC-20 denom listed in accountx.allowed_fees, but a
fee-bearing cancel settles in aault.
When neither fee_amount nor network.eip712_fee_amount is set, the client
prices the fee from the chain's feemarket params (GET /cosmos/evm/feemarket/v1/params, cached for 3 seconds per client): it takes
the larger of base_fee and min_gas_price, multiplies by the gas limit,
adds a 20% margin for base-fee moves between signing and inclusion, and never
goes below eip712_fee_amount. A chain that does not expose the gateway gets
the static floor. A transient gateway error is raised rather than silently
signing an underpriced tx.
Staking gas
Cosmos staking messages (delegate, undelegate, redelegate,
withdraw_rewards) are metered on execution. What a redelegation costs
depends on the delegator's state, for example whether a delegation to the
destination already exists or how many redelegation entries are open, so
no fixed number is right for every account. These methods simulate the tx
on the chain first (POST /cosmos/tx/v1beta1/simulate) and sign
gas_used * SIMULATED_GAS_MULTIPLIER / 100 as the gas limit (1.3x by
default, overridable through the manifest). The fee is computed from that
gas.
If the chain rejects the simulated tx, the call raises TxSimulationError
with the chain's message. Nothing is signed or broadcast, so you don't pay
a fee for a tx that would have failed in the block. If the simulate
endpoint is down or a proxy doesn't route it, the method logs a warning
and falls back to the embedded per-message constant (DELEGATE,
BEGIN_REDELEGATE, ...) times FEE_FREE_GAS_MULTIPLIER. Passing an
explicit gas_limit skips the simulation.
EVM client
Every Client has an evm attribute: an async EVM client on top of web3.py.
from ault_sdk import EvmClient, create_evm_client, CreateEvmClientOptions
# Construct standalone
evm = create_evm_client(CreateEvmClientOptions(
network=get_network_config("ault_10904-1"),
account=PrivateKeySigner("0x..."), # optional — reader-only if omitted
))
# ERC-20 reads
meta = await evm.erc20.metadata("0xTokenAddress...")
balance = await evm.erc20.balance_of("0xToken...", "0xOwner...")
# ERC-20 writes (requires signer)
tx_hash = await evm.erc20.transfer(
Erc20TransferParams(token="0xToken...", to="0xRecipient...", amount="10.5")
)
# Native AULT transfer
tx_hash = await evm.transfer_native(to="0xRecipient...", amount="1.0")
# TokenRegistry bridge (ERC-20 ⇄ bank)
await evm.bridge.to_core(BridgeParams(token="0xToken...", amount="10.0"))
await evm.bridge.to_evm(BridgeParams(token="0xToken...", amount="10.0"))
# Resolve token by symbol
address = await evm.tokens.resolve_address("USDT")
tokens = await evm.tokens.list_tokens()
Parallel query helpers
For license-heavy addresses (hundreds or thousands of licenses), the parallel helpers batch lookups:
analysis = await client.parallel.analyze_licenses("ault1...")
print(f"Total: {analysis['total']}")
print(f"Active: {analysis['active']}")
print(f"Delegated: {analysis['delegated']}")
# Or individual parallel helpers
ids = await client.parallel.get_all_license_ids("ault1...")
details = await client.parallel.get_license_details_parallel(ids)
delegations = await client.parallel.get_license_delegations_parallel(ids)
Error handling
from ault_sdk import ApiError, NetworkError, TimeoutError
try:
result = await client.license_tx.transfer_license(license_id=1, to="0x...")
if result.success:
print(f"Transaction committed: {result.tx_hash}")
elif result.confirmed is False:
print(f"Transaction broadcast but not confirmed yet: {result.tx_hash}")
elif result.confirmed is None:
print(
f"Transaction accepted by CheckTx, but this node can't confirm it: "
f"{result.tx_hash}"
)
else:
print(f"Transaction failed: {result.raw_log}")
except ApiError as e:
print(f"API Error (status={e.status}): {e}")
except NetworkError as e:
print(f"Network Error: {e}")
except TimeoutError:
print("Request timed out")
Advanced usage
Low-level access
create_client returns a high-level Client. Use create_ault_client directly if you want the lower-level composite without the tx method bundles.
from ault_sdk import create_ault_client, msg, sign_and_broadcast_eip712, SignAndBroadcastParams
client = create_ault_client(network=get_network_config("ault_10904-1"))
# Call the REST modules directly.
licenses = await client.rest.license.get_licenses()
# Build + broadcast a message manually.
delegate_msg = msg.miner.delegate_mining({
"owner": "ault1...",
"licenseIds": [1, 2, 3],
"operator": "ault1...",
})
result = await sign_and_broadcast_eip712(SignAndBroadcastParams(
network=client.network,
signer=PrivateKeySigner("0x..."),
signer_address="ault1...",
msgs=[delegate_msg],
))
Network configuration
from ault_sdk import NETWORKS, get_network_config, NetworkConfig
# Predefined networks
testnet = get_network_config("ault_10904-1")
localnet = get_network_config("ault_40904-1")
# Or construct a custom one
custom = NetworkConfig(
name="My Network",
type="testnet",
chain_id="ault_10904-1",
evm_chain_id=10904,
rpc_url="https://my-rpc.example.com",
rest_url="https://my-rest.example.com",
evm_rpc_url="https://my-evm.example.com",
dex_api_url="https://my-dex-api.example.com",
is_production=False,
)
Utility functions
from ault_sdk import (
evm_to_ault, ault_to_evm,
is_valid_ault_address, is_valid_evm_address,
parse_evm_chain_id_from_cosmos_chain_id,
)
# Address conversion
ault_addr = evm_to_ault("0x1234...abcd") # → "ault1..."
evm_addr = ault_to_evm("ault1...") # → "0x1234...abcd"
# Validation
is_valid_ault_address("ault1...") # True / False
is_valid_evm_address("0x1234...") # True / False
# Chain ID
evm_chain_id = parse_evm_chain_id_from_cosmos_chain_id("ault_10904-1") # → 10904
Constants
from ault_sdk import GAS_CONSTANTS, TIMING_CONSTANTS
GAS_CONSTANTS.EIP712_FEE_AMOUNT # '5000000000000000'
GAS_CONSTANTS.EIP712_GAS_LIMIT # '200000'
GAS_CONSTANTS.DENOM # 'aault'
GAS_CONSTANTS.PER_LICENSE # 200000
TIMING_CONSTANTS.API_TIMEOUT_MS # 30000
TIMING_CONSTANTS.API_RETRY_DELAY_MS # 1000
TIMING_CONSTANTS.API_MAX_BACKOFF_MS # 30000
Contributing
Bug reports, feature requests, and pull requests are welcome on the GitHub repository.
Local development uses uv, ruff, ty, and just:
uv sync # install dependencies
just check # lint + format + typecheck + unit tests
just test # unit tests only
The EIP-712 signing paths are guarded by byte-exact signature tests against known-good fixtures, so any drift in the signing stack trips the test suite immediately.
License
Released under the MIT License. See the LICENSE file in the repository for the full text.
Metadata
Release files for ault-sdk 0.13.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ault_sdk-0.13.0.tar.gz | 777.0 kB | With provenance | Details for ault_sdk-0.13.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ault_sdk-0.13.0-py3-none-any.whl | Python 3 | none | any | Details for ault_sdk-0.13.0-py3-none-any.whl |
Total release size: 1.5 MB
Release files / ault_sdk-0.13.0.tar.gz
| Download URL | ault_sdk-0.13.0.tar.gz |
|---|---|
| Size | 777.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6864ac4389fddda415bc0827181b122d088e91b97b623cd387b19c1951d41c88
|
|
BLAKE2b-256 checksum How to use checksums |
cb64522c8e180ed9adaf93d281db465cffc0b7de72adc47a7c0caf3425b18e7f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logRelease files / ault_sdk-0.13.0-py3-none-any.whl
| Download URL | ault_sdk-0.13.0-py3-none-any.whl |
|---|---|
| Size | 696.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6855ed2225968ff95d900ad3c313a60ecad9e6410db6325127761ddbf23b8dfb
|
|
BLAKE2b-256 checksum How to use checksums |
044bd8bc58a39eb699b6a858148782b1aaa879cc9e6d32f0f81ed98453ce5d4c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logSecurity for ault-sdk 0.13.0
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for ault_sdk-0.13.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logA 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.