pagina iniziale

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.

  1. Deposita una volta. Il tuo account nel programma si apre con il primo deposito (Depositi e prelievi).
  2. Apri una sessione con una chiave tua: una firma del wallet, nessun SOL.
  3. 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:

CampoTipoDescrizione
actionobbligatorio"session"
keyobbligatoriostringLa chiave pubblica della sessione, in base58.
expiresAtobbligatorionumberSecondi Unix, al massimo maxSessionTtl di GET /api/funds da adesso. Gli esempi usano quel limite meno 2 minuti.
limitobbligatorionumberGli USDC che la sessione può spendere in tutto per acquisti e divisioni.
maxTradeobbligatorionumberGli 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

CampoTipoDescrizione
idobbligatoriostringUn 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.
limitobbligatorionumberIl 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.
sharesfacoltativostringUnità 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.
budgetfacoltativostringSolo PMO4: l’amount dell’acquisto a mercato in micro-USDC come testo decimale, esattamente amount × 1000000.
expiresAtobbligatorionumberSecondi Unix: la chiusura per un ordine limite, 5 minuti da adesso (mai oltre la chiusura) per un ordine a mercato. Vedi Scadenza.
keyobbligatoriostringLa chiave pubblica della sessione, in base58.
signatureobbligatoriostringLa firma Ed25519 del messaggio qui sotto da parte della chiave di sessione, 128 caratteri esadecimali.
maxFeeBpsobbligatorionumberL’aliquota massima della commissione taker che l’ordine accetta: almeno fees.rateBps di GET /api/funds (ora 700).
formfacoltativo3 | 43: 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:

OrdineFormaByte
Ordine limite o vendita a un centesimo intero; vendita a mercatoPMO297
Ordine limite o vendita tra centesimi interi (sotto 1¢, sopra 99¢)PMO398
Acquisto a mercato, dove budgetBuys di GET /api/funds è attivo (lo è su seeryx.com)PMO497
Acquisto a mercato dove budgetBuys è disattivatoPMO2 o PMO3, con le azioni ricavate dall’importo
Divisione o unionePMS193

PMO2: un ordine a un centesimo intero

PosizioneByteCampoCodifica
04prefix"PMO2" (ASCII)
432ownerla chiave pubblica del tuo wallet
3632marketl’account del mercato
688idu64
761side0 up, 1 down
771action0 acquisto, 1 vendita
781limitu8, centesimi interi da 1 a 99
798sharesu64, unità di azione (1 azione = 10000)
878expiresAti64, secondi Unix
952maxFeeBpsu16

PMO3: un ordine tra centesimi interi

PosizioneByteCampoCodifica
04prefix"PMO3"
432owner
3632market
688idu64
761side0 up, 1 down
771action0 acquisto, 1 vendita
782limitu16, decimi di centesimo (995 equivale a 99,5¢)
808sharesu64
888expiresAti64
962maxFeeBpsu16

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.

PosizioneByteCampoCodifica
04prefix"PMO4"
432owner
3632market
688idu64
761side0 up, 1 down
772limitu16, decimi di centesimo: il prezzo peggiore di qualsiasi esecuzione
798budgetu64, micro-USDC: il massimo che paga in tutto, commissione inclusa
878expiresAti64
952maxFeeBpsu16

PMS1: una divisione o un’unione

Il suo signed è { id, shares, expiresAt, key, signature }; per una divisione shares è l’importo × 10000.

PosizioneByteCampoCodifica
04prefix"PMS1"
432owner
3632market
688idu64
761merge0 divisione, 1 unione
778sharesu64, coppie in unità di azione
858expiresAti64

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
// 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
// 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

POST /api/orders
{
  "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
  }
}
Un acquisto limite PMO2 e un acquisto a mercato PMO4 da 5 $ con cap di 60¢ (limit in decimi).

Quando il server rifiuta

Errore 400Soluzione
Orders must be signed by your trading session; reload the pageAggiungi signed.
Malformed signed orderUn 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 closeexpiresAt è nel passato o dopo la chiusura.
The signed order expires too soon to settle; sign it againexpiresAt è a meno di 5 secondi da adesso.
Your trading session expired; sign in to trading againkey 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 againUn ordine limite resterebbe nel libro ordini oltre la fine della sessione.
The order signature is not validI byte, owner, market o key differiscono da ciò che il server ha ricostruito dall’ordine.
The signed order does not match the orderIl limit o le shares firmati differiscono da quelli dell’ordine.
The signed order exceeds the amountUn 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 amountUn budget PMO4 non è pari ad amount × 1000000.
Only a market buy is signed with a budgetform 4 su un ordine limite o una vendita.
Market buys by budget are not available yet; reload the pageUn ordine PMO4 dove budgetBuys è disattivato.
Trading fees changed; reload the page and place the order againmaxFeeBps è inferiore all’aliquota attuale: rileggi GET /api/funds.
This order was already submittedL’id è già stato usato: firma ogni nuovo tentativo con un nuovo id.
An order can spend at most $… in this trading sessionL’ordine supera il maxTrade della sessione.
Your trading session limit is used up; sign in to trading againApri 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.