Market Maker GuideÂļ
Network & Compatibility
| Resource | Value |
|---|---|
| API base URL | https://api.testnet.sera.cx/api/v1 |
| Chain | Sepolia (chainId 11155111) |
| Sera contract | 0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e |
| Vault contract | 0x3c7945840bAE0d7e7f3824Ebccef1962629250F0 |
| SOR contract | 0x83c1368110B640A729f3810De5FBe94b99aa5668 |
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, EURT, and most 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 Sepolia and an API key in hand.
Need testnet tokens?
Mint any registered testnet stablecoin (USDC, EURC, GBPA, XSGD, âĻ) for free from the Sera testnet faucet on Sepolia: 0x4FAc3BB8B77547E2Da7ed903baDBeD2f46cBe65a. The full list of mintable tokens is in Supported Currency Pairs.
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.testnet.sera.cx/api/v1"
DOMAIN = {
"name": "Sera",
"version": "1",
"chainId": 11155111, # Sepolia
"verifyingContract": "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
}
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.testnet.sera.cx/api/v1";
const DOMAIN: TypedDataDomain = {
name: "Sera",
version: "1",
chainId: 11155111, // Sepolia
verifyingContract: "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
};
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 Sepolia testnet 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.