Skip to content

Market Maker GuideÂļ

This guide walks a market maker through Sera's order lifecycle end-to-end: authenticating, funding the Vault, placing single and multi-pair limit orders, monitoring fills, and cancelling. The endpoints referenced here are documented in detail under Endpoints — this page strings them together into a working flow.

Throughout, request snippets target the public testnet at https://api.sera.cx/api/v1. Mainnet uses the same shape; only the EIP-712 chainId and verifyingContract differ.

Who This Is ForÂļ

You are running an automated strategy that:

  • Places resting limit orders (bids and/or asks) on one or more stablecoin pairs.
  • Needs to amend or cancel quickly when the market moves.
  • Wants capital efficiency across multiple correlated pairs (USDC/EURC, USDC/EURT, â€Ļ).

If your flow is mostly one-shot swaps from a wallet, you want the Swaps endpoints instead.

PrerequisitesÂļ

Requirement Notes
Ethereum wallet Holds tokens, signs EIP-712 messages. A signer object suffices for off-the-shelf libraries (ethers, viem, web3py).
ETH for gas Needed for deposit and withdraw transactions, and for on-chain settlement of fills.
API key + secret Read-only credentials for queries and helpers — created by signing a ManageApiKey message. See Authentication.
Token registry snapshot Resolve from_token / to_token addresses from GET /tokens at startup and refresh periodically.
Active limits Read GET /config once per process; do not hard-code caps.

Step 1 — BootstrapÂļ

// 1. Resolve protocol caps and the active token registry once at startup.
const [{ limits }, { tokens }] = await Promise.all([
  fetch('https://api.sera.cx/api/v1/config').then(r => r.json()),
  fetch('https://api.sera.cx/api/v1/tokens').then(r => r.json()),
]);

const vlBatchMin = limits.vl_batch.min;
const vlBatchMax = limits.vl_batch.max;   // see GET /config → limits.vl_batch
const tokenByAddress = new Map(tokens.map(t => [t.address.toLowerCase(), t]));

limits.vl_batch.{min,max} defines the legal size of a Virtual Liquidity batch. Always read the cap from GET /config rather than baking a number into the bot.

Step 2 — Mint an API KeyÂļ

API keys are read-only credentials for queries; trading mutations still require an EIP-712 signature per order. Create one with a signed ManageApiKey payload:

const domain = {
  name: 'Sera',
  version: '1',
  chainId: 11155111,                              // Sepolia; use 1 on mainnet
  verifyingContract: '0xd0fc92d8eF9bE26D7288fCa1D6458f675e72B83a',
};
const types = {
  ManageApiKey: [
    { name: 'owner', type: 'address' },
    { name: 'action', type: 'string' },
    { name: 'timestamp', type: 'uint256' },
  ],
};

const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(domain, types, {
  owner: walletAddress,
  action: 'create',
  timestamp,
});

const { api_key, api_secret } = await fetch(
  'https://api.sera.cx/api/v1/api-keys',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      owner_address: walletAddress,
      action: 'create',
      timestamp,
      signature,
      label: 'Market-maker bot',
    }),
  },
).then(r => r.json());

// Persist api_secret immediately — it is returned exactly once.
const authHeader = `Bearer ${api_key}:${api_secret}`;

See Authentication for list/revoke flows and the 5-minute timestamp window.

Step 3 — Fund the VaultÂļ

Limit orders settle against your Vault balance, not your wallet. Deposit the spent token (the from_token of every order) before placing:

// Ask the API to build a deposit transaction.
const tx = await fetch('https://api.sera.cx/api/v1/deposit', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': authHeader,
  },
  body: JSON.stringify({
    owner_address: walletAddress,
    token: usdcAddress,
    amount: '1000000000',   // raw units (USDC: 6 decimals → 1,000 USDC)
  }),
}).then(r => r.json());

// Sign and broadcast the returned transaction with your wallet/signer.
const sent = await signer.sendTransaction(tx);
await sent.wait();

Confirm the balance landed before placing orders:

const { balances } = await fetch(
  `https://api.sera.cx/api/v1/balances?owner_address=${walletAddress}`,
  { headers: { 'Authorization': authHeader } },
).then(r => r.json());

for (const b of balances) {
  console.log(`${b.symbol}: vault=${b.vault_available}, frozen=${b.vault_frozen}`);
}

vault_frozen is the portion locked behind resting orders. Plan capacity off vault_available.

Step 4 — Place a Single Limit OrderÂļ

Every order is a signed EIP-712 Order message bound to a uuid_int (a uint256 derived from your client-side order_id). The recipe:

