API
Firma de órdenes
Cómo firma órdenes la clave de sesión de trading de Seeryx: los mensajes de orden PMO2, PMO3 y PMO4 y los pares PMS1, campo por campo, en TypeScript y Python.
Descripción general
Cada operación en seeryx.com se liquida en Solana a través del programa de Seeryx, y el programa solo acepta órdenes firmadas para tu cuenta. Tu billetera firma una vez, para abrir una sesión de trading; después, la clave de sesión firma cada orden, división y combinación como un breve mensaje binario. El servidor comprueba la firma antes de que la orden llegue al libro, y el programa la vuelve a comprobar cuando se liquida la ejecución.
- Deposita una vez. Tu cuenta en el programa se abre con tu primer depósito (Depósitos y retiros).
- Abre una sesión con una clave propia: una firma de la billetera, sin SOL.
- Firma cada orden con esa clave y envía la firma con la orden como
signed.
Abrir una sesión de trading
Crea un par de claves Ed25519 y, después, llama a POST /api/relay:
| Campo | Tipo | Descripción |
|---|---|---|
action | "session" | |
key | string | La clave pública de la sesión, en base58. |
expiresAt | number | Segundos Unix, como máximo maxSessionTtl de GET /api/funds a partir de ahora. Los ejemplos usan ese límite menos 2 minutos. |
limit | number | Los USDC que la sesión puede gastar en compras y divisiones en total. |
maxTrade | number | Los USDC que puede gastar una orden; como máximo, limit. |
La respuesta es { "transaction": "<base64>" }: una transacción v0 de Solana con Seeryx como pagador de la comisión y todos los espacios de firma vacíos. Fírmala con la billetera sin enviarla y envíala con POST /api/relay/submit: Seeryx añade su propia firma y la envía. Espera a que se confirme (con cualquier RPC de Solana, getSignatureStatuses) y, después, firma órdenes con la clave. tradingSession en chain.ts hace todo esto y guarda la clave en session.json.
El objeto firmado
| Campo | Tipo | Descripción |
|---|---|---|
id | string | Un id de orden u64 como texto decimal, único para tu billetera y nunca reutilizado. Los ejemplos usan la hora en milisegundos × 2^20 más 20 bits aleatorios. |
limit | number | El precio firmado. Una orden límite firma su límite; una orden de mercado, su cap: centavos enteros en PMO2, décimas de centavo con form: 3 o form: 4. |
shares | string | Unidades de acción (1 acción = 10.000) como texto decimal, en todas las formas salvo PMO4. Una orden límite o una venta firma exactamente sus acciones × 10.000; una compra de mercado PMO2/PMO3, el máximo de unidades que su importe compra al cap con la comisión. |
budget | string | Solo PMO4: el amount de la compra de mercado en micro-USDC como texto decimal, exactamente amount × 1000000. |
expiresAt | number | Segundos Unix: el cierre para una orden límite; 5 minutos a partir de ahora (nunca más allá del cierre) para una orden de mercado. Consulta Vencimiento. |
key | string | La clave pública de la sesión, en base58. |
signature | string | La firma Ed25519 del mensaje de abajo hecha con la clave de sesión, 128 caracteres hexadecimales. |
maxFeeBps | number | La tasa de comisión taker más alta que acepta la orden: al menos fees.rateBps de GET /api/funds (ahora, 700). |
form | 3 | 4 | 3: un límite entre centavos enteros (PMO3). 4: una compra de mercado por presupuesto (PMO4). Ausente en PMO2. |
Formatos de los mensajes
La clave de sesión firma estos bytes: un prefijo ASCII de 4 bytes y los campos en orden, con los enteros en little-endian (borsh). Elige la forma según la orden:
| Orden | Forma | Bytes |
|---|---|---|
| Orden límite o venta a un centavo entero; venta de mercado | PMO2 | 97 |
| Orden límite o venta entre centavos enteros (por debajo de 1¢ o por encima de 99¢) | PMO3 | 98 |
Compra de mercado, cuando budgetBuys de GET /api/funds está activado (lo está en seeryx.com) | PMO4 | 97 |
| Compra de mercado cuando budgetBuys está desactivado | PMO2 o PMO3, con las acciones calculadas a partir del importe | |
| División o combinación | PMS1 | 93 |
PMO2: una orden a un centavo entero
| Desplazamiento | Bytes | Campo | Codificación |
|---|---|---|---|
| 0 | 4 | prefix | "PMO2" (ASCII) |
| 4 | 32 | owner | la clave pública de tu billetera |
| 36 | 32 | market | la cuenta del mercado |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0: up, 1: down |
| 77 | 1 | action | 0: compra, 1: venta |
| 78 | 1 | limit | u8, centavos enteros de 1 a 99 |
| 79 | 8 | shares | u64, unidades de acción (1 acción = 10.000) |
| 87 | 8 | expiresAt | i64, segundos Unix |
| 95 | 2 | maxFeeBps | u16 |
PMO3: una orden entre centavos enteros
| Desplazamiento | Bytes | Campo | Codificación |
|---|---|---|---|
| 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: compra, 1: venta |
| 78 | 2 | limit | u16, décimas de centavo (995 son 99,5¢) |
| 80 | 8 | shares | u64 |
| 88 | 8 | expiresAt | i64 |
| 96 | 2 | maxFeeBps | u16 |
PMO4: una compra de mercado por presupuesto
Sin campo action (siempre es una compra) ni número de acciones. El programa limita la orden a su presupuesto a lo largo de todas sus ejecuciones, así que gasta todo su importe hasta el cap, por mucho que se ejecute por debajo del cap.
| Desplazamiento | Bytes | Campo | Codificación |
|---|---|---|---|
| 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, décimas de centavo: el peor precio de cualquier ejecución |
| 79 | 8 | budget | u64, micro-USDC: lo máximo que paga en total, comisión incluida |
| 87 | 8 | expiresAt | i64 |
| 95 | 2 | maxFeeBps | u16 |
PMS1: una división o una combinación
Su signed es { id, shares, expiresAt, key, signature }; en una división, shares es el importe × 10.000.
| Desplazamiento | Bytes | Campo | Codificación |
|---|---|---|---|
| 0 | 4 | prefix | "PMS1" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | merge | 0: división, 1: combinación |
| 77 | 8 | shares | u64, pares en unidades de acción |
| 85 | 8 | expiresAt | i64 |
Una compra de mercado PMO2/PMO3 nunca debe poder costar más que su importe: el servidor comprueba units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit en décimas de centavo, comisión redondeada hacia abajo al micro-USDC). affordable en message.ts encuentra el mayor units que lo cumple. Una orden así se detiene cuando tiene esas unidades (reason: "share_limit") y el resto del importe se queda en tu efectivo; PMO4 nunca se detiene ahí.
La cuenta del mercado
market es la dirección de programa derivada de programId de GET /api/funds con las semillas "market" y el slug como bytes UTF-8, rellenado con ceros hasta 32. owner es la clave pública de tu billetera, no la clave de sesión.
const id = new Uint8Array(32);
id.set(new TextEncoder().encode(slug));
const [market] = await getProgramDerivedAddress({ programAddress: address(funds.programId), seeds: ['market', id] });Código
message.ts construye los bytes (sin dependencias; verificado byte a byte con la codificación del propio programa). chain.ts abre la sesión, firma órdenes, divisiones y combinaciones y envía transacciones preparadas.
// 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 solicitud firmada
{
"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
}
}Cuando el servidor rechaza
| Error 400 | Solución |
|---|---|
| Orders must be signed by your trading session; reload the page | Añade signed. |
| Malformed signed order | Un campo tiene un tipo incorrecto: id, shares y budget son texto decimal; limit, expiresAt y maxFeeBps, números enteros. |
| The signed order must expire by the round close | expiresAt está en el pasado o es posterior al cierre. |
| The signed order expires too soon to settle; sign it again | expiresAt está a menos de 5 segundos en el futuro. |
| Your trading session expired; sign in to trading again | key no es tu sesión en la cadena de bloques, o termina en menos de 5 segundos: abre una sesión nueva. |
| Your trading session ends before this order would; sign in to trading again | Una orden límite quedaría en el libro más allá del final de la sesión. |
| The order signature is not valid | Los bytes, el propietario, el mercado o la clave difieren de lo que el servidor reconstruyó a partir de la orden. |
| The signed order does not match the order | El límite o las acciones firmados difieren de los de la orden. |
| The signed order exceeds the amount | Una compra de mercado PMO2/PMO3 firmó más acciones de las que su importe paga al cap. |
| The signed order does not match the amount | Un presupuesto PMO4 no es amount × 1000000. |
| Only a market buy is signed with a budget | form 4 en una orden límite o una venta. |
| Market buys by budget are not available yet; reload the page | Una orden PMO4 cuando budgetBuys está desactivado. |
| Trading fees changed; reload the page and place the order again | maxFeeBps está por debajo de la tasa actual: vuelve a leer GET /api/funds. |
| This order was already submitted | El id ya se usó: firma cada intento nuevo con un id nuevo. |
| An order can spend at most $… in this trading session | La orden supera el maxTrade de la sesión. |
| Your trading session limit is used up; sign in to trading again | Abre una sesión nueva, con un límite mayor si lo necesitas. |
Trading is paused after secure disconnect... (trading_paused) | La clave se cerró: abre una sesión nueva y vuelve a firmar. |