跳轉至

做市商指南

網路與相容性

資源 值
API 基礎 URL https://api.testnet.sera.cx/api/v1
鏈 Sepolia(chainId 11155111)
Sera 合約 0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e
Vault 合約 0x3c7945840bAE0d7e7f3824Ebccef1962629250F0
SOR 合約 0x83c1368110B640A729f3810De5FBe94b99aa5668

簽名原語。 所有交易型變更都是針對 Sera domain 的 EIP-712 類型化資料簽名。走 permit 路徑的充值使用 ERC-2612 Permit 擴充功能 — USDC、EURC、EURT 以及多數現代穩定幣支援,但並非所有 ERC-20 都支援;簽名前請呼叫 GET /permit/metadata 檢查。API key 管理使用 EIP-712 ManageApiKey payload。

已驗證客戶端。 Python eth_account >= 0.10 + requests;TypeScript ethers v6(signer.signTypedData)。已驗證支援 EIP-712 類型化資料的瀏覽器錢包:MetaMask、Rabby、Frame、Coinbase Wallet、Trust、Rainbow。Safe 多簽透過 EIP-1271 支援(訊息於鏈上驗證,而非透過 ecrecover)。

地址大小寫。 讀取類端點(/balances、/orders、/fills)將 owner_address 視為大小寫敏感 — 請傳入小寫形式。EIP-712 簽名 payload 接受 EIP-55 校驗和地址。

這是你寫 bot 第一版時希望開著的頁面。按順序講三件事:

  1. 端到端手動下達一筆限價單。
  2. 撤銷剛才那筆單。
  3. 把兩者包裝成一個由你自己的報價來源驅動的自動掛單迴圈。

下限價單前,先創建 API Key,並向 Vault 充值對應方向支出的代幣:bid 支出市場 quote,ask 支出 base。方向以 GET /markets 為準;僅有錢包 USDC 餘額並不足夠。參見賬戶充值,報價前檢查 vault_spendable。

需要測試代幣?

登錄表中的任一穩定幣(USDC、EURC、GBPA、XSGD …)都可以從 Sepolia 上的 Sera 測試網水龍頭免費鑄造:0x4FAc3BB8B77547E2Da7ed903baDBeD2f46cBe65a。完整的可鑄造代幣清單見 支援的貨幣對。

程式碼片段在 Python 與 TypeScript 之間切換。挑一種就一直用 — 兩份實作是逐行等價的。

準備

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...你的錢包私鑰..."                                # 簽名者
API_KEY     = "sera_..."                                           # 見 身分驗證
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...你的錢包私鑰...";                         // 簽名者
const API_KEY     = "sera_...";                                    // 見 身分驗證
const API_SECRET  = "...";
const wallet      = new Wallet(PRIVATE_KEY);
const OWNER       = wallet.address;

const AUTH = { "Authorization": `Bearer ${API_KEY}:${API_SECRET}` };

EIP-712 的 Order 型別資料結構與鏈上的 SeraLib.ORDER_TYPEHASH 一一對應。定義一次:

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" },
  ],
};

uuid_int 位元布局

每筆訂單的 uuid 是一個打包後的 256 位元整數:

┌──────────┬───────────────────┬──────────────────┬──────────┐
│[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) │
└──────────┴───────────────────┴──────────────────┴──────────┘

單筆獨立訂單:group_id = order_id >> 16,leg_id = 0。VL 批次第 i 個 leg:所有 leg 共享同一個 group_id(取自 leg 0 的 UUID4),leg_id = i。任何其他取值都會被伺服器拒絕。