import { v4 as uuidv4 } from 'uuid';
import { uuidToInt } from './uuid-utils';   // see "uuid_int" below

const orderId = uuidv4();
const uuidInt = uuidToInt(orderId);           // 256-bit numeric form
const expiration = Math.floor(Date.now() / 1000) + 3600;

const orderTypes = {
  Order: [
    { name: 'owner',       type: 'address' },
    { name: 'fromToken',   type: 'address' },
    { name: 'toToken',     type: 'address' },
    { name: 'amount',      type: 'uint256' },
    { name: 'price',       type: 'uint256' },
    { name: 'side',        type: 'uint8'   },   // 0 = bid, 1 = ask
    { name: 'orderId',     type: 'uint256' },
    { name: 'expiration',  type: 'uint256' },
  ],
};

const message = {
  owner:      walletAddress,
  fromToken:  baseTokenAddress,                 // market base (e.g. EURC)
  toToken:    quoteTokenAddress,                // market quote (e.g. USDC)
  amount:     parseUnits('1000', baseDecimals), // base-side quantity
  price:      parseUnits('1.085', priceScale),  // quote-per-base, scaled
  side:       0,                                // bid
  orderId:    uuidInt,
  expiration,
};
const signature = await signer.signTypedData(domain, orderTypes, message);

const response = await fetch('https://api.sera.cx/api/v1/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': authHeader,
  },
  body: JSON.stringify({
    owner_address: walletAddress,
    side: 'bid',
    amount: '1000.0',
    price: '1.085',
    order_type: 'limit',
    from_address: baseTokenAddress,
    to_address: quoteTokenAddress,
    order_id: orderId,
    uuid_int: uuidInt.toString(),
    signature,
    expiration,
  }),
});

if (!response.ok) {
  const { detail } = await response.json();
  // Branch on detail.error_code — see the error table below.
  throw new Error(`${detail.error_code}: ${detail.detail}`);
}
const { order_id } = await response.json();

A successful response confirms the order is in the matching engine. Resting orders show up in GET /orders, and any immediate cross is reflected via the settlement_summary of subsequent fetches. Full request/response fields are in Order Endpoints — Place Limit Order.

uuid_intÂļ

uuid_int is the composite uint256 form of order_id — the matching engine signs against this, not the UUID string. Compute it once from the UUID4:

function uuidToInt(uuidStr) {
  return BigInt('0x' + uuidStr.replace(/-/g, ''));
}

Both order_id (the human form) and uuid_int (the numeric form) must accompany every place/cancel request.

Error EnvelopeÂļ

Failed POST /orders calls return a typed envelope:

{ "detail": { "detail": "Order placement failed", "error_code": "INSUFFICIENT_EQUITY" } }

Branch on error_code, not on the human detail. The full table is documented under Order Endpoints — Error Envelope.

Step 5 — Place a Multi-Pair Batch (Virtual Liquidity)Âļ

A Virtual Liquidity (VL) batch resting on N distinct markets freezes only the largest single-leg cost, not the sum. Useful for quoting EURC/USDC and EURT/USDC simultaneously without doubling your collateral.

Each sibling is signed exactly like a standalone POST /orders request, plus a shared VL group_id and a sequential leg_id baked into the uuid_int. The API then submits them atomically:

const groupId = uuidv4();

const siblings = pairs.map((pair, idx) => ({
  owner_address: walletAddress,
  side: 'bid',
  amount: '1000.0',
  price: pair.bid,
  order_type: 'limit',
  from_address: pair.base,
  to_address:   pair.quote,
  order_id: uuidv4(),
  uuid_int: composeVlUuidInt(groupId, idx).toString(),
  signature: signSibling(...),                 // per-leg EIP-712 Order signature
  expiration: Math.floor(Date.now() / 1000) + 3600,
}));

const result = await fetch('https://api.sera.cx/api/v1/orders/vl/batch', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Authorization': authHeader },
  body: JSON.stringify({ orders: siblings }),
}).then(r => r.json());

Validation rules to mind (all enforced server-side; see Place VL Batch):

Rule Notes
Batch size 2 to 50 siblings. Query GET /config → limits.vl_batch for the active cap.
Same owner One owner_address across every leg.
Same spent token All siblings resolve to the same fromToken.
Unique markets Duplicates and inverse pairs are rejected.
Shared group Every leg's uuid_int must encode the same group_id.
Sequential legs leg_id values must be 0, 1, 2, â€Ļ in submission order.

