Skip to content

Signing orders

Every place, modify and cancel on /ws/trade carries a signature from a session key. The gateway checks the signature before it checks your collateral. A message without one gets order_rejected with signature_required.

The Python examples use httpx, cryptography and pycryptodome. They also use the signed function from API keys.

A session key is a key pair that you make to sign orders. It is not your API key. Your API key signs HTTP requests and the socket handshake. Your session key signs each order message.

  • The curve is P-256, also called secp256r1 or prime256v1.
  • The public key is 65 bytes: 0x04, then x, then y, each 32 bytes. You write it as 0x and 130 hex digits. We refuse the compressed form.
  • The address of the key is the last 20 bytes of the keccak256 hash of x then y. You write it as 0x and 40 hex digits. It is the session_key on every message you sign.

To get a session key, register your own key:

  1. Make a P-256 key pair.
  2. Work out its public key and its address.
  3. Send POST /api/user/me/session-keys, signed with your API key.

The signature of an API key request covers the body. See API keys.

Field Value
session_key The address of the key.
public_key The public key.
expires_at_ms When the key stops working, in Unix milliseconds. At most 24 hours from now. With an API key and a grant, at most 180 days after the grant’s issued_at_ms.
import json
import time
import httpx
from Crypto.Hash import keccak
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
BASE = "https://inflection.exchange"
def keccak256(data: bytes) -> bytes:
return keccak.new(digest_bits=256, data=data).digest()
KEY = ec.generate_private_key(ec.SECP256R1())
PUBLIC_KEY = KEY.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
ADDRESS = "0x" + keccak256(PUBLIC_KEY[1:])[12:].hex()
path = "/api/user/me/session-keys"
body = json.dumps(
{
"session_key": ADDRESS,
"public_key": "0x" + PUBLIC_KEY.hex(),
"expires_at_ms": int(time.time() * 1000) + 23 * 60 * 60 * 1000,
}
).encode()
response = httpx.post(
BASE + path,
content=body,
headers={**signed("POST", path, body), "content-type": "application/json"},
)
response.raise_for_status()
print(response.json())

The route answers 201 with the key. Keep the private key secret: it can place and cancel orders for your account. Each address can be registered once. If you lose the private key, register a new key.

  • GET /api/user/me/session-keys lists your live keys.
  • DELETE /api/user/me/session-keys/{address} revokes one. Its next message gets session_key_unknown. On the chain, the key stays valid for up to 12 more seconds, for orders that the exchange accepted just before.
  • Revoking an API key revokes the session keys made with it.
  • You can have 20 live keys. A new key then replaces the oldest key made with the same API key.
  • You can register 5 keys a minute.

If your account has a linked wallet, each key also needs a grant: a signature of the wallet. The exchange sets the start of a grant, not you. To make a grant:

  1. Send GET /api/user/me/session-keys/issued-at, signed with your API key. It returns issued_at_ms, the exchange’s time now, and issued_at_token.
  2. Sign the grant with the wallet. Use that issued_at_ms.
  3. Within 10 minutes, send POST /api/user/me/session-keys with the grant: wallet, issued_at_ms, issued_at_token and signature.

A grant without issued_at_token gets 422. A grant with an issued_at_ms or token that the exchange did not give your account, or one from more than 10 minutes ago, gets 400. The route reference describes the rest.

The lifetime of the key counts from issued_at_ms, not from the registration. With an API key, expires_at_ms is at most issued_at_ms plus 180 days. The exchange takes the key from issued_at_ms. A request before it gets session_key_unknown.

You sign a 32-byte digest of the message, not the JSON. To sign a message:

  1. Build the digest from the domain and the fields of the message.
  2. Sign the 32 bytes of the digest with ECDSA on P-256 and SHA-256.
  3. If s is above n/2, replace s with n minus s.
  4. Write r then s, each as 32 bytes, as 0x and 128 hex digits.

Most libraries hash the data you give them, so give them the digest. A library that takes a hash needs the SHA-256 hash of the digest. n is the order of the curve: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551.

The digest follows EIP-712. The domain has four fields:

Field Value
name Inflection Exchange
version 1
chainId chain_id of your environment
verifyingContract verifying_contract of your environment

The file schema/signing/environments.json is the only source of chain_id and verifying_contract. It has one entry for each environment. The live site signs in the testnet entry. Read the values from the file. Do not copy them into your code.

The domain changes if the exchange moves to another chain, or if it deploys its vault again. Signatures made in the old domain then stop working. Read the file again after each change.

