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.
Before placing limit orders, create an API key and fund the Vault with the token each side spends: bids spend the market quote token, asks spend the base token. Read that orientation from GET /markets; a wallet USDC balance alone is not sufficient. See account funding and check vault_spendable before quoting.
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 pathlib import Path
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";
import { mkdirSync, writeFileSync } from "node:fs";
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.
health_response = requests.get(f"{API}/health", timeout=10)
health_response.raise_for_status()
EXECUTOR_ID = int(health_response.json()["executor_id"])
def compose_uuid(order_id: str, executor_id: int = EXECUTOR_ID,
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())
const healthResponse = await fetch(`${API}/health`);
if (!healthResponse.ok) throw new Error(`health: ${healthResponse.status}`);
const EXECUTOR_ID = BigInt((await healthResponse.json()).executor_id);
function composeUuid(
orderId: string,
executorId: bigint = EXECUTOR_ID,
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ยถ
Select an active USDC/EURC market from GET /markets and keep its returned base/quote orientation. A bid buys the returned base using the quote token. The example price 1 is illustrative, in quote per base; choose a price for that orientation. Reversing a pair also changes side, quantity and price semantics.
response = requests.get(f"{API}/markets", timeout=10)
response.raise_for_status()
MARKET = next(m for m in response.json()["markets"]
if {m["base_symbol"], m["quote_symbol"]} == {"USDC", "EURC"})
BASE = {"address": MARKET["base_address"], "decimals": MARKET["base_decimals"]}
QUOTE = {"address": MARKET["quote_address"], "decimals": MARKET["quote_decimals"]}
const marketsResponse = await fetch(`${API}/markets`);
if (!marketsResponse.ok) throw new Error(`markets: ${marketsResponse.status}`);
const MARKET = (await marketsResponse.json()).markets.find((m: any) =>
[m.base_symbol, m.quote_symbol].sort().join("/") === "EURC/USDC");
if (!MARKET) throw new Error("No active USDC/EURC market");
const BASE = { address: MARKET.base_address, decimals: MARKET.base_decimals };
const QUOTE = { address: MARKET.quote_address, decimals: MARKET.quote_decimals };
The wire request uses from_address=base and to_address=quote; side determines the spent token. Preview supplies canonical values and the exact raw EIP-712 message. Sign it, persist the request, then submit.
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) # deployment executor ID
preview_request = {
"owner_address": OWNER, "side": side,
"amount": format(amount, "f"), "price": format(price, "f"),
"order_type": "limit", "from_address": base["address"],
"to_address": quote["address"], "order_id": order_id,
"uuid_int": str(uuid_int), "expiration": expiration,
}
response = requests.post(f"{API}/orders/preview", json=preview_request, timeout=10)
response.raise_for_status()
preview = response.json()
signable = encode_typed_data(DOMAIN, preview["eip712_types"], preview["eip712_order"])
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
wire = {
"owner_address": OWNER,
"side": side,
"amount": preview["normalized_amount"],
"price": preview["normalized_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.removeprefix("0x"),
"expiration": expiration,
}
journal = Path("order-requests")
journal.mkdir(mode=0o700, exist_ok=True)
saved_path = journal / f"{order_id}.json"
with saved_path.open("x", encoding="utf-8") as saved:
json.dump(wire, saved)
print(f"Saved order request: {saved_path}")
r = requests.post(f"{API}/orders", headers={**AUTH, "Content-Type": "application/json"},
data=json.dumps(wire), timeout=10)
if r.status_code != 201:
detail = r.json().get("detail")
raise RuntimeError(f"HTTP {r.status_code}, order {order_id}: {detail}")
return {"order_id": order_id, "uuid_int": str(uuid_int)}
placed = place_order("bid", BASE, QUOTE, Decimal("1000"),
Decimal("1"), int(time.time()) + 3600)
print("placed:", placed)
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); // deployment executor ID
const previewResponse = await fetch(`${API}/orders/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: OWNER, side, amount, price, order_type: "limit",
from_address: base.address, to_address: quote.address,
order_id: orderId, uuid_int: uuidInt.toString(), expiration,
}),
});
if (!previewResponse.ok) throw new Error(JSON.stringify(await previewResponse.json()));
const preview = await previewResponse.json();
const signature = await wallet.signTypedData(DOMAIN, preview.eip712_types, preview.eip712_order);
const wire = {
owner_address: OWNER,
side, amount: preview.normalized_amount, price: preview.normalized_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,
};
mkdirSync("order-requests", { recursive: true, mode: 0o700 });
const savedPath = `order-requests/${orderId}.json`;
writeFileSync(savedPath, JSON.stringify(wire), { flag: "wx", mode: 0o600 });
console.log(`Saved order request: ${savedPath}`);
const r = await fetch(`${API}/orders`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify(wire),
});
if (r.status !== 201) {
const detail = (await r.json()).detail;
throw new Error(`HTTP ${r.status}, order ${orderId}: ${JSON.stringify(detail)}`);
}
return { order_id: orderId, uuid_int: uuidInt.toString() };
}
const placed = await placeOrder("bid", BASE, QUOTE, "1000", "1",
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.removeprefix("0x"),
}),
timeout=10,
)
if r.status_code == 429:
raise RuntimeError("cancel rejected (cooldown or rate limit) โ inspect response and retry later")
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 rejected (cooldown or rate limit) โ inspect response and retry later");
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(MARKET["symbol"]) # 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", BASE, QUOTE, qty, bid_px, ...)
open_ask โ place_order("ask", BASE, QUOTE, 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(MARKET["symbol"])
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", BASE, QUOTE, QTY, bid_px, expiry)
open_bid = {"order_id": placed_bid["order_id"], "uuid_int": int(placed_bid["uuid_int"])}
placed_ask = place_order("ask", BASE, QUOTE, QTY, ask_px, expiry)
open_ask = {"order_id": placed_ask["order_id"], "uuid_int": int(placed_ask["uuid_int"])}
last_mid = mid
except RuntimeError as e:
# HTTP rejection, including typed business errors and string details.
print("place/cancel error; reconcile saved orders before restarting:", e)
break
except requests.RequestException as e:
print("network error; reconcile saved orders before restarting:", e)
break
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(MARKET.symbol);
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", BASE, QUOTE, QTY, bidPx, expiry);
openBid = { order_id: pb.order_id, uuid_int: BigInt(pb.uuid_int) };
const pa = await placeOrder("ask", BASE, QUOTE, QTY, askPx, expiry);
openAsk = { order_id: pa.order_id, uuid_int: BigInt(pa.uuid_int) };
lastMid = mid;
} catch (e) {
console.error("loop paused; reconcile saved orders before restarting:", e);
break;
}
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 starts at placement. A new ID is not an exemption. Handle 429 and wait until the cooldown expires. The current implementation has no per-account exemption.
- 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.
Recover before restartingยถ
The single-order helpers use a local order-requests/ directory (the TypeScript version runs in Node.js). Each request file is written before sending the order, without API credentials or the private key. Keep this directory private and retain the files until reconciliation. Record each successful side immediately; the two calls are not an atomic pair.
On any place/cancel error the loop stops. Before restarting, load the saved order_id, uuid_int and signed body, then query GET /orders/{order_id} with the owner's API Key. A single 404 after an unknown submission is not proof that no order was accepted; allow propagation and reconcile before deciding to resend the same body. DUPLICATE_ORDER_ID requires reading the existing order, not generating a new ID. Resume only after every uncertain request and any remaining open order has been reconciled. Do not reset the local state and blindly rerun the loop; this tutorial does not implement automatic restart recovery.
Tutorial 4 โ Multi-Pair Quoting With a Virtual Liquidity Batchยถ
The single-pair loop reserves quote tokens for bids and base tokens for asks in off-chain accounting. In a VL batch, all legs spend the same token across distinct markets and share a group_id; the initial reservation is 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]) -> 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=EXECUTOR_ID,
leg_id=i, group_uuid=leg_0_uuid)
base, quote = leg["base"], leg["quote"]
response = requests.post(f"{API}/orders/preview", json={
"owner_address": OWNER, "side": leg["side"],
"amount": format(leg["amount"], "f"), "price": format(leg["price"], "f"),
"order_type": "limit", "from_address": base["address"],
"to_address": quote["address"], "order_id": order_id,
"uuid_int": str(compose_uuid(order_id)), "expiration": leg["expiration"],
}, timeout=10)
response.raise_for_status()
preview = response.json()
order_struct = {**preview["eip712_order"], "uuid": str(uuid_int)}
signable = encode_typed_data(DOMAIN, preview["eip712_types"], order_struct)
sig = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signed_legs.append({
"owner_address": OWNER,
"side": leg["side"],
"amount": preview["normalized_amount"],
"price": preview["normalized_price"],
"order_type": "limit",
"from_address": base["address"],
"to_address": quote["address"],
"order_id": order_id,
"uuid_int": str(uuid_int),
"signature": "0x" + sig.removeprefix("0x"),
"expiration": leg["expiration"],
})
wire = {"orders": signed_legs}
journal = Path("vl-requests")
journal.mkdir(mode=0o700, exist_ok=True)
saved_path = journal / f"{leg_0_uuid}.json"
with saved_path.open("x", encoding="utf-8") as saved:
json.dump(wire, saved)
print(f"Saved VL request: {saved_path}")
r = requests.post(
f"{API}/orders/vl/batch",
headers={**AUTH, "Content-Type": "application/json"},
data=json.dumps(wire),
timeout=10,
)
r.raise_for_status()
return r.json()
async function placeVlBatch(legs: {
side: "bid" | "ask";
base: { address: string; decimals: number };
quote: { address: string; decimals: number };
amount: string;
price: string;
expiration: number;
}[]): Promise<{ order_ids: string[]; [key: string]: unknown }> {
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, EXECUTOR_ID, BigInt(i), leg0Uuid);
const previewResponse = await fetch(`${API}/orders/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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: composeUuid(orderId).toString(), expiration: leg.expiration,
}),
});
if (!previewResponse.ok) throw new Error(JSON.stringify(await previewResponse.json()));
const preview = await previewResponse.json();
const orderStruct = { ...preview.eip712_order, uuid: uuidInt.toString() };
const signature = await wallet.signTypedData(DOMAIN, preview.eip712_types, orderStruct);
signedLegs.push({
owner_address: OWNER,
side: leg.side, amount: preview.normalized_amount, price: preview.normalized_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 wire = { orders: signedLegs };
mkdirSync("vl-requests", { recursive: true, mode: 0o700 });
const savedPath = `vl-requests/${leg0Uuid}.json`;
writeFileSync(savedPath, JSON.stringify(wire), { flag: "wx", mode: 0o600 });
console.log(`Saved VL request: ${savedPath}`);
const r = await fetch(`${API}/orders/vl/batch`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify(wire),
});
if (!r.ok) throw new Error(`VL batch failed: ${r.status}`);
const body = await r.json();
return body;
}
For each distinct market, choose the side that spends the shared collateral token and price it in that marketโs returned orientation. Cancel the previous group using POST /orders/vl/cancel, then reconcile orders and settlements before replacing the batch. Read the active cap from GET /config โ limits.vl_batch.
VL requests are saved under vl-requests/ before submission. On a timeout or error, stop the loop and reconcile every saved leg using its original ID; do not call the generating helper again as a retry. Resend the saved body only after reconciliation. The full response includes outcomes and possibly pending budget evidence; use the VL result rules. Keep saved files private until reconciliation.
Practical Notesยถ
- Persist every signed identity. Retain
order_id,uuid_int, the VL group/leg identity and the full signed body. A VL leg UUID cannot be reconstructed from its order ID alone. Query the stored order if the local identity is lost. - Preserve submission identity. Persist the order ID and exact signed body before sending. After a timeout, query that ID first; a duplicate submission can return
DUPLICATE_ORDER_ID, not a fresh success response. Do not replace an unresolved order with a new ID. - 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.
The examples use order preview for canonical amounts and signing fields. Preview does not reserve funds or bypass final order/VL validation. Load the current signing domain and executor ID from /config and /health; sample deployment constants are not permanent identifiers.
For VL legs, /orders/preview accepts only a standalone UUID (leg_id=0, group derived from that order ID). The example previews each leg with that temporary UUID, then replaces only the returned Order uuid with the actual shared-group VL UUID before signing. Submit the normalized amount/price and the signed VL UUID together.