跳转至

做市商指南

本指南从端到端走完 Sera 的订单生命周期:身份验证、向 Vault 充值、下达单笔与多对限价单、监听成交,以及撤单。本页引用的所有端点都在 Endpoints 下有详细文档 — 本页将它们串联成可运行的流程。

文中的请求示例指向公开测试网 https://api.sera.cx/api/v1。主网形态相同;仅 EIP-712 的 chainIdverifyingContract 不同。

适用对象

您正在运行一个自动化策略:

  • 在一个或多个稳定币交易对上挂出限价单(买单和/或卖单)。
  • 在行情变化时需要快速修改或撤单。
  • 希望在多个相关交易对(USDC/EURC、USDC/EURT、…)之间获得资金效率。

如果您主要是从钱包发起一次性兑换,请改用 Swaps 端点。

前置条件

要求 说明
以太坊钱包 持有代币、签署 EIP-712 消息。常见库(ethers、viem、web3py)只需一个 signer 对象即可。
ETH 作为 Gas 用于 depositwithdraw 交易,以及成交的链上结算。
API key 与 secret 用于查询与辅助接口的只读凭证 — 通过签署 ManageApiKey 消息创建。详见 身份验证
代币注册表快照 启动时从 GET /tokens 解析 from_token / to_token,并定期刷新。
当前限制 进程启动时读取一次 GET /config;不要硬编码上限。

步骤 1 — 启动准备

// 1. 启动时一次性解析协议上限与可用代币注册表。
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;   // 参见 GET /config → limits.vl_batch
const tokenByAddress = new Map(tokens.map(t => [t.address.toLowerCase(), t]));

limits.vl_batch.{min,max} 定义了一个 Virtual Liquidity 批次的合法大小。请始终从 GET /config 读取上限,而不要在 bot 中写死数值。

步骤 2 — 创建 API Key

API key 是用于查询的只读凭证;交易型变更仍需对每笔订单进行 EIP-712 签名。通过签署 ManageApiKey 创建:

const domain = {
  name: 'Sera',
  version: '1',
  chainId: 11155111,                              // Sepolia;主网使用 1
  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: '做市商机器人',
    }),
  },
).then(r => r.json());

// 立即持久化 api_secret — 它只返回一次。
const authHeader = `Bearer ${api_key}:${api_secret}`;

列表/撤销流程与 5 分钟时间戳窗口请参阅 身份验证

步骤 3 — 向 Vault 充值

限价单从您的 Vault 余额(而非钱包余额)结算。在挂单前先将花费代币(每笔订单的 from_token)充入 Vault:

// 让 API 构建一笔充值交易。
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',   // 原始单位(USDC:6 位小数 → 1,000 USDC)
  }),
}).then(r => r.json());

// 用您的钱包/signer 签名并广播返回的交易。
const sent = await signer.sendTransaction(tx);
await sent.wait();

下单前确认余额已到账:

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 是已被挂单锁定的部分。容量规划以 vault_available 为准。

步骤 4 — 下达单笔限价单

每笔订单都是一个绑定到 uuid_int(从客户端 order_id 派生的 uint256)的已签名 EIP-712 Order 消息。流程:

import { v4 as uuidv4 } from 'uuid';
import { uuidToInt } from './uuid-utils';   // 见下文 "uuid_int"

const orderId = uuidv4();
const uuidInt = uuidToInt(orderId);           // 256 位数值形式
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,                 // 市场基础币(如 EURC)
  toToken:    quoteTokenAddress,                // 市场计价币(如 USDC)
  amount:     parseUnits('1000', baseDecimals), // 基础币侧数量
  price:      parseUnits('1.085', priceScale),  // 每单位基础币的计价币数量
  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();
  // 在 detail.error_code 上分支 — 见下方错误表。
  throw new Error(`${detail.error_code}: ${detail.detail}`);
}
const { order_id } = await response.json();

成功响应表示订单已进入撮合引擎。在后续 GET /orders 中可看到挂单,任何即时成交反映在 settlement_summary 中。完整字段定义在 订单端点 — 下达限价单

uuid_int

uuid_intorder_id 的复合 uint256 形式 — 撮合引擎对这个数值签名,而非 UUID 字符串。从 UUID4 一次性计算:

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

每次下单/撤单都必须同时提交 order_id(人类可读形式)与 uuid_int(数值形式)。

错误信封

失败的 POST /orders 返回一个有类型的信封:

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

请基于 error_code 分支,不要基于人类 detail。完整表格位于 订单端点 — 错误信封

步骤 5 — 下达多对批次(虚拟流动性)