Each message type has a type string:

Place(uint64 marketId,uint64 clientOrderId,uint8 side,uint8 outcome,uint8 orderType,uint32 priceBps,uint64 quantity,uint8 quantityUnit,uint8 timeInForce,uint64 expiryMs,bool postOnly,uint16 maxFeeBps)
Modify(uint64 marketId,uint64 clientOrderId,uint32 version,uint32 priceBps,uint64 quantity)
Cancel(uint64 marketId,uint64 clientOrderId)
Withdraw(uint64 accountId,uint64 toAccountId,uint64 amount,uint64 nonce)

Every field is a whole number. Write each as 32 bytes, big-endian. A bool is 0 or 1. The struct hash is keccak256 of the hash of the type string, then the fields in the order of the type string. The digest is keccak256 of 0x1901, the domain separator and the struct hash.

Sign the values you send. If you leave out an optional field, sign its default.

Signed field Message field Value
marketId market_id As sent.
clientOrderId client_order_id As sent.
side side bid is 0, ask is 1.
outcome outcome up is 0, down is 1.
orderType order_type limit is 0, market is 1.
version version of a modify As sent. The order’s current version plus 1.
priceBps price_bps As sent. For a market order, its worst price. 0 if you leave it out.
quantity quantity As sent.
quantityUnit quantity_unit lots is 0, cash is 1.
timeInForce time_in_force gtc is 0, ioc is 1, fok is 2, gtd is 3.
expiryMs expiry_ms of a gtd order 0 for any other order.
postOnly post_only false is 0, true is 1.
maxFeeBps max_fee_bps As sent. 0 if you leave it out.

max_fee_bps is the most fee the order accepts on each fill, in basis points of the fill’s cost. Sign at least the taker_fee_rate_bps of the market, which is in the market record. A place with less gets max_fee_below_market_rate, and a market with a taker fee rejects the default of 0. The rejection carries the taker_fee_rate_bps to use. See Fees.

A modify signs marketId, clientOrderId, version, priceBps and quantity. A cancel signs marketId and clientOrderId. No order message signs a time or a nonce.

A withdraw signs four fields. The message has no account id field. The gateway checks the signature against the account of your session:

Signed field Message field Value
accountId None Your account id, from auth_success.
toAccountId None Always 0.
amount amount As sent, in units of 0.000001 cash.
nonce nonce As sent. The next_nonce of your account’s withdrawals.

The trading socket reference has the withdraw message and its answers.

This function adds the two signature fields to a place, modify, cancel or withdraw. For a withdraw, pass your account id as account_id. It uses json, KEY, ADDRESS and keccak256 from the code above. It reads a copy of environments.json from the folder you run it in.

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
ENVIRONMENT = json.load(open("environments.json"))["testnet"]
CHAIN_ID = ENVIRONMENT["chain_id"]
VERIFYING_CONTRACT = int(ENVIRONMENT["verifying_contract"], 16)
CURVE_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
TYPES = {
"place": b"Place(uint64 marketId,uint64 clientOrderId,uint8 side,uint8 outcome,uint8 orderType,uint32 priceBps,uint64 quantity,uint8 quantityUnit,uint8 timeInForce,uint64 expiryMs,bool postOnly,uint16 maxFeeBps)",
"modify": b"Modify(uint64 marketId,uint64 clientOrderId,uint32 version,uint32 priceBps,uint64 quantity)",
"cancel": b"Cancel(uint64 marketId,uint64 clientOrderId)",
"withdraw": b"Withdraw(uint64 accountId,uint64 toAccountId,uint64 amount,uint64 nonce)",
}
DOMAIN_TYPE = b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
SIDES = {"bid": 0, "ask": 1}
OUTCOMES = {"up": 0, "down": 1}
ORDER_TYPES = {"limit": 0, "market": 1}
QUANTITY_UNITS = {"lots": 0, "cash": 1}
TIME_IN_FORCE = {"gtc": 0, "ioc": 1, "fok": 2, "gtd": 3}
def number(value: int) -> bytes:
return value.to_bytes(32, "big")
DOMAIN_SEPARATOR = keccak256(
keccak256(DOMAIN_TYPE)
+ keccak256(b"Inflection Exchange")
+ keccak256(b"1")
+ number(CHAIN_ID)
+ number(VERIFYING_CONTRACT)
)
def fields_of(message: dict, account_id: int = 0) -> list[int]:
if message["type"] == "withdraw":
return [account_id, 0, message["amount"], message["nonce"]]
if message["type"] == "cancel":
return [message["market_id"], message["client_order_id"]]
if message["type"] == "modify":
return [
message["market_id"],
message["client_order_id"],
message["version"],
message["price_bps"],
message["quantity"],
]
time_in_force = message.get("time_in_force", "gtc")
expiry_ms = 0
if isinstance(time_in_force, dict):
expiry_ms = time_in_force["gtd"]["expiry_ms"]
time_in_force = "gtd"
return [
message["market_id"],
message["client_order_id"],
SIDES[message["side"]],
OUTCOMES[message["outcome"]],
ORDER_TYPES[message.get("order_type", "limit")],
message.get("price_bps", 0),
message["quantity"],
QUANTITY_UNITS[message.get("quantity_unit", "lots")],
TIME_IN_FORCE[time_in_force],
expiry_ms,
int(message.get("post_only", False)),
message.get("max_fee_bps", 0),
]
def sign_message(message: dict, account_id: int = 0) -> dict:
fields = fields_of(message, account_id)
struct_hash = keccak256(keccak256(TYPES[message["type"]]) + b"".join(number(field) for field in fields))
digest = keccak256(b"\x19\x01" + DOMAIN_SEPARATOR + struct_hash)
r, s = decode_dss_signature(KEY.sign(digest, ec.ECDSA(hashes.SHA256())))
if s > CURVE_ORDER // 2:
s = CURVE_ORDER - s
return {**message, "session_key": ADDRESS, "signature": "0x" + number(r).hex() + number(s).hex()}