The response distinguishes three states per leg — they are all success, not rejection:

  • order_ids — every leg actually placed, in submission order.
  • amendments — legs the engine clipped down so the batch fits the shared budget (the user signed for original_amount; the order rests at actual_amount, reason: "budget_clip").
  • cancelled — legs dropped because the budget was exhausted by an earlier-priority sibling (reason: "quota_exceeded").
  • fills — legs that crossed immediately at placement (one entry per leg; a leg crossing N makers produces N trades[] entries).
  • vl_group — { max_budget, budget_consumed, spent_token }. Future fills draw from max_budget − budget_consumed.

When the whole batch rests cleanly, amendments / cancelled / fills are empty and order_ids matches what you submitted 1:1.

Step 6 — Monitor Open Orders and FillsÂļ

Polling cadence: most market-maker bots reconcile every 1–5 seconds. The shape of GET /orders (paginated, filterable on status, symbol, side, ranges) is documented under List Orders.

const { trades, total } = await fetch(
  `https://api.sera.cx/api/v1/orders?owner_address=${walletAddress}&status=pending&limit=500`,
  { headers: { 'Authorization': authHeader } },
).then(r => r.json());

The order statuses you care about:

  • pending — resting on the book, possibly partially filled.
  • matched — engine has crossed every leg; awaiting on-chain settlement.
  • settled — chain-confirmed terminal success.
  • failed — chain-confirmed revert, or a pre-broadcast failure.
  • cancelled — terminal cancel.

For per-fill detail, GET /fills/{order_id} returns the matched trades, including settlement status and counterparty order IDs.

Step 7 — CancelÂļ

One order:

const cancelTypes = {
  CancelOrder: [
    { name: 'owner',    type: 'address' },
    { name: 'orderId',  type: 'uint256' },
  ],
};
const signature = await signer.signTypedData(domain, cancelTypes, {
  owner:   walletAddress,
  orderId: uuidInt,        // composite uint256, not the UUID string
});

await fetch('https://api.sera.cx/api/v1/orders/cancel', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Authorization': authHeader },
  body: JSON.stringify({
    owner_address: walletAddress,
    order_id: orderId,
    uuid_int: uuidInt.toString(),
    signature,
  }),
});

Hitting the cancel cooldown (default 5 minutes per order) returns 429 — back off, do not retry tight. If you lost uuid_int, fetch the order first; it is returned in every order status response.

A whole VL batch in one shot:

await fetch('https://api.sera.cx/api/v1/orders/vl/cancel', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Authorization': authHeader },
  body: JSON.stringify({
    owner_address: walletAddress,
    vl_batch_id: primaryOrderId,   // any leg's id of the batch
    signature: vlCancelSig,
  }),
});

Everything you have resting:

const url = `https://api.sera.cx/api/v1/orders/cancel-all?owner_address=${walletAddress}`;
const result = await fetch(url, {
  method: 'DELETE',
  headers: { 'Authorization': authHeader },
}).then(r => r.json());

console.log(result.cancelled.length, 'cancelled,', result.skipped_cooldown.length, 'on cooldown');

cancel-all is the bulk panic button — it iterates server-side and respects the same per-order cooldown.

Step 8 — WithdrawÂļ

When you want to pull collateral back to your wallet, sign an InstantWithdraw and submit. The full payload is documented under Account Endpoints — Withdraw (co-signature).

Best PracticesÂļ

  • Idempotency — pick order_id (UUID4) on the client side and treat the API call as idempotent on it. Retrying a request with the same order_id after a network blip is safe.
  • Keep uuid_int indexed — every cancel and most reconciliation requires it. Store it alongside order_id at placement time.
  • Pre-flight balance checks — call GET /balances and confirm vault_available â‰Ĩ frozen_amount(order) before submitting; the API will reject otherwise (INSUFFICIENT_EQUITY).
  • Refresh /config periodically — limits.vl_batch.max (and future caps) may change without notice. Re-read at least daily.
  • Watch the settlement_summary — matched is not terminal. A matched order can still surface a revert; reconcile against latest_failed_fill_failure_reason (display) and error_code (logic) before clearing state.
  • Cancel cooldown is per order — bots that flicker quotes need to size their inventory so they are not constantly cancelling the same order_id.

Next StepsÂļ

  • Order Endpoints — full reference for every /orders route, fields, and error codes.
  • Authentication — API-key management and EIP-712 signing details.
  • Virtual Liquidity — conceptual primer on the VL budget model.
  • Order Types — limit, FOK, IOC, and post-only semantics.