EIP-712 Order Signing
All orders on 4rho are signed off-chain using EIP-712 typed structured data and matched by the CLOB engine. Matched trades are settled on-chain by the operator.
Prerequisites
Placing orders requires two separate secrets:
| Secret | Purpose | Used For |
|---|---|---|
| API key secret | HMAC authentication | Request headers (X-4RHO-SIGNATURE) |
| Ethereum private key | EIP-712 order signing | Request body (signature field) |
Your API key secret authenticates you to the API. Your Ethereum private key proves you own the wallet placing the order. These are different keys — do not confuse them.
Common mistake: Passing your Ethereum address (20 bytes, starts with
0x) where a private key (32 bytes / 64 hex chars) is expected. The address identifies your wallet; the private key signs transactions from it.
Domain
The EIP-712 domain must match the deployed Exchange contract:
{
"name": "4rho Exchange",
"version": "1",
"chainId": 137,
"verifyingContract": "0xc183e918d9b1276b3e0037c4d66c8d25748a791f"
}
chainId— Polygon Mainnet (137) or Amoy testnet (80002).verifyingContract— the active Exchange contract. Today this is0xc183e918d9b1276b3e0037c4d66c8d25748a791f(ExchangeV6, live on Polygon mainnet since 2026-05-23). The previous deployment ExchangeV5 (0x4295bad60bc14357d49544D11a219c605B18F44d) is read-only for settlement of pre-cutover orders and rejects new signatures.
⚠️ Do not hard-code the verifyingContract. Fetch it from
GET /v1/platform/configonce on startup and re-fetch on every deploy. The address is the operator's contract pointer; it rotates on every upgrade. Hard-coded clients break silently the next time the platform cuts over to a new exchange — your signed orders will be rejected asINVALID_SIGNATUREbecause the recovered signer won't match the order'ssignerfield once the domain typehash diverges.
Migration policy
When a new Exchange is deployed, 4rho rolls out as follows:
- The operator brings the new contract online and confirms it's funded + healthy.
GET /v1/platform/configflips to the newexchange_addressatomically. The order verifier on the API switches to the new domain in the same deploy, so any order you sign against the freshly-fetched address is accepted immediately.- If you cache the verifyingContract beyond a single trading session, refresh whenever you receive
INVALID_SIGNATUREfor a freshly-signed order — it's the load-bearing signal that your cached address is stale. - The previous Exchange remains read-only for settlement of orders signed before the cutover; it does NOT accept new signatures. There is no quiet period: from the cutover instant, only the new address verifies.
Order Type
The Order struct for EIP-712 signing — field order is load-bearing. The on-chain ORDER_TYPEHASH is computed from this exact sequence; one swapped field produces a different typehash and the signature won't verify (P0-08 / 2026-05-03 incident).
| Field | Solidity Type | Description |
|---|---|---|
salt | uint256 | Random nonce for uniqueness |
maker | address | Wallet address of the order creator |
signer | address | Wallet that signs the order (usually same as maker) |
taker | address | Specific counterparty, or 0x0 for any |
tokenId | uint256 | Outcome token identifier |
makerAmount | uint256 | Amount the maker is offering (in wei) |
takerAmount | uint256 | Amount the maker wants in return (in wei) |
expiration | uint256 | Unix timestamp when the order expires |
nonce | uint256 | Wallet nonce for replay protection |
feeRateBps | uint256 | Fee rate in basis points |
minTakerNet | uint256 | Minimum net amount the maker accepts after fees. 0 = no constraint (the legacy default). |
side | uint8 | 0 = BUY, 1 = SELL |
minTakerNet is part of the typehash even when you don't use it; clients that omit it must serialize the field as 0 so the hash matches the on-chain ORDER_TYPEHASH.
Signing with ethers.js (v6)
import { ethers } from "ethers";
// Create a wallet from your private key (64 hex characters, NOT your address)
const PRIVATE_KEY = "0xYourPrivateKeyHere"; // 32 bytes = 64 hex chars
const wallet = new ethers.Wallet(PRIVATE_KEY);
// wallet.address → "0x..." (your public address, derived automatically)
// IMPORTANT: fetch verifyingContract from /v1/platform/config rather
// than hard-coding it. The address rotates whenever the platform cuts
// over to a new Exchange (most recent: V4 → V5 on 2026-05-04). A
// hard-coded value silently breaks at the next cutover; the
// freshly-fetched value works through the cutover with no client
// change.
const platformConfig = await fetch("https://api.4rho.com/v1/platform/config")
.then((r) => r.json());
const domain = {
name: "4rho Exchange",
version: "1",
chainId: 137,
verifyingContract: platformConfig.exchange_address,
};
const types = {
Order: [
{ name: "salt", type: "uint256" },
{ name: "maker", type: "address" },
{ name: "signer", type: "address" },
{ name: "taker", type: "address" },
{ name: "tokenId", type: "uint256" },
{ name: "makerAmount", type: "uint256" },
{ name: "takerAmount", type: "uint256" },
{ name: "expiration", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "feeRateBps", type: "uint256" },
{ name: "minTakerNet", type: "uint256" },
{ name: "side", type: "uint8" },
],
};
const order = {
salt: BigInt(Math.floor(Math.random() * 2 ** 128)),
maker: wallet.address,
signer: wallet.address,
taker: ethers.ZeroAddress,
tokenId: BigInt("12345"),
makerAmount: ethers.parseUnits("10", 6), // 10 USDC
takerAmount: ethers.parseUnits("5", 6), // 5 USDC
expiration: BigInt(Math.floor(Date.now() / 1000) + 3600),
nonce: BigInt(0),
feeRateBps: BigInt(50), // 0.50%
minTakerNet: BigInt(0), // 0 = no per-order net floor (legacy default)
side: 0, // BUY
};
const signature = await wallet.signTypedData(domain, types, order);
Signing with web3.py
import time
from web3 import Web3
from eth_account import Account
from eth_account.messages import encode_typed_data
# Create an account from your private key (64 hex characters, NOT your address)
PRIVATE_KEY = "0xYourPrivateKeyHere" # 32 bytes = 64 hex chars
account = Account.from_key(PRIVATE_KEY)
# account.address → "0x..." (your public address, derived automatically)
# IMPORTANT: fetch verifyingContract from /v1/platform/config — see
# JS example above for the rationale.
import requests
platform_config = requests.get("https://api.4rho.com/v1/platform/config").json()
domain_data = {
"name": "4rho Exchange",
"version": "1",
"chainId": 137,
"verifyingContract": platform_config["exchange_address"],
}
order_types = {
"Order": [
{"name": "salt", "type": "uint256"},
{"name": "maker", "type": "address"},
{"name": "signer", "type": "address"},
{"name": "taker", "type": "address"},
{"name": "tokenId", "type": "uint256"},
{"name": "makerAmount", "type": "uint256"},
{"name": "takerAmount", "type": "uint256"},
{"name": "expiration", "type": "uint256"},
{"name": "nonce", "type": "uint256"},
{"name": "feeRateBps", "type": "uint256"},
{"name": "minTakerNet", "type": "uint256"},
{"name": "side", "type": "uint8"},
]
}
order_data = {
"salt": 123456789,
"maker": account.address,
"signer": account.address,
"taker": "0x0000000000000000000000000000000000000000",
"tokenId": 12345,
"makerAmount": 10_000_000, # 10 USDC (6 decimals)
"takerAmount": 5_000_000, # 5 USDC
"expiration": int(time.time()) + 3600,
"nonce": 0,
"feeRateBps": 50, # 0.50%
"minTakerNet": 0, # 0 = no per-order net floor (legacy default)
"side": 0, # BUY
}
signable = encode_typed_data(
domain_data, order_types, "Order", order_data
)
signed = account.sign_message(signable)
signature = signed.signature.hex()
Submitting the Signed Order
curl -X POST https://api.4rho.com/v1/orders \
-H "Content-Type: application/json" \
-H "X-4RHO-API-KEY: $API_KEY" \
-H "X-4RHO-SIGNATURE: $HMAC_SIGNATURE" \
-H "X-4RHO-TIMESTAMP: $TIMESTAMP" \
-H "X-4RHO-PASSPHRASE: $PASSPHRASE" \
-d '{
"market_id": "550e8400-e29b-41d4-a716-446655440000",
"outcome_index": 0,
"salt": "123456789",
"maker": "0xYourAddress",
"signer": "0xYourAddress",
"taker": "0x0000000000000000000000000000000000000000",
"token_id": "12345",
"maker_amount": "10000000",
"taker_amount": "5000000",
"expiration": 1735689600,
"nonce": 0,
"fee_rate_bps": 50,
"min_taker_net": "0",
"side": "BUY",
"signature": "0xYourEIP712Signature..."
}'
The market_id and outcome_index fields identify which market and outcome you are trading. The remaining fields are the EIP-712 signed order data. Both the HMAC signature (headers) and the EIP-712 signature (body) are required.
See the Orders reference for the full list of request fields including optional ones like time_in_force and post_only.