accueil

API

Signature des ordres

Comment une clé de session de trading Seeryx signe les ordres : messages PMO2, PMO3, PMO4 et paires PMS1, champ par champ, en TypeScript et Python.

Vue d’ensemble

Chaque opération sur seeryx.com est réglée sur Solana par le programme Seeryx, et le programme n’accepte que des ordres signés pour votre compte. Votre portefeuille signe une seule fois, pour ouvrir une session de trading ; la clé de session signe ensuite chaque ordre, scission et fusion sous la forme d’un court message binaire. Le serveur vérifie la signature avant que l’ordre n’atteigne le carnet, et le programme la vérifie de nouveau lors du règlement de l’exécution.

  1. Déposez une fois. Votre compte dans le programme s’ouvre avec votre premier dépôt (Dépôts et retraits).
  2. Ouvrez une session avec une clé qui vous appartient : une signature du portefeuille, sans SOL.
  3. Signez chaque ordre avec cette clé et envoyez la signature avec l’ordre dans signed.

Ouvrir une session de trading

Générez une paire de clés Ed25519, puis appelez POST /api/relay :

ChampTypeDescription
actionobligatoire"session"
keyobligatoirestringLa clé publique de session, en base58.
expiresAtobligatoirenumberSecondes Unix, au plus maxSessionTtl de GET /api/funds à partir de maintenant. Les exemples utilisent cette limite moins 2 minutes.
limitobligatoirenumberLes USDC que la session peut dépenser au total en achats et en scissions.
maxTradeobligatoirenumberLes USDC qu’un ordre peut dépenser ; au plus limit.

La réponse est { "transaction": "<base64>" } : une transaction Solana v0 dont Seeryx est le payeur des frais et dont tous les emplacements de signature sont vides. Signez-la avec le portefeuille sans l’envoyer, puis transmettez-la à POST /api/relay/submit : Seeryx ajoute sa propre signature et l’envoie. Attendez sa confirmation (n’importe quel RPC Solana, getSignatureStatuses), puis signez les ordres avec la clé. tradingSession dans chain.ts fait tout cela et conserve la clé dans session.json.

L’objet signé

ChampTypeDescription
idobligatoirestringUn id d’ordre u64 en texte décimal, unique pour votre portefeuille et jamais réutilisé. Les exemples utilisent l’heure en millisecondes × 2^20 plus 20 bits aléatoires.
limitobligatoirenumberLe prix signé. Un ordre à cours limité signe sa limite, un ordre au marché son cap : en cents entiers dans PMO2, en dixièmes de cent avec form: 3 ou form: 4.
sharesfacultatifstringUnités de part (1 part = 10000) en texte décimal, dans toutes les formes sauf PMO4. Un ordre à cours limité ou une vente signe exactement ses parts × 10000 ; un achat au marché PMO2/PMO3, le nombre maximal d’unités que son montant permet d’acheter au cap, frais compris.
budgetfacultatifstringPMO4 uniquement : le champ amount de l’achat au marché en micro-USDC, en texte décimal, exactement amount × 1000000.
expiresAtobligatoirenumberSecondes Unix : la clôture pour un ordre à cours limité, 5 minutes plus tard (jamais au-delà de la clôture) pour un ordre au marché. Voir Expiration.
keyobligatoirestringLa clé publique de session, en base58.
signatureobligatoirestringLa signature Ed25519 du message ci-dessous par la clé de session, en 128 caractères hexadécimaux.
maxFeeBpsobligatoirenumberLe taux de frais taker le plus élevé que l’ordre accepte : au moins fees.rateBps de GET /api/funds (actuellement 700).
formfacultatif3 | 43 : une limite entre deux cents entiers (PMO3). 4 : un achat au marché par budget (PMO4). Absent pour PMO2.

Formats des messages

La clé de session signe ces octets : un préfixe ASCII de 4 octets puis les champs dans l’ordre, les entiers en little-endian (borsh). Choisissez la forme selon l’ordre :

OrdreFormeOctets
Ordre à cours limité ou vente à un cent entier ; vente au marchéPMO297
Ordre à cours limité ou vente entre deux cents entiers (en dessous de 1¢, au-dessus de 99¢)PMO398
Achat au marché, lorsque budgetBuys de GET /api/funds est activé (c’est le cas sur seeryx.com)PMO497
Achat au marché lorsque budgetBuys est désactivéPMO2 ou PMO3, parts calculées à partir du montant
Scission ou fusionPMS193

PMO2 : un ordre à un cent entier

