Skip to content

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:

  1. Place a single limit order manually, end-to-end.
  2. Cancel that order.
  3. 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ยถ

pip install eth-account requests
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}"}
npm i ethers@6
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:

curl -H "$AUTH" "$API/orders?owner_address=$OWNER&status=pending&limit=10"

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.")
async function getMid(pair: string): Promise<number> {
  // Wire up your pricing feed here โ€” internal service, Bloomberg,
  // Refinitiv, an aggregator REST API, etc. Return quote-per-base
  // (e.g. EURC/USDC = 1.085).
  throw new Error("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_EQUITY is your friend. If you see it, you're trying to freeze more vault balance than you have. Reduce QTY, 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_order returning 200 means the matching engine accepted the cancel; chain settlement of any in-flight fill is asynchronous. If you track inventory, watch settlement_summary via GET /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 from GET /config at startup; treat it as the source of truth.
  • Watch settlement_summary before clearing inventory state. A 200 on POST /orders/cancel or a pending โ†’ cancelled transition does not mean every in-flight match has resolved on chain. Poll GET /orders/{id} until settlement_summary.status terminalises.

Next Stepsยถ

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.