Building a bot
The unattended quote loop, and the idioms that keep it alive
The Quickstart drives one trade by hand. This page turns that
into a process that runs unattended: it assumes you already have signer.mjs,
an API key, and a funded party.
The desk hosts no bot of its own — quoting is entirely yours.
The loop
Everything the bot does is one reaction: an RFQ arrives, so quote it and sign
what the quote returned. The socket delivers the RFQs (and keeps you
invitable); for a maker, GET /tx/pending is only the recovery path after a
restart. (A party that also takes signs more: whatever the desk queues for a
trade it accepted — see The sign queue.)
import WebSocket from "ws";
import { loadPrivateKey, signHashB64 } from "./signer.mjs";
const API = process.env.API_URL ?? "https://api.devnet.cessio.cc";
const KEY = process.env.API_KEY; // mk_… from registration
const privateKey = loadPrivateKey(process.env.PARTY_KEY_PEM);
const SPREAD_BPS = 50; // 0.5% around reference
const PRICES = { "cbtc/usdcx": 97000, "ceth/usdcx": 3500 };
const auth = { "X-API-Key": KEY, "content-type": "application/json" };
const live = new Set(); // rfqIds holding a live quote of ours
/** Sign a list of actions and submit them in ONE request. */
async function signActions(actions) {
if (actions.length === 0) return;
const res = await fetch(`${API}/tx/execute`, {
method: "POST",
headers: auth,
body: JSON.stringify({
signatures: actions.map((a) => ({ actionId: a.id, signature: signHashB64(privateKey, a.hash) })),
}),
}).then((r) => r.json());
for (const r of res.results) if (r.status !== "executed") console.warn(`unsigned: ${JSON.stringify(r)}`);
}
/** Anything still waiting on us from before a restart — the recovery path. */
async function drainPending() {
const { actions } = await fetch(`${API}/tx/pending`, { headers: auth }).then((r) => r.json());
await signActions(actions);
}
async function quote(rfq) {
if (live.has(rfq.rfqId)) return; // the reconnect snapshot replays RFQs we quote
const ref = PRICES[`${rfq.base}/${rfq.quote}`];
if (ref === undefined) return; // a pair we don't price
const factor = rfq.direction === "SELL" ? 1 - SPREAD_BPS / 1e4 : 1 + SPREAD_BPS / 1e4;
const res = await fetch(`${API}/maker/quotes`, {
method: "POST",
headers: auth,
// .toFixed keeps float noise out of the wire format — see "Prices are strings"
// validUntil omitted: the desk caps its default at the RFQ deadline.
body: JSON.stringify({ rfqId: rfq.rfqId, price: (ref * factor).toFixed(2) }),
});
if (!res.ok) return console.warn(`quote rejected: ${res.status} ${await res.text()}`);
live.add(rfq.rfqId);
await signActions((await res.json()).actions); // anchors the quote — not optional
}
async function connect() {
// What we already quote, read BEFORE the snapshot replays `rfq.created`:
// quoting a live quote again would replace it for nothing.
live.clear();
try {
const incoming = await fetch(`${API}/rfq/incoming`, { headers: auth }).then((r) => r.json());
for (const r of incoming) if (r.open && r.myQuote?.status === "pending") live.add(r.rfqId);
} catch (e) {
console.warn(`rfq/incoming unreadable, re-quoting the snapshot: ${e}`);
}
const ws = new WebSocket(`${API.replace(/^http/, "ws")}/maker/stream`, {
headers: { "X-API-Key": KEY },
});
ws.on("message", (data) => {
let frame;
try {
frame = JSON.parse(String(data));
} catch {
return; // malformed frames are ignored
}
if (frame.type === "rfq.created") return void quote(frame.payload);
if (frame.type === "quote.status" && frame.payload.status !== "pending") return void live.delete(frame.payload.rfqId);
if (frame.type === "resync") return void ws.close();
// Everything else — trade progress and the like — is informational.
});
ws.on("close", () => setTimeout(connect, 1000)); // reconnect = fresh snapshot
}
await drainPending(); // recover anything a restart left behind
await connect();Install ws in the bot project before running this example. Browser-native
WebSocket cannot set the X-API-Key upgrade header, so it cannot authenticate
the maker stream.
That is a complete maker. The rest of this page is what keeps it running.
Ignore what you don't understand
Frames are routed by party, not by socket kind, and the event set can grow — your maker socket may carry types you have never heard of. Validate, then skip:
const HANDLED = new Set(["rfq.created", "quote.status", "resync"]);
if (!HANDLED.has(frame?.type)) return; // never crash, never log-spamA bot that dies on one unexpected frame loses every RFQ until someone restarts it.
Keep no state you cannot rebuild
Track RFQs in a plain Map<rfqId, RfqDto> per API key and clear it on
disconnect — the reconnect snapshot rebuilds it, and resync is just a
disconnect you were told about. No database, no drift. The snapshot rules are
in Subscribe to RFQs.
Sign, or you never traded
POST /maker/quotes returning 200 means nothing until the allocate actions
it returned are signed. If the bot's signing path stalls, it quietly stops
filling — every quote stays invisible to takers and dies at its validUntil.
Alert on it. GET /maker/status answers in one call, and its two live fields
are exactly the ways a bot dies silently:
const s = await fetch(`${API}/maker/status`, { headers: auth }).then((r) => r.json());
if (!s.invitable) alert("stream is down — no RFQ will ever reach us");
if (s.pendingActions > 0) alert(`${s.pendingActions} unsigned actions — signing path stalled`);Price around a reference, mind the direction
Where the number comes from is your business — your own book, an exchange feed, a pool. A spread around a reference, signed by the RFQ's direction, is the common shape:
// below reference when the taker sells (you buy), above when the taker buys.
const factor = direction === "SELL" ? 1 - spreadBps / 10_000 : 1 + spreadBps / 10_000;If you have no feed of your own, GET /maker/reference-price?base=…"e=…
returns the desk's advisory reference (CoinGecko, cached server-side). It
answers {"price": null} whenever it has nothing, it is not a price the desk
will hold you to, and polling it gains nothing over the cache. A serious maker
brings its own source.
Set validUntil only when you mean it
Omit it and the desk uses its own quote lifetime, already capped at the RFQ
deadline — which is what most bots want. Send it when your pricing has a real
window, and keep it under rfq.deadline; an explicit value past the deadline is
a 409, because silently shortening a commitment you stated would be worse.
Log rejected quotes, don't throw
A non-ok response to POST /maker/quotes is business as usual — someone else
was faster, the deadline hit, the pair moved. Log it with the response text
and keep serving other RFQs.
Prices are strings
Money crosses the wire as a decimal string ("97300.0"), never a float — past
~15 significant digits a float silently rounds, and the ledger will not accept
what you meant. Do your arithmetic in whatever precision you like; format to a
decimal string at the edge.