DécalageOctetsChampEncodage
04prefix"PMO2" (ASCII)
432ownerla clé publique de votre portefeuille
3632marketle compte du marché
688idu64
761side0 = up, 1 = down
771action0 = achat, 1 = vente
781limitu8, cents entiers de 1 à 99
798sharesu64, unités de part (1 part = 10000)
878expiresAti64, secondes Unix
952maxFeeBpsu16

PMO3 : un ordre entre deux cents entiers

DécalageOctetsChampEncodage
04prefix"PMO3"
432owner
3632market
688idu64
761side0 = up, 1 = down
771action0 = achat, 1 = vente
782limitu16, dixièmes de cent (995 correspond à 99,5¢)
808sharesu64
888expiresAti64
962maxFeeBpsu16

PMO4 : un achat au marché par budget

Pas d’action (c’est toujours un achat) ni de nombre de parts. Le programme tient l’ordre à son budget sur l’ensemble des exécutions : il dépense donc la totalité de son montant jusqu’au cap, quel que soit l’écart sous le cap auquel il est exécuté.

DécalageOctetsChampEncodage
04prefix"PMO4"
432owner
3632market
688idu64
761side0 = up, 1 = down
772limitu16, dixièmes de cent : le pire prix de toute exécution
798budgetu64, micro-USDC : le montant maximal payé au total, frais compris
878expiresAti64
952maxFeeBpsu16

PMS1 : une scission ou une fusion

Son signed est { id, shares, expiresAt, key, signature } ; pour une scission, shares vaut le montant × 10000.

DécalageOctetsChampEncodage
04prefix"PMS1"
432owner
3632market
688idu64
761merge0 = scission, 1 = fusion
778sharesu64, paires en unités de part
858expiresAti64

Un achat au marché PMO2/PMO3 ne doit jamais pouvoir coûter plus que son montant : le serveur vérifie units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit en dixièmes de cent, frais arrondis à l’inférieur au micro-USDC près). affordable dans message.ts trouve la plus grande valeur de units qui satisfait cette condition. Un tel ordre s’arrête dès qu’il a obtenu ces unités (reason: "share_limit") et le reste du montant demeure dans vos liquidités ; PMO4 ne s’arrête jamais à ce stade.

Le compte du marché

market est l’adresse de programme dérivée de programId de GET /api/funds avec les graines (seeds) "market" et le slug en octets UTF-8, complété par des zéros jusqu’à 32 octets. owner est la clé publique de votre portefeuille, pas la clé de session.

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 construit les octets (sans dépendance ; vérifié octet par octet par rapport à l’encodage du programme lui-même). chain.ts ouvre la session, signe les ordres, les scissions et les fusions et envoie les transactions préparées.

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

Une requête signée

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 achat à cours limité PMO2 et un achat au marché PMO4 de 5 $ avec un cap de 60¢ (limite en dixièmes).

Quand le serveur refuse

Erreur 400Correction
Orders must be signed by your trading session; reload the pageAjoutez signed.
Malformed signed orderUn champ a le mauvais type : id, shares et budget sont en texte décimal ; limit, expiresAt et maxFeeBps sont des nombres entiers.
The signed order must expire by the round closeexpiresAt est dans le passé ou après la clôture.
The signed order expires too soon to settle; sign it againexpiresAt est à moins de 5 secondes dans le futur.
Your trading session expired; sign in to trading againkey n’est pas votre session sur la blockchain, ou elle se termine dans moins de 5 secondes : ouvrez une nouvelle session.
Your trading session ends before this order would; sign in to trading againUn ordre à cours limité resterait dans le carnet au-delà de la fin de la session.
The order signature is not validLes octets, owner, market ou la clé diffèrent de ce que le serveur a reconstruit à partir de l’ordre.
The signed order does not match the orderLa limite ou les parts signées diffèrent de celles de l’ordre.
The signed order exceeds the amountUn achat au marché PMO2/PMO3 a signé plus de parts que son montant ne permet d’en payer au cap.
The signed order does not match the amountUn budget PMO4 ne vaut pas amount × 1000000.
Only a market buy is signed with a budgetform 4 sur un ordre à cours limité ou une vente.
Market buys by budget are not available yet; reload the pageUn ordre PMO4 alors que budgetBuys est désactivé.
Trading fees changed; reload the page and place the order againmaxFeeBps est inférieur au taux actuel : relisez GET /api/funds.
This order was already submittedL’id a déjà été utilisé : signez chaque nouvelle tentative avec un nouvel id.
An order can spend at most $… in this trading sessionL’ordre dépasse le maxTrade de la session.
Your trading session limit is used up; sign in to trading againOuvrez une nouvelle session, avec une limite plus élevée si nécessaire.
Trading is paused after secure disconnect... (trading_paused)La clé a été fermée : ouvrez une nouvelle session et signez de nouveau.