API
Signing orders
How a Seeryx trading session key signs orders: the PMO2, PMO3 and PMO4 order messages and PMS1 pairs, field by field, with TypeScript and Python code.
Overview
Every trade on seeryx.com settles on Solana through the Seeryx program, and the program accepts only orders signed for your account. Your wallet signs once, to open a trading session; the session key then signs each order, split and merge as a short binary message. The server checks the signature before the order reaches the book and the program checks it again when the fill settles.
- Deposit once. Your account in the program opens with your first deposit (Deposits and withdrawals).
- Open a session with a key of your own: one wallet signature, no SOL.
- Sign each order with that key and send the signature with the order as
signed.
Open a trading session
Make an Ed25519 key pair, then POST /api/relay:
| Field | Type | Description |
|---|---|---|
action | "session" | |
key | string | The session public key, base58. |
expiresAt | number | Unix seconds, at most maxSessionTtl of GET /api/funds from now. The samples use that limit minus 2 minutes. |
limit | number | USDC the session may spend on buys and splits in all. |
maxTrade | number | USDC one order may spend; at most limit. |
The answer is { "transaction": "<base64>" }: a Solana v0 transaction with Seeryx as the fee payer and every signature slot empty. Sign it with the wallet without sending it, and POST /api/relay/submit it: Seeryx adds its own signature and sends it. Wait until it is confirmed (any Solana RPC, getSignatureStatuses), then sign orders with the key. tradingSession in chain.ts does all of this and keeps the key in session.json.
The signed object
| Field | Type | Description |
|---|---|---|
id | string | A u64 order id as decimal text, unique for your wallet and never reused. The samples use the time in milliseconds × 2^20 plus 20 random bits. |
limit | number | The signed price. A limit order signs its limit, a market order its cap: whole cents in PMO2, tenths of a cent with form: 3 or form: 4. |
shares | string | Share units (1 share = 10000) as decimal text, every form but PMO4. A limit order or a sale signs exactly its shares × 10000; a PMO2/PMO3 market buy the most units its amount buys at the cap with the fee. |
budget | string | PMO4 only: the market buy's amount in micro-USDC as decimal text, exactly amount × 1000000. |
expiresAt | number | Unix seconds: the close for a limit order, 5 minutes ahead (never past the close) for a market order. See Expiry. |
key | string | The session public key, base58. |
signature | string | The Ed25519 signature of the message below by the session key, 128 hex characters. |
maxFeeBps | number | The highest taker fee rate the order accepts: at least fees.rateBps of GET /api/funds (now 700). |
form | 3 | 4 | 3: a limit between whole cents (PMO3). 4: a market buy by budget (PMO4). Absent for PMO2. |
Message formats
The session key signs these bytes: a 4-byte ASCII prefix and the fields in order, integers little-endian (borsh). Pick the form by the order:
| Order | Form | Bytes |
|---|---|---|
| Limit order or sale at a whole cent; market sale | PMO2 | 97 |
| Limit order or sale between whole cents (below 1¢, above 99¢) | PMO3 | 98 |
Market buy, where budgetBuys of GET /api/funds is on (it is on seeryx.com) | PMO4 | 97 |
| Market buy where budgetBuys is off | PMO2 or PMO3, shares from the amount | |
| Split or merge | PMS1 | 93 |
PMO2: an order at a whole cent
| Offset | Bytes | Field | Encoding |
|---|---|---|---|
| 0 | 4 | prefix | "PMO2" (ASCII) |
| 4 | 32 | owner | your wallet's public key |
| 36 | 32 | market | the market account |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 up, 1 down |
| 77 | 1 | action | 0 buy, 1 sell |
| 78 | 1 | limit | u8, whole cents 1-99 |
| 79 | 8 | shares | u64, share units (1 share = 10000) |
| 87 | 8 | expiresAt | i64, Unix seconds |
| 95 | 2 | maxFeeBps | u16 |
PMO3: an order between whole cents
| Offset | Bytes | Field | Encoding |
|---|---|---|---|
| 0 | 4 | prefix | "PMO3" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 up, 1 down |
| 77 | 1 | action | 0 buy, 1 sell |
| 78 | 2 | limit | u16, tenths of a cent (995 is 99.5¢) |
| 80 | 8 | shares | u64 |
| 88 | 8 | expiresAt | i64 |
| 96 | 2 | maxFeeBps | u16 |
PMO4: a market buy by budget
No action (always a buy) and no share count. The program holds the order to its budget across every fill, so it spends its whole amount up to the cap however far below the cap it fills.
| Offset | Bytes | Field | Encoding |
|---|---|---|---|
| 0 | 4 | prefix | "PMO4" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 up, 1 down |
| 77 | 2 | limit | u16, tenths of a cent: the worst price of any fill |
| 79 | 8 | budget | u64, micro-USDC: the most it pays in all, fee included |
| 87 | 8 | expiresAt | i64 |
| 95 | 2 | maxFeeBps | u16 |
PMS1: a split or a merge
Its signed is { id, shares, expiresAt, key, signature }; for a split shares is the amount × 10000.
| Offset | Bytes | Field | Encoding |
|---|---|---|---|
| 0 | 4 | prefix | "PMS1" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | merge | 0 split, 1 merge |
| 77 | 8 | shares | u64, pairs in share units |
| 85 | 8 | expiresAt | i64 |
A PMO2/PMO3 market buy must never be able to cost more than its amount: the server checks units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit in tenths of a cent, fee rounded down to a micro-USDC). affordable in message.ts finds the largest such units. Such an order stops once it has those units (reason: "share_limit") and the rest of the amount stays in your cash; PMO4 never stops there.
The market account
market is the program address derived from programId of GET /api/funds with the seeds "market" and the slug as UTF-8 bytes, zero-padded to 32. owner is your wallet's public key, not the session key.
const id = new Uint8Array(32);
id.set(new TextEncoder().encode(slug));
const [market] = await getProgramDerivedAddress({ programAddress: address(funds.programId), seeds: ['market', id] });Code
message.ts builds the bytes (no dependencies; checked byte for byte against the program's own encoding). chain.ts opens the session, signs orders, splits and merges and sends prepared transactions.
// message.ts: the bytes a session key signs, exactly as the program reads them (borsh, little-endian).
// No dependencies. tests/developers.test.ts checks it against tests/fixtures/messages.json.
const le = (size: number, value: bigint) => {
const out = new Uint8Array(size);
for (let i = 0; i < size; i++) out[i] = Number((value >> BigInt(8 * i)) & 0xffn);
return out;
};
const join = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
};
const ascii = (text: string) => new TextEncoder().encode(text);
export interface OrderTerms {
owner: Uint8Array; // your wallet's public key, 32 bytes
market: Uint8Array; // the round's market account, 32 bytes
id: bigint; // unique per wallet, never reused
side: 'up' | 'down';
action: 'buy' | 'sell';
limit: number; // tenths of a cent: 450 is 45c, 995 is 99.5c
shares: bigint; // share units: 1 share is 10000
expiresAt: bigint; // unix seconds, at most the round's end
maxFeeBps: number; // the highest taker fee rate you accept
}
// PMO2 for a limit in whole cents, PMO3 for a limit between whole cents.
export function orderMessage(o: OrderTerms) {
const side = o.side === 'up' ? 0 : 1, action = o.action === 'buy' ? 0 : 1;
const head = join(o.owner, o.market, le(8, o.id), Uint8Array.of(side, action));
const tail = join(le(8, o.shares), le(8, o.expiresAt), le(2, BigInt(o.maxFeeBps)));
return o.limit % 10 === 0
? join(ascii('PMO2'), head, le(1, BigInt(o.limit / 10)), tail)
: join(ascii('PMO3'), head, le(2, BigInt(o.limit)), tail);
}
// PMO4: a market buy by budget, where GET /api/funds says budgetBuys. It spends up to `budget` micro-USDC in all,
// taker fee included, and never fills above `limit` (tenths of a cent); no action (always a buy), no share count.
export function budgetMessage(o: {
owner: Uint8Array; market: Uint8Array; id: bigint; side: 'up' | 'down';
limit: number; budget: bigint; expiresAt: bigint; maxFeeBps: number;
}) {
const side = Uint8Array.of(o.side === 'up' ? 0 : 1);
return join(ascii('PMO4'), o.owner, o.market, le(8, o.id), side, le(2, BigInt(o.limit)),
le(8, o.budget), le(8, o.expiresAt), le(2, BigInt(o.maxFeeBps)));
}
// PMS1: a split (cash into Up + Down pairs) or a merge (pairs back into cash).
export function pairMessage(p: {
owner: Uint8Array; market: Uint8Array; id: bigint; merge: boolean; shares: bigint; expiresAt: bigint;
}) {
const merge = Uint8Array.of(p.merge ? 1 : 0);
return join(ascii('PMS1'), p.owner, p.market, le(8, p.id), merge, le(8, p.shares), le(8, p.expiresAt));
}
// The most share units `cash` micro-USDC buys at `price` tenths of a cent, taker fee included:
// what a market buy signs.
export function affordable(cash: bigint, price: number, rateBps: number) {
const step = price % 10 ? 10n : 1n; // between whole cents shares go in steps of 0.001
const p = BigInt(price), rate = BigInt(rateBps);
const cost = (n: bigint) => n * p / 10n + n * rate * p * (1000n - p) / 100_000_000n;
let n = cash * 100_000_000n / (p * 10_000_000n + rate * p * (1000n - p));
n -= n % step;
while (n > 0n && cost(n) > cash) n -= step;
while (cost(n + step) <= cash) n += step;
return n;
}// chain.ts: trading session and signed orders for on-chain trading. Node 22+, npm i @solana/kit
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import {
address, createKeyPairFromPrivateKeyBytes, createSolanaRpc, getAddressEncoder, getAddressFromPublicKey,
getBase64EncodedWireTransaction, getBase64Encoder, getProgramDerivedAddress,
getTransactionDecoder, partiallySignTransaction, signBytes,
} from '@solana/kit';
import type { Address, Signature } from '@solana/kit';
import { affordable, budgetMessage, orderMessage, pairMessage } from './message';
import { api, type Wallet } from './seeryx';
// GET /api/funds on a server that trades on chain
export interface Funds {
mode: string; onChainTrading?: boolean; cluster: 'mainnet' | 'devnet';
programId: string; maxSessionTtl: number; fees: { rateBps: number } | null;
budgetBuys?: boolean; // market buys are signed by budget (PMO4)
}
export interface Session { key: Address; expiresAt: number; pair: CryptoKeyPair }
export interface Order {
slug: string; side: 'up' | 'down'; action: 'buy' | 'sell'; type: 'limit' | 'market';
shares?: number; amount?: number; limit?: number; cap?: number; postOnly?: boolean;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const bytes = (a: Address) => new Uint8Array(getAddressEncoder().encode(a));
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex');
// A transaction the exchange prepared with itself as the fee payer: the wallet signs first (every other slot is empty).
export async function signPrepared(wallet: Wallet, base64: string) {
const transaction = getTransactionDecoder().decode(getBase64Encoder().encode(base64));
return partiallySignTransaction([wallet.pair], transaction);
}
// Any Solana RPC of the cluster reads the transaction's status: your own in SOLANA_RPC, or the public one.
const rpcOf = (funds: Funds) => createSolanaRpc(process.env.SOLANA_RPC
?? (funds.cluster === 'mainnet' ? 'https://api.mainnet-beta.solana.com' : 'https://api.devnet.solana.com'));
// Signs it, sends it and waits until it is confirmed. The wallet needs no SOL.
export async function sendPrepared(funds: Funds, wallet: Wallet, base64: string) {
const signed = await signPrepared(wallet, base64);
// the exchange adds its own signature after the wallet's and sends it
const transaction = getBase64EncodedWireTransaction(signed);
const sent = await api<{ signature: string }>('/relay/submit', { method: 'POST', body: { transaction } });
const signature = sent.signature as Signature;
const rpc = rpcOf(funds);
for (let i = 0; i < 60; i++) {
await sleep(1000);
const { value: [status] } = await rpc.getSignatureStatuses([signature]).send();
if (status?.err) throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
const state = status?.confirmationStatus;
if (state === 'confirmed' || state === 'finalized') return signature;
}
throw new Error('The transaction was not confirmed in time');
}
// The session key signs orders without the wallet. It can trade within its limits and can never withdraw.
// Kept in session.json (keep that file private) and reused while it has time left. A limit order rests until its
// round closes and must not outlive the session: `until` is the latest close you will quote in (unix seconds).
export async function tradingSession(funds: Funds, wallet: Wallet, budget = 1000, until = 0): Promise<Session> {
const file = 'session.json', now = Math.floor(Date.now() / 1000);
if (existsSync(file)) {
const saved = JSON.parse(readFileSync(file, 'utf8'));
// the furthest 5m and 15m rounds that trade close within 3 hours: keep a session only with time to spare
if (saved.owner === wallet.address && saved.expiresAt > Math.max(now + 3 * 3600, until + 60)) {
const pair = await createKeyPairFromPrivateKeyBytes(new Uint8Array(saved.seed));
return { key: saved.key, expiresAt: saved.expiresAt, pair };
}
}
const seed = crypto.getRandomValues(new Uint8Array(32));
const pair = await createKeyPairFromPrivateKeyBytes(seed);
const key = await getAddressFromPublicKey(pair.publicKey);
const expiresAt = now + funds.maxSessionTtl - 120;
// budget: USDC this session may spend on buys and splits in total, and per order
const body = { action: 'session', key, expiresAt, limit: budget, maxTrade: budget };
const { transaction } = await api<{ transaction: string }>('/relay', { method: 'POST', body });
await sendPrepared(funds, wallet, transaction);
const record = { owner: wallet.address, key, expiresAt, seed: [...seed] };
writeFileSync(file, JSON.stringify(record), { mode: 0o600 });
return { key, expiresAt, pair };
}
// The round's market account: the slug, zero-padded to 32 bytes, is the seed.
export async function marketOf(funds: Funds, slug: string) {
const id = new Uint8Array(32);
id.set(new TextEncoder().encode(slug));
const programAddress = address(funds.programId);
const [market] = await getProgramDerivedAddress({ programAddress, seeds: ['market', id] });
return market;
}
// When a market closes (unix seconds): a round at start + length, an event market at its closesAt.
const LENGTH: Record<string, number> = { '5m': 300, '15m': 900, '1h': 3600, '1d': 86_400 };
const closes = new Map<string, number>();
export async function closeOf(slug: string) {
const [, tf, start] = /-updown-(5m|15m|1h|1d)-(\d+)$/.exec(slug) ?? [];
if (tf) return Number(start) + LENGTH[tf];
if (!closes.has(slug)) closes.set(slug, (await api<{ closesAt: number }>(`/events/${slug}`)).closesAt);
return closes.get(slug)!;
}
// A limit order is signed until the close; a market order, split or merge for 5 minutes (never past the close).
async function expiry(slug: string, rests: boolean) {
const close = await closeOf(slug);
return rests ? close : Math.min(close, Math.floor(Date.now() / 1000) + 300);
}
// Order ids: unique per wallet and never reused: the time in milliseconds x 2^20 plus 20 random bits.
let lastId = 0n;
function nextId() {
const random = BigInt(crypto.getRandomValues(new Uint32Array(1))[0] & 0xfffff);
const id = (BigInt(Date.now()) << 20n) | random;
lastId = id > lastId ? id : lastId + 1n;
return lastId;
}
// The order body with its `signed` object, ready for POST /api/orders (or one item of /api/orders/batch).
export async function signOrder(funds: Funds, session: Session, owner: Address, order: Order) {
const price = order.type === 'limit' ? order.limit : order.cap; // market orders must carry a cap
if (price === undefined) throw new Error('A limit order needs limit, a market order needs cap');
const limit = Math.round(price * 10); // tenths of a cent
const rate = funds.fees?.rateBps ?? 0;
if (order.type === 'market' && order.action === 'buy' && funds.budgetBuys) {
// PMO4: the amount itself is signed as the budget (fee included) and the cap as the worst price of any fill
const id = nextId(), expiresAt = await expiry(order.slug, false), budget = BigInt(Math.round(order.amount! * 1e6));
const market = bytes(await marketOf(funds, order.slug));
const message = budgetMessage({
owner: bytes(owner), market, id, side: order.side, limit, budget, expiresAt: BigInt(expiresAt), maxFeeBps: rate,
});
const signature = hex(await signBytes(session.pair.privateKey, message));
const signed = { id: String(id), limit, budget: String(budget), expiresAt, key: session.key, signature, maxFeeBps: rate, form: 4 };
return { ...order, signed };
}
// a market buy (where budgetBuys is off) signs what its amount buys at the cap, fee included; everything else its shares
const shares = order.type === 'market' && order.action === 'buy'
? affordable(BigInt(Math.round(order.amount! * 1e6)), limit, rate)
: BigInt(Math.round(order.shares! * 1e4));
const id = nextId(), expiresAt = await expiry(order.slug, order.type === 'limit');
const market = bytes(await marketOf(funds, order.slug));
const message = orderMessage({
owner: bytes(owner), market, id, side: order.side, action: order.action,
limit, shares, expiresAt: BigInt(expiresAt), maxFeeBps: rate,
});
const signature = hex(await signBytes(session.pair.privateKey, message));
const fine = limit % 10 !== 0; // between whole cents: PMO3, the limit in tenths
const signed = {
id: String(id), limit: fine ? limit : limit / 10, shares: String(shares), expiresAt,
key: session.key, signature, maxFeeBps: rate, ...(fine ? { form: 3 } : {}),
};
return { ...order, signed };
}
// `signed` of a split (`pairs` = the amount in USDC) or a merge (`pairs` = the shares).
export async function signPair(
funds: Funds, session: Session, owner: Address, slug: string, merge: boolean, pairs: number,
) {
const id = nextId(), expiresAt = await expiry(slug, false), shares = BigInt(Math.round(pairs * 1e4));
const market = bytes(await marketOf(funds, slug));
const message = pairMessage({
owner: bytes(owner), market, id, merge, shares, expiresAt: BigInt(expiresAt),
});
const signature = hex(await signBytes(session.pair.privateKey, message));
return { id: String(id), shares: String(shares), expiresAt, key: session.key, signature };
}A signed request
{
"slug": "btc-updown-5m-1791916200", "side": "up", "action": "buy", "type": "limit", "shares": 10, "limit": 45,
"signed": {
"id": "1877965812345678901", "limit": 45, "shares": "100000", "expiresAt": 1791916500,
"key": "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB",
"signature": "2c6e257e85a2df1d...9e8a0f (128 hex characters)",
"maxFeeBps": 700
}
}
{
"slug": "btc-updown-5m-1791916200", "side": "down", "action": "buy", "type": "market", "amount": 5, "cap": 60,
"signed": {
"id": "1877965812345678902", "limit": 600, "budget": "5000000", "expiresAt": 1791916460,
"key": "GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB", "signature": "...", "maxFeeBps": 700, "form": 4
}
}When the server refuses
| 400 error | Fix |
|---|---|
| Orders must be signed by your trading session; reload the page | Add signed. |
| Malformed signed order | A field has the wrong type: id, shares and budget are decimal text; limit, expiresAt and maxFeeBps whole numbers. |
| The signed order must expire by the round close | expiresAt is in the past or after the close. |
| The signed order expires too soon to settle; sign it again | expiresAt is less than 5 seconds ahead. |
| Your trading session expired; sign in to trading again | key is not your session on chain, or it ends within 5 seconds: open a new session. |
| Your trading session ends before this order would; sign in to trading again | A limit order would rest past the session's end. |
| The order signature is not valid | The bytes, the owner, the market or the key differ from what the server rebuilt from the order. |
| The signed order does not match the order | The signed limit or shares differ from the order's. |
| The signed order exceeds the amount | A PMO2/PMO3 market buy signed more shares than its amount pays for at the cap. |
| The signed order does not match the amount | A PMO4 budget is not amount × 1000000. |
| Only a market buy is signed with a budget | form 4 on a limit order or a sale. |
| Market buys by budget are not available yet; reload the page | A PMO4 order where budgetBuys is off. |
| Trading fees changed; reload the page and place the order again | maxFeeBps is below the current rate: read GET /api/funds again. |
| This order was already submitted | The id was used before: sign every new attempt with a new id. |
| An order can spend at most $… in this trading session | The order is above the session's maxTrade. |
| Your trading session limit is used up; sign in to trading again | Open a new session, with a larger limit if you need one. |
Trading is paused after secure disconnect... (trading_paused) | The key was closed: open a new session and sign again. |