Market Maker GuideÂļ
Network & Compatibility
| Resource | Value |
|---|---|
| API base URL | https://api.sera.cx/api/v1 |
| Chain | Ethereum mainnet (chainId 1) |
| Sera contract | 0xB5C50C5D5f038404F85970b7f5B7259C4AC0E198 |
| Vault contract | 0xC7d4Fd2638e6630C8C61329878676b88A8A24D43 |
| SOR contract | 0xa7A0cf7cd6f043fCA23f29d8ae5aae6b46e11c18 |
Signing primitives. Every trading mutation is an EIP-712 typed-data signature against the Sera domain. Deposits that take the permit path use the ERC-2612 Permit extension â supported by USDC, EURC, and many modern stablecoins, not all ERC-20s; call GET /permit/metadata to check support before signing. API-key management uses an EIP-712 ManageApiKey payload.
Tested clients. Python eth_account >= 0.10 + requests; TypeScript ethers v6 (signer.signTypedData). Browser wallets confirmed working with EIP-712 typed data: MetaMask, Rabby, Frame, Coinbase Wallet, Trust, Rainbow. Safe multisigs work via EIP-1271 (the message is verified on-chain rather than via ecrecover).
Address casing. Read endpoints (/balances, /orders, /fills) treat owner_address as case-sensitive â pass the lowercase form. EIP-712 signed payloads accept EIP-55 checksum addresses.
This is the page you want open while you write the first version of your bot. It walks through three things in order:
- Place a single limit order manually, end-to-end.
- Cancel that order.
- Wrap both into an automated quoting loop driven by your own rate source.
Setup that lives elsewhere â minting an API key, funding the Vault, withdrawing later â is linked under Next Steps. Come back here once your wallet has a USDC balance on Ethereum mainnet and an API key in hand.
Code snippets are tabbed between Python and TypeScript. Pick a tab and stick with it; the two implementations are line-for-line equivalent.
SetupÂļ
from decimal import Decimal
import json, time, uuid
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data
API = "https://api.sera.cx/api/v1"
DOMAIN = {
"name": "Sera",
"version": "1",
"chainId": 1, # Mainnet
"verifyingContract": "0xB5C50C5D5f038404F85970b7f5B7259C4AC0E198",
}
PRIVATE_KEY = "0x...your wallet key..." # signer
API_KEY = "sera_..." # see Authentication
API_SECRET = "..."
OWNER = Account.from_key(PRIVATE_KEY).address
AUTH = {"Authorization": f"Bearer {API_KEY}:{API_SECRET}"}
import { Wallet, TypedDataDomain, randomBytes } from "ethers";
const API = "https://api.sera.cx/api/v1";
const DOMAIN: TypedDataDomain = {
name: "Sera",
version: "1",
chainId: 1, // Mainnet
verifyingContract: "0xB5C50C5D5f038404F85970b7f5B7259C4AC0E198",
};
const PRIVATE_KEY = "0x...your wallet key..."; // signer
const API_KEY = "sera_..."; // see Authentication
const API_SECRET = "...";
const wallet = new Wallet(PRIVATE_KEY);
const OWNER = wallet.address;
const AUTH = { "Authorization": `Bearer ${API_KEY}:${API_SECRET}` };
The EIP-712 Order typed-data structure mirrors the on-chain SeraLib.ORDER_TYPEHASH. Define it once:
ORDER_TYPES = {
"Order": [
{"name": "user", "type": "address"},
{"name": "expiration", "type": "uint48"},
{"name": "feeBps", "type": "uint48"},
{"name": "recipient", "type": "address"},
{"name": "fromToken", "type": "address"},
{"name": "toToken", "type": "address"},
{"name": "fromAmount", "type": "uint256"},
{"name": "toAmount", "type": "uint256"},
{"name": "initialDepositAmount", "type": "uint256"},
{"name": "uuid", "type": "uint256"},
]
}
CANCEL_ORDER_TYPES = {
"CancelOrder": [
{"name": "owner", "type": "address"},
{"name": "orderId", "type": "uint256"},
]
}
const ORDER_TYPES = {
Order: [
{ name: "user", type: "address" },
{ name: "expiration", type: "uint48" },
{ name: "feeBps", type: "uint48" },
{ name: "recipient", type: "address" },
{ name: "fromToken", type: "address" },
{ name: "toToken", type: "address" },
{ name: "fromAmount", type: "uint256" },
{ name: "toAmount", type: "uint256" },
{ name: "initialDepositAmount", type: "uint256" },
{ name: "uuid", type: "uint256" },
],
};
const CANCEL_ORDER_TYPES = {
CancelOrder: [
{ name: "owner", type: "address" },
{ name: "orderId", type: "uint256" },
],
};
The uuid_int Bit LayoutÂļ
Every order's uuid is a packed 256-bit integer:
ââââââââââââŦââââââââââââââââââââŦâââââââââââââââââââŦâââââââââââ
â[255:252] â [251:124] â [123:12] â [11:0] â
â Executor â Order ID â Group ID â Leg ID â
â 4 bit â 128 bit â 112 bit â 12 bit â
â (0â15) â (full UUID4) â (UUID4 >> 16) â (0â4095) â
ââââââââââââ´ââââââââââââââââââââ´âââââââââââââââââââ´âââââââââââ
For a standalone order, group_id = order_id >> 16 and leg_id = 0. For a VL batch leg i, every leg shares the same group_id (derived from leg 0's UUID4) and leg_id = i. The server rejects anything else.
executor_id is set per deployment. On Ethereum mainnet it is 0 unless your deployment publishes otherwise. Read once at startup and reuse â drifting executor IDs invalidate every signed UUID you have outstanding.
def compose_uuid(order_id: str, executor_id: int = 0,
leg_id: int = 0, group_uuid: str | None = None) -> int:
"""Pack a UUID4 string into the on-chain composite uint256."""
oid = int(uuid.UUID(order_id))
gsrc = int(uuid.UUID(group_uuid)) if group_uuid else oid
gid = gsrc >> 16 # top 112 of 128
return (executor_id << 252) | (oid << 124) | (gid << 12) | leg_id
def new_order_id() -> str:
return str(uuid.uuid4())
function composeUuid(
orderId: string,
executorId: bigint = 0n,
legId: bigint = 0n,
groupUuid: string | null = null,
): bigint {
const oid = BigInt("0x" + orderId.replace(/-/g, ""));
const gsrc = groupUuid ? BigInt("0x" + groupUuid.replace(/-/g, "")) : oid;
const gid = gsrc >> 16n; // top 112 of 128
return (executorId << 252n) | (oid << 124n) | (gid << 12n) | legId;
}
function newOrderId(): string {
const b = randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
const h = Buffer.from(b).toString("hex");
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20,32)}`;
}
Tutorial 1 â Place a Single Limit OrderÂļ
You're going to place a bid at 1.085 EURC/USDC for 1000 EURC. The market base is EURC, the quote is USDC. A bid means: spend USDC to receive EURC.
Resolve the token addresses from GET /tokens once at startup; for this walkthrough we hard-code them:
The wire payload to POST /orders uses pair-natural fields (side, amount, price, from_address=base, to_address=quote). The EIP-712 message is the on-chain Order struct, where fromToken is whatever you spend (USDC on a bid, EURC on an ask) and amounts are raw. Build both, sign, submit:
def raw(amount: Decimal, decimals: int) -> int:
return int(amount * (Decimal(10) ** decimals))
def place_order(side: str, base: dict, quote: dict,
amount: Decimal, price: Decimal,
expiration: int) -> dict:
order_id = new_order_id()
uuid_int = compose_uuid(order_id) # executor_id=0
# Pair-natural raws
base_raw = raw(amount, base["decimals"])
quote_raw = raw(amount * price, quote["decimals"])
# EIP-712 struct: fromToken is what the signer SPENDS.
if side == "bid":
from_token, to_token = quote["address"], base["address"]
from_amount, to_amount = quote_raw, base_raw
else:
from_token, to_token = base["address"], quote["address"]
from_amount, to_amount = base_raw, quote_raw
order_struct = {
"user": OWNER,
"expiration": expiration,
"feeBps": 0,
"recipient": "0x0000000000000000000000000000000000000000",
"fromToken": from_token,
"toToken": to_token,
"fromAmount": from_amount,
"toAmount": to_amount,
"initialDepositAmount": 0,
"uuid": uuid_int,
}
signable = encode_typed_data(DOMAIN, ORDER_TYPES, order_struct)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
wire = {
"owner_address": OWNER,
"side": side,
"amount": str(amount),
"price": str(price),
"order_type": "limit",
"from_address": base["address"], # base, NOT spend
"to_address": quote["address"], # quote, NOT receive
"order_id": order_id,
"uuid_int": str(uuid_int),
"signature": "0x" + signature.lstrip("0x"),
"expiration": expiration,
}
r = requests.post(f"{API}/orders", headers={**AUTH, "Content-Type": "application/json"},
data=json.dumps(wire), timeout=10)
if r.status_code != 201:
envelope = r.json().get("detail", {})
raise RuntimeError(f"{envelope.get('error_code')}: {envelope.get('detail')}")
return {"order_id": order_id, "uuid_int": str(uuid_int)}
placed = place_order("bid", EURC, USDC, Decimal("1000"),
Decimal("1.085"), int(time.time()) + 3600)
print("placed:", placed)
function raw(amount: string, decimals: number): bigint {
const [w, f = ""] = amount.split(".");
const padded = (w + f.padEnd(decimals, "0")).slice(0, w.length + decimals);
return BigInt(padded || "0");
}
async function placeOrder(
side: "bid" | "ask",
base: { address: string; decimals: number },
quote: { address: string; decimals: number },
amount: string,
price: string,
expiration: number,
) {
const orderId = newOrderId();
const uuidInt = composeUuid(orderId); // executorId=0n
const baseRaw = raw(amount, base.decimals);
const quoteRaw = raw((Number(amount) * Number(price)).toString(), quote.decimals);
const isBid = side === "bid";
const fromToken = isBid ? quote.address : base.address;
const toToken = isBid ? base.address : quote.address;
const fromAmount = isBid ? quoteRaw : baseRaw;
const toAmount = isBid ? baseRaw : quoteRaw;
const orderStruct = {
user: OWNER, expiration, feeBps: 0,
recipient: "0x0000000000000000000000000000000000000000",
fromToken, toToken, fromAmount, toAmount,
initialDepositAmount: 0n, uuid: uuidInt,
};
const signature = await wallet.signTypedData(DOMAIN, ORDER_TYPES, orderStruct);
const wire = {
owner_address: OWNER,
side, amount, price, order_type: "limit",
from_address: base.address, // base, NOT spend
to_address: quote.address, // quote, NOT receive
order_id: orderId,
uuid_int: uuidInt.toString(),
signature,
expiration,
};
const r = await fetch(`${API}/orders`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify(wire),
});
if (r.status !== 201) {
const env = (await r.json()).detail ?? {};
throw new Error(`${env.error_code}: ${env.detail}`);
}
return { order_id: orderId, uuid_int: uuidInt.toString() };
}
const placed = await placeOrder("bid", EURC, USDC, "1000", "1.085",
Math.floor(Date.now() / 1000) + 3600);
console.log("placed:", placed);
Confirm it landed:
The error envelope returned on 4xx is documented in Order Endpoints â Error Envelope. Branch on error_code, not on the human detail.
Tutorial 2 â Cancel That OrderÂļ
The CancelOrder struct only carries owner and orderId (the composite uint256, not the UUID string). You stored both at placement time â use them now.
def cancel_order(order_id: str, uuid_int: int) -> None:
signable = encode_typed_data(
DOMAIN, CANCEL_ORDER_TYPES,
{"owner": OWNER, "orderId": uuid_int},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
r = requests.post(
f"{API}/orders/cancel",
headers={**AUTH, "Content-Type": "application/json"},
data=json.dumps({
"owner_address": OWNER,
"order_id": order_id,
"uuid_int": str(uuid_int),
"signature": "0x" + signature.lstrip("0x"),
}),
timeout=10,
)
if r.status_code == 429:
raise RuntimeError("cancel cooldown â wait and retry")
r.raise_for_status()
cancel_order(placed["order_id"], int(placed["uuid_int"]))
async function cancelOrder(orderId: string, uuidInt: bigint): Promise<void> {
const signature = await wallet.signTypedData(
DOMAIN, CANCEL_ORDER_TYPES,
{ owner: OWNER, orderId: uuidInt },
);
const r = await fetch(`${API}/orders/cancel`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: OWNER,
order_id: orderId,
uuid_int: uuidInt.toString(),
signature,
}),
});
if (r.status === 429) throw new Error("cancel cooldown â wait and retry");
if (!r.ok) throw new Error(`cancel failed: ${r.status}`);
}
await cancelOrder(placed.order_id, BigInt(placed.uuid_int));
Per-order cancel cooldown is 5 minutes â hitting it returns 429. If you ever lose uuid_int, fetch the order: GET /orders/{order_id} returns it in the response body.
Tutorial 3 â Automate Two-Sided Quoting on One PairÂļ
This is the loop:
loop:
mid â get_mid("EURC/USDC") # your rate source
if first_iteration or |mid â last_mid| / last_mid > drift_bps:
cancel(open_bid, open_ask) # any stale orders
bid_px â mid * (1 â spread_bps/1e4)
ask_px â mid * (1 + spread_bps/1e4)
open_bid â place_order("bid", EURC, USDC, qty, bid_px, ...)
open_ask â place_order("ask", EURC, USDC, qty, ask_px, ...)
last_mid â mid
sleep(poll_seconds)
Three knobs:
spread_bpsâ half-spread on each side. 5 bps each side = 10 bps round-trip.drift_bpsâ only requote when the mid has moved by more than this. Stops you from churning cancels when the rate jitters within a tick.poll_secondsâ how often you wake up. 1â5 s is typical.
The Rate StubÂļ
You bring the rate source. The integration point is one function:
def get_mid(pair: str) -> Decimal:
"""Return the current mid price for `pair`.
Implement against your own data feed â internal pricing service,
Bloomberg, an aggregator REST API, etc. The output must be a
Decimal price expressed as quote-per-base (e.g. EURC/USDC = 1.085).
"""
raise NotImplementedError("Wire up your pricing feed here.")
The LoopÂļ
SPREAD_BPS = Decimal("5") # 5 bps either side
DRIFT_BPS = Decimal("3") # requote only when mid moves > 3 bps
POLL_S = 2.0
QTY = Decimal("1000") # base units per side
open_bid = open_ask = None
last_mid = None
while True:
try:
mid = get_mid("EURC/USDC")
if last_mid is not None and \
abs(mid - last_mid) / last_mid * 10_000 <= DRIFT_BPS:
time.sleep(POLL_S); continue
# Cancel-first, then place â avoids self-match across the requote.
if open_bid: cancel_order(**open_bid)
if open_ask: cancel_order(**open_ask)
open_bid = open_ask = None
half = SPREAD_BPS / Decimal(10_000)
bid_px = (mid * (Decimal(1) - half)).quantize(Decimal("0.0001"))
ask_px = (mid * (Decimal(1) + half)).quantize(Decimal("0.0001"))
expiry = int(time.time()) + 3600
placed_bid = place_order("bid", EURC, USDC, QTY, bid_px, expiry)
placed_ask = place_order("ask", EURC, USDC, QTY, ask_px, expiry)
open_bid = {"order_id": placed_bid["order_id"], "uuid_int": int(placed_bid["uuid_int"])}
open_ask = {"order_id": placed_ask["order_id"], "uuid_int": int(placed_ask["uuid_int"])}
last_mid = mid
except RuntimeError as e:
# Surfaces our typed error_code: INSUFFICIENT_EQUITY, STP_BLOCKED, âĻ
print("place/cancel error:", e)
except requests.RequestException as e:
print("network error:", e)
time.sleep(POLL_S)
const SPREAD_BPS = 5; // each side
const DRIFT_BPS = 3;
const POLL_MS = 2_000;
const QTY = "1000"; // base units per side
type Live = { order_id: string; uuid_int: bigint } | null;
let openBid: Live = null, openAsk: Live = null, lastMid: number | null = null;
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
while (true) {
try {
const mid = await getMid("EURC/USDC");
if (lastMid !== null &&
Math.abs(mid - lastMid) / lastMid * 10_000 <= DRIFT_BPS) {
await sleep(POLL_MS); continue;
}
if (openBid) await cancelOrder(openBid.order_id, openBid.uuid_int);
if (openAsk) await cancelOrder(openAsk.order_id, openAsk.uuid_int);
openBid = openAsk = null;
const half = SPREAD_BPS / 10_000;
const bidPx = (mid * (1 - half)).toFixed(4);
const askPx = (mid * (1 + half)).toFixed(4);
const expiry = Math.floor(Date.now() / 1000) + 3600;
const pb = await placeOrder("bid", EURC, USDC, QTY, bidPx, expiry);
const pa = await placeOrder("ask", EURC, USDC, QTY, askPx, expiry);
openBid = { order_id: pb.order_id, uuid_int: BigInt(pb.uuid_int) };
openAsk = { order_id: pa.order_id, uuid_int: BigInt(pa.uuid_int) };
lastMid = mid;
} catch (e) {
console.error("loop error:", e);
}
await sleep(POLL_MS);
}
A few behaviours worth knowing before this hits production:
- Cancel first, then place. If you place fresh quotes before cancelling stale ones, your new bid can cross your stale ask â the API rejects with
STP_BLOCKED. The loop above cancels both legs before placing. INSUFFICIENT_EQUITYis your friend. If you see it, you're trying to freeze more vault balance than you have. ReduceQTY, fund more, or trim the spread.- Cancel cooldown is per
order_id. It only matters if you try to cancel the same order twice. The loop above places a neworder_idevery requote, so each cancel hits a different cooldown bucket. - Reconcile before clearing local state.
cancel_orderreturning 200 means the matching engine accepted the cancel; chain settlement of any in-flight fill is asynchronous. If you track inventory, watchsettlement_summaryviaGET /orders/{id}until it terminalises before zeroing your position state.
Tutorial 4 â Multi-Pair Quoting With a Virtual Liquidity BatchÂļ
The single-pair loop above freezes one slot of USDC for the bid and one slot of EURC for the ask. If you want to quote N pairs from one collateral pool, you place a VL batch: every leg shares the same group_id, and the matching engine freezes only the largest single-leg cost (not the sum).
Use the same loop skeleton; replace the per-pair placeOrder with a batch call:
def place_vl_batch(legs: list[dict]) -> list[dict]:
"""legs: list of {side, base, quote, amount, price, expiration}."""
leg_0_uuid = new_order_id() # source of group_id
signed_legs = []
for i, leg in enumerate(legs):
order_id = leg_0_uuid if i == 0 else new_order_id()
uuid_int = compose_uuid(order_id, executor_id=0,
leg_id=i, group_uuid=leg_0_uuid)
base, quote = leg["base"], leg["quote"]
base_raw = raw(leg["amount"], base["decimals"])
quote_raw = raw(leg["amount"] * leg["price"], quote["decimals"])
if leg["side"] == "bid":
ft, tt, fa, ta = quote["address"], base["address"], quote_raw, base_raw
else:
ft, tt, fa, ta = base["address"], quote["address"], base_raw, quote_raw
struct = {
"user": OWNER, "expiration": leg["expiration"],
"feeBps": 0,
"recipient": "0x0000000000000000000000000000000000000000",
"fromToken": ft, "toToken": tt,
"fromAmount": fa, "toAmount": ta,
"initialDepositAmount": 0, "uuid": uuid_int,
}
signable = encode_typed_data(DOMAIN, ORDER_TYPES, struct)
sig = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signed_legs.append({
"owner_address": OWNER,
"side": leg["side"],
"amount": str(leg["amount"]),
"price": str(leg["price"]),
"order_type": "limit",
"from_address": base["address"],
"to_address": quote["address"],
"order_id": order_id,
"uuid_int": str(uuid_int),
"signature": "0x" + sig.lstrip("0x"),
"expiration": leg["expiration"],
})
r = requests.post(
f"{API}/orders/vl/batch",
headers={**AUTH, "Content-Type": "application/json"},
data=json.dumps({"orders": signed_legs}),
timeout=10,
)
r.raise_for_status()
return r.json()["order_ids"]
async function placeVlBatch(legs: {
side: "bid" | "ask";
base: { address: string; decimals: number };
quote: { address: string; decimals: number };
amount: string;
price: string;
expiration: number;
}[]): Promise<string[]> {
const leg0Uuid = newOrderId(); // source of group_id
const signedLegs: any[] = [];
for (let i = 0; i < legs.length; i++) {
const leg = legs[i];
const orderId = i === 0 ? leg0Uuid : newOrderId();
const uuidInt = composeUuid(orderId, 0n, BigInt(i), leg0Uuid);
const baseRaw = raw(leg.amount, leg.base.decimals);
const quoteRaw = raw((Number(leg.amount) * Number(leg.price)).toString(),
leg.quote.decimals);
const isBid = leg.side === "bid";
const ft = isBid ? leg.quote.address : leg.base.address;
const tt = isBid ? leg.base.address : leg.quote.address;
const fa = isBid ? quoteRaw : baseRaw;
const ta = isBid ? baseRaw : quoteRaw;
const struct = {
user: OWNER, expiration: leg.expiration, feeBps: 0,
recipient: "0x0000000000000000000000000000000000000000",
fromToken: ft, toToken: tt, fromAmount: fa, toAmount: ta,
initialDepositAmount: 0n, uuid: uuidInt,
};
const signature = await wallet.signTypedData(DOMAIN, ORDER_TYPES, struct);
signedLegs.push({
owner_address: OWNER,
side: leg.side, amount: leg.amount, price: leg.price,
order_type: "limit",
from_address: leg.base.address,
to_address: leg.quote.address,
order_id: orderId,
uuid_int: uuidInt.toString(),
signature, expiration: leg.expiration,
});
}
const r = await fetch(`${API}/orders/vl/batch`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify({ orders: signedLegs }),
});
if (!r.ok) throw new Error(`VL batch failed: ${r.status}`);
const body = await r.json();
return body.order_ids as string[];
}
Wrap a quoting loop around it: pull a mid for each pair, compute bid+ask per leg, cancel the previous batch via POST /orders/vl/cancel (signature over CancelVLBatch{owner, vlBatchId}), submit a fresh one. Query GET /config â limits.vl_batch once at startup for the current batch-size cap.
Practical NotesÂļ
- Persist
order_idanduuid_inttogether. Every cancel needs both. Keying your local state onorder_idonly and recomputinguuid_intfrom scratch is fine; storing them together is just less error-prone. - Idempotency. Picking
order_idclient-side makesPOST /ordersidempotent on it. Network blip after submit? Re-send the same payload â the server dedupes. - Don't hard-code
limits.vl_batch.max. Read it fromGET /configat startup; treat it as the source of truth. - Watch
settlement_summarybefore clearing inventory state. A200onPOST /orders/cancelor apending â cancelledtransition does not mean every in-flight match has resolved on chain. PollGET /orders/{id}untilsettlement_summary.statusterminalises.
Next StepsÂļ
- Authentication â minting and revoking API keys.
- Order Endpoints â full request/response reference, every field, every error code.
- Account Endpoints â deposit into the Vault, withdraw, query balances.
- Virtual Liquidity â the budget model behind VL batches.
- Order Types â limit, FOK, IOC, post-only semantics.