ExchangeOS writes carry a signed transaction envelope. Signing is local; the SDK never sends a private key to the REST server.
- Build the unsigned transaction in canonical insertion order:
action,nonce, optionalexpiresAfter, optionalvaultAddress, and optionaluser. - Encode that mapping with MessagePack without sorting keys.
- Compute
connectionId = keccak256(messagepack_bytes). - Hash EIP-712
Agent(string source,bytes32 connectionId). - Sign the digest with secp256k1 and emit
r,s, andv(0or1). - Add the signature to the submission envelope.
The EIP-712 domain is:
| Field | Value |
|---|---|
name |
Exchange |
version |
1 |
chainId |
70000196 |
verifyingContract |
0x0000000000000000000000000000000000000000 |
vaultAddress identifies a delegated submission account and is included in
the unsigned MessagePack, matching the Java SDK and the full TradeZone Rust
SDK. user, when supplied, is also included in the unsigned hash.
JSON and REST envelopes represent addresses as 0x-prefixed strings. During
canonical MessagePack encoding, typed Address fields—including vaultAddress,
user, and subAccountTransfer.subAccountUser—are encoded as 20-byte binary
values, matching Rust serde. This conversion only affects signing bytes and
does not change the public JSON wire format.
Each supported signed action is an immutable dataclass with an explicit Python
constructor. Typed actions can be passed directly to sign_action():
from exos_python_sdk import PositionMode, UpdatePositionMode, sign_action
action = UpdatePositionMode(mode=PositionMode.HEDGE)
signed = sign_action(action, private_key, nonce=1_700_000_000_000)Every action exposes its wire discriminator through action.type and its
validated JSON/MessagePack mapping through action.to_wire(). Typed actions
also implement Mapping, so signing remains compatible with the existing
protocol functions.
All variants and their fields live in signing/actions/action.py, mirroring
the Rust SDK's internally tagged Action enum. Variant field order determines
the MessagePack bytes. In particular, CancelAll.assetIds is emitted even
when empty.
| Typed Action | Wire type | Constructor fields |
|---|---|---|
PlaceOrder |
placeOrder |
orders, grouping=ActionGrouping.NA, builder=None |
Cancel |
cancel |
cancels |
CancelAll |
cancelAll |
asset_ids, market_type |
Modify |
modify |
modifies |
CreateSubAccount |
createSubAccount |
name |
SubAccountTransfer |
subAccountTransfer |
sub_account_user, is_deposit, token_id, amount |
UpdateLeverage |
updateLeverage |
asset_id, leverage, position_side=None |
UpdateMarginMode |
updateMarginMode |
asset_id, margin_mode |
UpdatePositionMode |
updatePositionMode |
mode |
UpdateIsolatedMargin |
updateIsolatedMargin |
asset_id, position_side, direction, amount |
ReserveRequestWeight |
reserveRequestWeight |
amount, token_id=None |
PredictionSplit |
predictionSplit |
market_id, size |
PredictionMerge |
predictionMerge |
market_id, size |
PredictionRedeem |
predictionRedeem |
market_id |
Noop |
noop |
none |
The three Prediction* entries preserve the existing Outcomes signing
protocol only; they do not imply Outcomes REST or WebSocket support.
ReserveRequestWeight and Noop are separate protocol actions, with Noop
also used by the ExchangeOS trade endpoint and heartbeat compatibility helper.
In particular, MarketType.PREDICTION is retained only for standalone
compatibility signing.
Trade endpoint wrappers forward raw signed mappings to OpenAPI, which owns
the current market/business validation. The typed cancelAll helper still
restricts its market type to Spot or Perps because those are the SDK trade
surface variants.
Trade helper functions build order items, cancel items, modify items, Perps
market specifications, and limit/trigger order types. order_item() requires
the signed action's market_type; when client_order_id is omitted, it
generates a valid API client order ID and includes it in the wire mapping.
cancel_item() accepts an optional market_type and omits it from the wire
when absent. Raw signed mappings still need to contain every field required by
the OpenAPI backend.
price_amend_type may be set to noAmend or clampToLimit. modify_item() requires
exactly one of oid or client_order_id, matching the wire fields oid and
clientOrderId. Account helpers cover
leverage, margin mode, position mode, isolated margin, subaccount creation, and
subaccount transfer. Outcomes helpers remain signing compatibility wrappers.
The existing *_action() functions remain available as compatibility helpers;
they construct the corresponding typed action and return to_wire().
Heartbeat uses the Java SDK's canonical {"type":"noop"} action. Leverage
updates accept an optional positionSide; marginMode is configured through
the separate margin-mode action and is not part of the signed leverage action.
All price, size, amount, and leverage values should be decimal strings. Floats are rejected before MessagePack encoding.
Fixed action parameters are available as signing enums, including
MarketType, OrderSide, OrderTimeInForce, MarginMode, PositionSide,
and PositionMode. Builders continue to accept their equivalent string values
for compatibility; both forms emit identical wire data and signatures. REST
response enums remain separate because they are forward-compatible and do not
define the closed signing protocol.
The two market-type enums intentionally have different scope:
exos_python_sdk.RestMarketTypeis for REST queries and contains onlySPOTandPERP.exos_python_sdk.MarketTypeis the closed signing enum and retainsPREDICTIONfor protocol compatibility in addition toSPOTandPERP.
When nonce is omitted, the SDK allocates a process-local monotonically
increasing millisecond value. This prevents concurrent calls in one process
from reusing a nonce; callers coordinating across multiple processes should
supply their own nonce allocator.
generate_client_order_id() emits a fixed 34-character lowercase value:
0x + region(1 hex) + source(1 hex) + secure random(30 hex)
The 30-character suffix contains 120 bits from secrets.token_hex(15). New
SDK-generated IDs default to Region.HK and OrderSource.API, producing the
prefix 0x03. Registered sources are Manual 2, API 3, Trigger 4, and
Liquidation 5. Second-nibble values 0 and 1 are reserved for legacy
Prediction testnet/mainnet IDs and are not generated by this SDK.
validate_client_order_id() accepts 0x or 0X and case-insensitive hex for
compatibility with the server parser. Generation always returns canonical
lowercase. Existing legacy IDs remain valid inputs.
sign_action() accepts a typed Action or any validated action mapping. Use a
raw mapping only when a new server action is available before a typed action
ships. Mapping key order is part of the signed bytes, so construct raw mappings
in protocol order.
It returns an immutable SignedTransaction. Pass that object directly to a
signed REST write or call to_wire() to obtain a fresh JSON-compatible
mapping. Optional expires_after, vault_address, and user values are part
of the signed bytes; changing any of them requires a new signature. The
chain argument selects the EIP-712 Agent.source value (Dev, Testnet, or
Mainnet) and does not change the fixed domain chain ID.
For application code, prefer client.sign_action(...). It selects the source
from the client's Environment, which binds the signing chain to the REST and
WebSocket deployment and rejects a separate chain override:
signed = client.sign_action(action, private_key, nonce=1_700_000_000_000)
receipt = client.trade.place_order(signed)The module-level sign_action() remains available for protocol tooling and
independent signature verification.
sign_transaction() remains an equivalent compatibility alias for applications
written against pre-release versions of the SDK.
Compatibility helpers include connection_id(), signing_hash(), and
recover_address(). They are useful for fixtures and independent signature
verification.