The file schema/signing/vectors.json holds test vectors. They use a made-up key, the local domain and fixed numbers. Each vector has the message, the digest and a signature.

  1. Build the domain separator from the local entry of environments.json. It must be 0x1792fd62ff0dfb0b8e1221c70eac3334c620bf409d019f8779f5d7a5e954a97c.
  2. Build the digest of a vector in the local domain. It must equal digest.
  3. Verify the vector’s signature against the vector’s public key.

Your own signatures differ from the vectors, because ECDSA uses a random number. Verify them. Do not compare them.

A signed message has no time and no nonce. The exchange takes no signed message twice for a different reason: an order id is used once, and a change names the version it makes.

  • A client_order_id is never reused in a market, even after its order has filled or been cancelled. A second place with the same id gets duplicate_client_order_id. Use a random number from 1 to 9007199254740991 for each place.
  • If you do not know whether a place arrived, send the same message again. The exchange rejects it if it already has the order. It cannot place the order twice.
  • A place makes version 0 of an order. A modify signs the version it makes, which is the current version plus 1. Each modify that the exchange applies adds 1 to the version. A fill does not change it.
  • If the version of a modify is not the current version plus 1, the exchange rejects it with version_changed. This happens when you send a modify twice, or when another connection of your account changed the order first.
  • Read the current version from order_modified, which every connection of your account gets. After a restart, read it from open_orders, which gives the version of each order.
  • Many connections can use one session key, in any order.

A cancel needs no version. If the order is already gone, a second cancel gets unknown_order.

If your bot still sends signed_at_ms or nonce, the gateway answers with an error message, because it does not know those fields. Remove them.

The gateway checks two times against its own clock. It sends this clock as time_ms in auth_success.

The gateway allows 4 seconds for a message to reach the engine. So:

  • A gtd order whose expiry_ms is less than 4 seconds ahead gets expiry_too_soon.
  • A session key that expires in 5 seconds or less gets session_key_expired. This margin is the 4 seconds plus 1 second for the engine’s clock.

Your own clock only has to be close enough to choose an expiry_ms.

A refusal is an order_rejected message with a reason. It has no seq. Nothing happened to your order. A field in the wrong format, such as a signature of the wrong length, gets an error message instead.

reason Why What to do
signature_required session_key or signature is missing. Send both.
session_key_unknown No live key with this address belongs to your account. Or the request is before the key’s issued_at_ms. Or, for a place or modify, the gateway has had no list of keys for 5 seconds. Register the key. A key can take a moment to reach the gateway. In the other cases, send the request again.
session_key_expired The key has expired, or it expires in 5 seconds or less. Register a new key.
signature_invalid The signature does not match the message and the key, or its s is above n/2. Check the digest, SHA-256 and low s with the vectors.
version_changed The version of a modify is not the order’s current version plus 1. Read the current version. Sign the modify again.
expiry_too_soon The expiry_ms of a gtd order is less than 4 seconds ahead of the gateway’s clock. Choose a later expiry_ms.

Every rejection is listed in Errors.