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.
- Déposez une fois. Votre compte dans le programme s’ouvre avec votre premier dépôt (Dépôts et retraits).
- Ouvrez une session avec une clé qui vous appartient : une signature du portefeuille, sans SOL.
- 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 :
| Champ | Type | Description |
|---|---|---|
action | "session" | |
key | string | La clé publique de session, en base58. |
expiresAt | number | Secondes Unix, au plus maxSessionTtl de GET /api/funds à partir de maintenant. Les exemples utilisent cette limite moins 2 minutes. |
limit | number | Les USDC que la session peut dépenser au total en achats et en scissions. |
maxTrade | number | Les 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é
| Champ | Type | Description |
|---|---|---|
id | string | Un 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. |
limit | number | Le 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. |
shares | string | Unité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. |
budget | string | PMO4 uniquement : le champ amount de l’achat au marché en micro-USDC, en texte décimal, exactement amount × 1000000. |
expiresAt | number | Secondes 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. |
key | string | La clé publique de session, en base58. |
signature | string | La signature Ed25519 du message ci-dessous par la clé de session, en 128 caractères hexadécimaux. |
maxFeeBps | number | Le taux de frais taker le plus élevé que l’ordre accepte : au moins fees.rateBps de GET /api/funds (actuellement 700). |
form | 3 | 4 | 3 : 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 :
| Ordre | Forme | Octets |
|---|---|---|
| Ordre à cours limité ou vente à un cent entier ; vente au marché | PMO2 | 97 |
| Ordre à cours limité ou vente entre deux cents entiers (en dessous de 1¢, au-dessus de 99¢) | PMO3 | 98 |
Achat au marché, lorsque budgetBuys de GET /api/funds est activé (c’est le cas sur seeryx.com) | PMO4 | 97 |
| Achat au marché lorsque budgetBuys est désactivé | PMO2 ou PMO3, parts calculées à partir du montant | |
| Scission ou fusion | PMS1 | 93 |
PMO2 : un ordre à un cent entier
| Décalage | Octets | Champ | Encodage |
|---|---|---|---|
| 0 | 4 | prefix | "PMO2" (ASCII) |
| 4 | 32 | owner | la clé publique de votre portefeuille |
| 36 | 32 | market | le compte du marché |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 = up, 1 = down |
| 77 | 1 | action | 0 = achat, 1 = vente |
| 78 | 1 | limit | u8, cents entiers de 1 à 99 |
| 79 | 8 | shares | u64, unités de part (1 part = 10000) |
| 87 | 8 | expiresAt | i64, secondes Unix |
| 95 | 2 | maxFeeBps | u16 |
PMO3 : un ordre entre deux cents entiers
| Décalage | Octets | Champ | Encodage |
|---|---|---|---|
| 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 = achat, 1 = vente |
| 78 | 2 | limit | u16, dixièmes de cent (995 correspond à 99,5¢) |
| 80 | 8 | shares | u64 |
| 88 | 8 | expiresAt | i64 |
| 96 | 2 | maxFeeBps | u16 |
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écalage | Octets | Champ | Encodage |
|---|---|---|---|
| 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, dixièmes de cent : le pire prix de toute exécution |
| 79 | 8 | budget | u64, micro-USDC : le montant maximal payé au total, frais compris |
| 87 | 8 | expiresAt | i64 |
| 95 | 2 | maxFeeBps | u16 |
PMS1 : une scission ou une fusion
Son signed est { id, shares, expiresAt, key, signature } ; pour une scission, shares vaut le montant × 10000.
| Décalage | Octets | Champ | Encodage |
|---|---|---|---|
| 0 | 4 | prefix | "PMS1" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | merge | 0 = scission, 1 = fusion |
| 77 | 8 | shares | u64, paires en unités de part |
| 85 | 8 | expiresAt | i64 |
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: 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 };
}Une requête signée
{
"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
}
}Quand le serveur refuse
| Erreur 400 | Correction |
|---|---|
| Orders must be signed by your trading session; reload the page | Ajoutez signed. |
| Malformed signed order | Un 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 close | expiresAt est dans le passé ou après la clôture. |
| The signed order expires too soon to settle; sign it again | expiresAt est à moins de 5 secondes dans le futur. |
| Your trading session expired; sign in to trading again | key 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 again | Un ordre à cours limité resterait dans le carnet au-delà de la fin de la session. |
| The order signature is not valid | Les 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 order | La limite ou les parts signées diffèrent de celles de l’ordre. |
| The signed order exceeds the amount | Un 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 amount | Un budget PMO4 ne vaut pas amount × 1000000. |
| Only a market buy is signed with a budget | form 4 sur un ordre à cours limité ou une vente. |
| Market buys by budget are not available yet; reload the page | Un ordre PMO4 alors que budgetBuys est désactivé. |
| Trading fees changed; reload the page and place the order again | maxFeeBps est inférieur au taux actuel : relisez GET /api/funds. |
| This order was already submitted | L’id a déjà été utilisé : signez chaque nouvelle tentative avec un nouvel id. |
| An order can spend at most $… in this trading session | L’ordre dépasse le maxTrade de la session. |
| Your trading session limit is used up; sign in to trading again | Ouvrez 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. |