虚拟流动性(VL)批次同时挂出 N 个不同市场,但只冻结最大单腿成本,而非总和。例如可以同时挂 EURC/USDC 与 EURT/USDC 而无需翻倍抵押。

每个子订单与独立的 POST /orders 请求签法一致,加上共享的 VL group_id 与一个嵌入到 uuid_int 中的顺序 leg_id。API 会原子地提交它们:

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(...),                 // 每个 leg 的 EIP-712 Order 签名
  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());

需注意的校验规则(全部由服务端强制执行;见 下达 VL 批次):

规则 说明
批次大小 2 到 50 个子订单。请查询 GET /config → limits.vl_batch 获取当前上限。
同一所有者 每个 leg 共用同一 owner_address
同一花费代币 所有子订单都解析到同一 fromToken
唯一市场 重复或反向交易对会被拒绝。
共享分组 每个 leg 的 uuid_int 必须编码同一 group_id
顺序 leg leg_id 必须按提交顺序为 0, 1, 2, …

响应区分三种 per-leg 状态 — 它们都是 成功,而非拒绝:

  • order_ids — 实际下单的每个 leg,按提交顺序排列。
  • amendments — 引擎为适配共享预算而缩小的 legs(用户对 original_amount 签名,但订单以 actual_amount 挂出,reason: "budget_clip")。
  • cancelled — 因预算已被更高优先级的兄弟订单耗尽而被丢弃的 legs(reason: "quota_exceeded")。
  • fills — 下单时立刻成交的 legs(每个 leg 一条;一个 leg 穿过 N 个挂单产生 N 条 trades[])。
  • vl_group{ max_budget, budget_consumed, spent_token }。后续成交从 max_budget − budget_consumed 中扣减。

当整批干净挂出时,amendments / cancelled / fills 为空,order_ids 与您提交的一一对应。

步骤 6 — 监控挂单与成交

轮询节奏:多数做市商 bot 每 1–5 秒对账一次。GET /orders 的形态(分页,可按 statussymbolside、范围筛选)记录在 订单列表

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());

需要关心的订单状态:

  • pending — 挂在订单簿上,可能已部分成交。
  • matched — 引擎已撮合每个 leg;等待链上结算。
  • settled — 链上确认的终态成功。
  • failed — 链上确认的回滚,或预广播失败。
  • cancelled — 终态撤单。

对每笔成交的细节,GET /fills/{order_id} 返回已撮合的 trades,包括结算状态与对手方订单 ID。

步骤 7 — 撤单

单笔撤单:

const cancelTypes = {
  CancelOrder: [
    { name: 'owner',    type: 'address' },
    { name: 'orderId',  type: 'uint256' },
  ],
};
const signature = await signer.signTypedData(domain, cancelTypes, {
  owner:   walletAddress,
  orderId: uuidInt,        // 复合 uint256,不是 UUID 字符串
});

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,
  }),
});

碰到撤单冷却(每笔订单默认 5 分钟)会返回 429 — 请退避,不要密集重试。如果丢失了 uuid_int,先取回订单;它会出现在每个订单状态响应中。

一次性撤销整个 VL 批次:

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,   // 批次中任一 leg 的 id
    signature: vlCancelSig,
  }),
});

全部挂单一并撤销:

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, '已撤单,', result.skipped_cooldown.length, '冷却中');

cancel-all 是批量紧急按钮 — 服务端循环执行,遵守同样的每订单冷却。

步骤 8 — 提款

要把抵押品提回钱包,请签署 InstantWithdraw 并提交。完整 payload 记录在 账户端点 — 双签提款

最佳实践

  • 幂等性 — 在客户端选择 order_id(UUID4),并将 API 调用视为对它幂等。网络抖动后用同一个 order_id 重试是安全的。
  • 保持 uuid_int 的索引 — 撤单和大多数对账都需要它。下单时连同 order_id 一并存储。
  • 预先做余额检查 — 提交前调用 GET /balances,确认 vault_available ≥ frozen_amount(order);否则 API 会拒绝(INSUFFICIENT_EQUITY)。
  • 定期刷新 /configlimits.vl_batch.max(以及将来的上限)可能不另行通知地变化。至少每天读一次。
  • 关注 settlement_summarymatched 不是 终态。已撮合的订单仍可能出现回滚;在清理状态前请基于 latest_failed_fill_failure_reason(显示)与 error_code(逻辑)对账。
  • 撤单冷却以每个订单为单位 — 频繁闪报的 bot 需要规划库存,避免反复撤同一个 order_id

下一步