API
Firma degli ordini
Come la chiave di sessione di Seeryx firma gli ordini: i messaggi PMO2, PMO3, PMO4 e le coppie PMS1, campo per campo, con codice TypeScript e Python.
Panoramica
Ogni operazione su seeryx.com viene regolata su Solana tramite il programma Seeryx, e il programma accetta solo ordini firmati per il tuo conto. Il tuo wallet firma una sola volta, per aprire una sessione di trading; poi la chiave di sessione firma ogni ordine, divisione e unione come un breve messaggio binario. Il server verifica la firma prima che l’ordine arrivi nel libro ordini e il programma la verifica di nuovo quando l’esecuzione viene regolata.
- Deposita una volta. Il tuo account nel programma si apre con il primo deposito (Depositi e prelievi).
- Apri una sessione con una chiave tua: una firma del wallet, nessun SOL.
- Firma ogni ordine con quella chiave e invia la firma insieme all’ordine come
signed.
Apri una sessione di trading
Crea una coppia di chiavi Ed25519, poi chiama POST /api/relay:
| Campo | Tipo | Descrizione |
|---|---|---|
action | "session" | |
key | string | La chiave pubblica della sessione, in base58. |
expiresAt | number | Secondi Unix, al massimo maxSessionTtl di GET /api/funds da adesso. Gli esempi usano quel limite meno 2 minuti. |
limit | number | Gli USDC che la sessione può spendere in tutto per acquisti e divisioni. |
maxTrade | number | Gli USDC che un singolo ordine può spendere; al massimo pari a limit. |
La risposta è { "transaction": "<base64>" }: una transazione Solana v0 con Seeryx come pagatore delle commissioni e tutti gli slot di firma vuoti. Firmala con il wallet senza inviarla e inviala a POST /api/relay/submit: Seeryx aggiunge la propria firma e la trasmette. Attendi che sia confermata (con qualsiasi RPC Solana, getSignatureStatuses), poi firma gli ordini con la chiave. tradingSession in chain.ts fa tutto questo e conserva la chiave in session.json.
L’oggetto firmato
| Campo | Tipo | Descrizione |
|---|---|---|
id | string | Un id d’ordine u64 come testo decimale, unico per il tuo wallet e mai riutilizzato. Gli esempi usano il tempo in millisecondi × 2^20 più 20 bit casuali. |
limit | number | Il prezzo firmato. Un ordine limite firma il suo limite, un ordine a mercato il suo cap: centesimi interi in PMO2, decimi di centesimo con form: 3 o form: 4. |
shares | string | Unità di azione (1 azione = 10000) come testo decimale, in tutte le forme tranne PMO4. Un ordine limite o una vendita firma esattamente le sue azioni × 10000; un acquisto a mercato PMO2/PMO3 il numero massimo di unità che il suo importo compra al cap, commissione inclusa. |
budget | string | Solo PMO4: l’amount dell’acquisto a mercato in micro-USDC come testo decimale, esattamente amount × 1000000. |
expiresAt | number | Secondi Unix: la chiusura per un ordine limite, 5 minuti da adesso (mai oltre la chiusura) per un ordine a mercato. Vedi Scadenza. |
key | string | La chiave pubblica della sessione, in base58. |
signature | string | La firma Ed25519 del messaggio qui sotto da parte della chiave di sessione, 128 caratteri esadecimali. |
maxFeeBps | number | L’aliquota massima della commissione taker che l’ordine accetta: almeno fees.rateBps di GET /api/funds (ora 700). |
form | 3 | 4 | 3: un limite tra centesimi interi (PMO3). 4: un acquisto a mercato con budget (PMO4). Assente per PMO2. |
Formati dei messaggi
La chiave di sessione firma questi byte: un prefisso ASCII di 4 byte e i campi in ordine, interi little-endian (borsh). Scegli la forma in base all’ordine:
| Ordine | Forma | Byte |
|---|---|---|
| Ordine limite o vendita a un centesimo intero; vendita a mercato | PMO2 | 97 |
| Ordine limite o vendita tra centesimi interi (sotto 1¢, sopra 99¢) | PMO3 | 98 |
Acquisto a mercato, dove budgetBuys di GET /api/funds è attivo (lo è su seeryx.com) | PMO4 | 97 |
| Acquisto a mercato dove budgetBuys è disattivato | PMO2 o PMO3, con le azioni ricavate dall’importo | |
| Divisione o unione | PMS1 | 93 |
PMO2: un ordine a un centesimo intero
| Posizione | Byte | Campo | Codifica |
|---|---|---|---|
| 0 | 4 | prefix | "PMO2" (ASCII) |
| 4 | 32 | owner | la chiave pubblica del tuo wallet |
| 36 | 32 | market | l’account del mercato |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 up, 1 down |
| 77 | 1 | action | 0 acquisto, 1 vendita |
| 78 | 1 | limit | u8, centesimi interi da 1 a 99 |
| 79 | 8 | shares | u64, unità di azione (1 azione = 10000) |
| 87 | 8 | expiresAt | i64, secondi Unix |
| 95 | 2 | maxFeeBps | u16 |
PMO3: un ordine tra centesimi interi
| Posizione | Byte | Campo | Codifica |
|---|---|---|---|
| 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 acquisto, 1 vendita |
| 78 | 2 | limit | u16, decimi di centesimo (995 equivale a 99,5¢) |
| 80 | 8 | shares | u64 |
| 88 | 8 | expiresAt | i64 |
| 96 | 2 | maxFeeBps | u16 |
PMO4: un acquisto a mercato con budget
Nessuna action (è sempre un acquisto) e nessun numero di azioni. Il programma vincola l’ordine al suo budget su tutte le esecuzioni, quindi spende l’intero importo fino al cap, per quanto al di sotto del cap venga eseguito.
| Posizione | Byte | Campo | Codifica |
|---|---|---|---|
| 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, decimi di centesimo: il prezzo peggiore di qualsiasi esecuzione |
| 79 | 8 | budget | u64, micro-USDC: il massimo che paga in tutto, commissione inclusa |
| 87 | 8 | expiresAt | i64 |
| 95 | 2 | maxFeeBps | u16 |
PMS1: una divisione o un’unione
Il suo signed è { id, shares, expiresAt, key, signature }; per una divisione shares è l’importo × 10000.
| Posizione | Byte | Campo | Codifica |
|---|---|---|---|
| 0 | 4 | prefix | "PMS1" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | merge | 0 divisione, 1 unione |
| 77 | 8 | shares | u64, coppie in unità di azione |
| 85 | 8 | expiresAt | i64 |
Un acquisto a mercato PMO2/PMO3 non deve mai poter costare più del suo importo: il server verifica units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit in decimi di centesimo, commissione arrotondata per difetto al micro-USDC). affordable in message.ts trova il valore di units più grande che soddisfa la condizione. Un ordine del genere si ferma quando ha raggiunto quelle unità (reason: "share_limit") e il resto dell’importo rimane nei tuoi fondi disponibili; PMO4 non si ferma mai lì.
L’account del mercato
market è l’indirizzo di programma derivato da programId di GET /api/funds con i seed "market" e lo slug come byte UTF-8, completati con zeri fino a 32. owner è la chiave pubblica del tuo wallet, non la chiave di sessione.
const id = new Uint8Array(32);
id.set(new TextEncoder().encode(slug));
const [market] = await getProgramDerivedAddress({ programAddress: address(funds.programId), seeds: ['market', id] });Codice
message.ts costruisce i byte (nessuna dipendenza; verificato byte per byte con la codifica del programma stesso). chain.ts apre la sessione, firma ordini, divisioni e unioni e invia le transazioni preparate.
// 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 };
}Una richiesta firmata
{
"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
}
}Quando il server rifiuta
| Errore 400 | Soluzione |
|---|---|
| Orders must be signed by your trading session; reload the page | Aggiungi signed. |
| Malformed signed order | Un campo ha il tipo sbagliato: id, shares e budget sono testo decimale; limit, expiresAt e maxFeeBps numeri interi. |
| The signed order must expire by the round close | expiresAt è nel passato o dopo la chiusura. |
| The signed order expires too soon to settle; sign it again | expiresAt è a meno di 5 secondi da adesso. |
| Your trading session expired; sign in to trading again | key non è la tua sessione on-chain, oppure scade entro 5 secondi: apri una nuova sessione. |
| Your trading session ends before this order would; sign in to trading again | Un ordine limite resterebbe nel libro ordini oltre la fine della sessione. |
| The order signature is not valid | I byte, owner, market o key differiscono da ciò che il server ha ricostruito dall’ordine. |
| The signed order does not match the order | Il limit o le shares firmati differiscono da quelli dell’ordine. |
| The signed order exceeds the amount | Un acquisto a mercato PMO2/PMO3 ha firmato più azioni di quante il suo importo ne paghi al cap. |
| The signed order does not match the amount | Un budget PMO4 non è pari ad amount × 1000000. |
| Only a market buy is signed with a budget | form 4 su un ordine limite o una vendita. |
| Market buys by budget are not available yet; reload the page | Un ordine PMO4 dove budgetBuys è disattivato. |
| Trading fees changed; reload the page and place the order again | maxFeeBps è inferiore all’aliquota attuale: rileggi GET /api/funds. |
| This order was already submitted | L’id è già stato usato: firma ogni nuovo tentativo con un nuovo id. |
| An order can spend at most $… in this trading session | L’ordine supera il maxTrade della sessione. |
| Your trading session limit is used up; sign in to trading again | Apri una nuova sessione, con un limit più alto se ti serve. |
Trading is paused after secure disconnect... (trading_paused) | La chiave è stata chiusa: apri una nuova sessione e firma di nuovo. |