executor_id 依部署設定。Sepolia 測試網預設為 0,除非你的部署另外公告。啟動時讀一次後重複使用 — executor_id 漂移會讓所有已簽名但未結算的 UUID 失效。

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:
    """把 UUID4 字串打包成鏈上複合 uint256。"""
    oid    = int(uuid.UUID(order_id))
    gsrc   = int(uuid.UUID(group_uuid)) if group_uuid else oid
    gid    = gsrc >> 16                                            # 128 位元中的最高 112 位元
    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;                                       // 128 位元中的最高 112 位元
  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)}`;
}

教學 1 — 手動下達一筆限價單

從 GET /markets 選擇活躍的 USDC/EURC 市場,並保持返回的 base/quote 方向。bid 使用報價代幣買入基礎代幣。示例價格 1 僅用於演示,單位是 quote/base;請按該方向選擇價格。反向交易對也會改變 side、數量和價格的含義。

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

送往 POST /orders 的 wire payload 使用對的自然欄位(side、amount、price、from_address=base、to_address=quote)。EIP-712 訊息是鏈上 Order 結構 — 其中 fromToken 是你支出的幣(bid 時是 USDC、ask 時是 EURC),數量是原始單位。兩者都建立、簽名、送出:

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,不是 spend
        "to_address":    quote["address"],                          # quote,不是 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,不是 spend
    to_address:   quote.address,                                   // quote,不是 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);

確認訂單已掛出:

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

4xx 回傳的錯誤信封在 訂單端點 → 錯誤信封 中有說明。請依 error_code 分支,不要依人類 detail。

教學 2 — 撤銷剛才那筆單

CancelOrder 結構只攜帶 owner 與 orderId(複合 uint256,不是 UUID 字串)。下單時你已經把兩者都存起來了 — 現在派上用場。

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("撤單冷卻中 — 稍後重試")
    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("撤單冷卻中 — 稍後重試");
  if (!r.ok) throw new Error(`撤單失敗: ${r.status}`);
}

await cancelOrder(placed.order_id, BigInt(placed.uuid_int));

每訂單撤單冷卻 5 分鐘 — 命中冷卻會回傳 429。若遺失 uuid_int,呼叫 GET /orders/{order_id} 即可在回應主體中取回。

教學 3 — 單交易對雙邊自動報價

這是迴圈:

迴圈:
    mid     ← get_mid(MARKET["symbol"])            # 你的報價來源
    if 首次迭代 or |mid − last_mid| / last_mid > drift_bps:
        cancel(open_bid, open_ask)            # 任何已過期的訂單
        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)

三個可調旋鈕:

  • spread_bps — 單側半價差。每側 5 bps = 來回 10 bps。
  • drift_bps — 只在 mid 移動超過該門檻時重新掛單。避免在小幅抖動時反覆撤換。
  • poll_seconds — 喚醒間隔。1–5 秒比較常見。

報價來源 stub

報價來源由你自己提供。整合點就一個函式:

def get_mid(pair: str) -> Decimal:
    """回傳 `pair` 當前的 mid 價。

    實作要對接你自己的資料源 — 內部定價服務、Bloomberg、
    某個聚合器 REST API 等。回傳值是以 quote-per-base 表示的
    Decimal 價格(例如 EURC/USDC = 1.085)。
    """
    raise NotImplementedError("接入你的報價來源。")
async function getMid(pair: string): Promise<number> {
  // 接入你自己的報價來源 — 內部服務、Bloomberg、Refinitiv、
  // 聚合器 REST API 等。回傳 quote-per-base
  // (例如 EURC/USDC = 1.085)。
  throw new Error("接入你的報價來源。");
}

迴圈本體

SPREAD_BPS = Decimal("5")     # 單側 5 bps
DRIFT_BPS  = Decimal("3")     # 僅當 mid 移動 > 3 bps 才重新掛單
POLL_S     = 2.0
QTY        = Decimal("1000")  # 每側的 base 數量

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

        # 先撤再掛 — 避免跨週期自成交。
        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:
        # 我們的型別化 error_code: INSUFFICIENT_EQUITY、STP_BLOCKED 等。
        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;                                              // 單側
const DRIFT_BPS  = 3;
const POLL_MS    = 2_000;
const QTY        = "1000";                                         // 每側的 base 數量

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

進入生產前,有幾條行為值得清楚:

  • 先撤再掛。 若在撤掉舊單之前就掛新單,你的新 bid 可能會撞上舊的 ask — API 會以 STP_BLOCKED 拒絕。上面的迴圈先撤再掛。
  • INSUFFICIENT_EQUITY 是好事。 看到它表示你凍結的 vault 餘額超過可用。減小 QTY、加充值、或收緊價差。
  • 撤單冷卻從下單時開始。 新 ID 不構成豁免。請處理 429 並等待冷卻結束,當前實現沒有按帳戶豁免。
  • 清狀態前先對帳。 cancel_order 回傳 200 只代表撮合引擎接受了取消;任何進行中成交的鏈上結算是非同步的。如果你追蹤庫存,要輪詢 GET /orders/{id} 的 settlement_summary 直到它進入終態,再清掉本地部位。

恢復後再啟動循環

單筆下單輔助函式使用本地 order-requests/ 目錄(TypeScript 版本執行於 Node.js),在傳送訂單前保存請求檔案,不保存 API 憑據或私鑰。保持目錄私有,完成對帳前保留檔案。每一側成功後立即記錄;兩次呼叫不是原子操作。

下單或撤單報錯時循環停止。重新啟動前,讀取已保存的 order_id、uuid_int 和簽名請求,攜帶所有者 API Key 查詢 GET /orders/{order_id}。未知提交後的一次 404 不能證明訂單未被接受,應等待資料傳播並完成對帳,再決定是否重送原請求。DUPLICATE_ORDER_ID 要求查詢已有訂單,不是產生新 ID。所有不確定請求及剩餘掛單完成對帳後才能恢復;不要清空本地狀態直接重跑循環。此教學不實作自動重啟恢復。

教學 4 — 用 VL 批次做多交易對報價

單交易對循環在鏈下記賬中為 bid 預留報價代幣、為 ask 預留基礎代幣。VL 批次的各腿在不同市場支出同一代幣,並共享 group_id;初始預留為最大單腿成本,不是各腿總和。

骨架不變;把單 leg 的 placeOrder 換成批量呼叫:

def place_vl_batch(legs: list[dict]) -> dict:
    """legs: 每個為 {side, base, quote, amount, price, expiration}。"""
    leg_0_uuid = new_order_id()                                    # 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();                                   // 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;
}

每個不同市場選擇支出共享抵押代幣的方向,按該市場返回的基礎/報價方向定價。使用 POST /orders/vl/cancel 取消舊組,核對訂單與結算結果後再提交替代批次。通過 GET /config → limits.vl_batch 讀取活躍上限。

VL 請求在發送前保存到 vl-requests/。超時或出錯後停止循環,使用原始 ID 逐腿核對保存的請求;不要再次調用生成函數來重試。核對後如需重發,使用保存的原始 body。完整響應包含下單結果和可能待確認的預算證據,按VL 結果規則處理。核對完成前請妥善保管這些私有文件。

實務要點

  • 保存每個已簽名身份。 保留 order_id、uuid_int、VL group/leg 身份和完整已簽名請求。不能僅由某條腿的 order_id 重建其 VL uuid_int;本地身份丟失時先查詢原訂單。
  • 保留提交身份。 發送前持久化訂單 ID 與完整簽名請求。超時後先查詢該 ID;重複提交可能回傳 DUPLICATE_ORDER_ID,不會保證再次回傳成功。不要用新 ID 替換結果尚未確定的訂單。
  • 不要把 limits.vl_batch.max 寫死。 啟動時從 GET /config 讀,並把它當成唯一可信來源。
  • 清庫存前先看 settlement_summary。 取消訂單回傳 200、或狀態 pending → cancelled,都不代表所有進行中的撮合在鏈上都落地了。輪詢 GET /orders/{id} 直到 settlement_summary.status 進入終態。

下一步

示例使用訂單預覽取得規範數量與簽名欄位。預覽不預留資金,也不跳過最終訂單/VL 校驗。請從 /config、/health 讀取當前簽名域與 executor ID,示例部署常量不是永久標識。

對 VL 各腿,/orders/preview 只接受獨立訂單 UUID(leg_id=0,group 來自該訂單 ID)。示例先用此臨時 UUID 預覽,再在簽名前將回傳 Order 的 uuid 替換爲實際共享 group 的 VL UUID。提交時使用規範金額/價格和已簽名的 VL UUID。