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:

SecretPurposeUsed For
API key secretHMAC authenticationRequest headers (X-4RHO-SIGNATURE)
Ethereum private keyEIP-712 order signingRequest 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 is 0xc183e918d9b1276b3e0037c4d66c8d25748a791f (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/config once 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 as INVALID_SIGNATURE because the recovered signer won't match the order's signer field once the domain typehash diverges.

Migration policy

When a new Exchange is deployed, 4rho rolls out as follows:

  1. The operator brings the new contract online and confirms it's funded + healthy.
  2. GET /v1/platform/config flips to the new exchange_address atomically. 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.
  3. If you cache the verifyingContract beyond a single trading session, refresh whenever you receive INVALID_SIGNATURE for a freshly-signed order — it's the load-bearing signal that your cached address is stale.
  4. 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).

FieldSolidity TypeDescription
saltuint256Random nonce for uniqueness
makeraddressWallet address of the order creator
signeraddressWallet that signs the order (usually same as maker)
takeraddressSpecific counterparty, or 0x0 for any
tokenIduint256Outcome token identifier
makerAmountuint256Amount the maker is offering (in wei)
takerAmountuint256Amount the maker wants in return (in wei)
expirationuint256Unix timestamp when the order expires
nonceuint256Wallet nonce for replay protection
feeRateBpsuint256Fee rate in basis points
minTakerNetuint256Minimum net amount the maker accepts after fees. 0 = no constraint (the legacy default).
sideuint80 = 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.