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.
What a session key is
Section titled “What a session key is”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 as0xand 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
0xand 40 hex digits. It is thesession_keyon every message you sign.
Get a session key
Section titled “Get a session key”To get a session key, register your own key:
- Make a P-256 key pair.
- Work out its public key and its address.
- 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 jsonimport time
import httpxfrom Crypto.Hash import keccakfrom cryptography.hazmat.primitives.asymmetric import ecfrom 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-keyslists your live keys.DELETE /api/user/me/session-keys/{address}revokes one. Its next message getssession_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:
- Send
GET /api/user/me/session-keys/issued-at, signed with your API key. It returnsissued_at_ms, the exchange’s time now, andissued_at_token. - Sign the grant with the wallet. Use that
issued_at_ms. - Within 10 minutes, send
POST /api/user/me/session-keyswith thegrant:wallet,issued_at_ms,issued_at_tokenandsignature.
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.
Sign a message
Section titled “Sign a message”You sign a 32-byte digest of the message, not the JSON. To sign a message:
- Build the digest from the domain and the fields of the message.
- Sign the 32 bytes of the digest with ECDSA on P-256 and SHA-256.
- If s is above n/2, replace s with n minus s.
- Write r then s, each as 32 bytes, as
0xand 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 domain
Section titled “The domain”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.
The types
Section titled “The types”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.
The code
Section titled “The code”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 hashesfrom 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()}Check your code
Section titled “Check your code”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.
- Build the domain separator from the
localentry ofenvironments.json. It must be0x1792fd62ff0dfb0b8e1221c70eac3334c620bf409d019f8779f5d7a5e954a97c. - Build the digest of a vector in the
localdomain. It must equaldigest. - Verify the vector’s
signatureagainst the vector’s public key.
Your own signatures differ from the vectors, because ECDSA uses a random number. Verify them. Do not compare them.
Ids and versions
Section titled “Ids and versions”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_idis never reused in a market, even after its order has filled or been cancelled. A secondplacewith the same id getsduplicate_client_order_id. Use a random number from 1 to 9007199254740991 for eachplace. - If you do not know whether a
placearrived, send the same message again. The exchange rejects it if it already has the order. It cannot place the order twice. - A
placemakes version 0 of an order. Amodifysigns the version it makes, which is the current version plus 1. Eachmodifythat the exchange applies adds 1 to the version. A fill does not change it. - If the
versionof amodifyis not the current version plus 1, the exchange rejects it withversion_changed. This happens when you send amodifytwice, 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 fromopen_orders, which gives theversionof 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
gtdorder whoseexpiry_msis less than 4 seconds ahead getsexpiry_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.
When a message is refused
Section titled “When a message is refused”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.