inicio

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.

  1. Deposita una vez. Tu cuenta en el programa se abre con tu primer depósito (Depósitos y retiros).
  2. Abre una sesión con una clave propia: una firma de la billetera, sin SOL.
  3. 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:

CampoTipoDescripción
actionobligatorio"session"
keyobligatoriostringLa clave pública de la sesión, en base58.
expiresAtobligatorionumberSegundos Unix, como máximo maxSessionTtl de GET /api/funds a partir de ahora. Los ejemplos usan ese límite menos 2 minutos.
limitobligatorionumberLos USDC que la sesión puede gastar en compras y divisiones en total.
maxTradeobligatorionumberLos 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

CampoTipoDescripción
idobligatoriostringUn 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.
limitobligatorionumberEl 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.
sharesopcionalstringUnidades 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.
budgetopcionalstringSolo PMO4: el amount de la compra de mercado en micro-USDC como texto decimal, exactamente amount × 1000000.
expiresAtobligatorionumberSegundos 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.
keyobligatoriostringLa clave pública de la sesión, en base58.
signatureobligatoriostringLa firma Ed25519 del mensaje de abajo hecha con la clave de sesión, 128 caracteres hexadecimales.
maxFeeBpsobligatorionumberLa tasa de comisión taker más alta que acepta la orden: al menos fees.rateBps de GET /api/funds (ahora, 700).
formopcional3 | 43: 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:

OrdenFormaBytes
Orden límite o venta a un centavo entero; venta de mercadoPMO297
Orden límite o venta entre centavos enteros (por debajo de 1¢ o por encima de 99¢)PMO398
Compra de mercado, cuando budgetBuys de GET /api/funds está activado (lo está en seeryx.com)PMO497
Compra de mercado cuando budgetBuys está desactivadoPMO2 o PMO3, con las acciones calculadas a partir del importe
División o combinaciónPMS193

PMO2: una orden a un centavo entero

DesplazamientoBytesCampoCodificación
04prefix"PMO2" (ASCII)
432ownerla clave pública de tu billetera
3632marketla cuenta del mercado
688idu64
761side0: up, 1: down
771action0: compra, 1: venta
781limitu8, centavos enteros de 1 a 99
798sharesu64, unidades de acción (1 acción = 10.000)
878expiresAti64, segundos Unix
952maxFeeBpsu16

PMO3: una orden entre centavos enteros

DesplazamientoBytesCampoCodificación
04prefix"PMO3"
432owner
3632market
688idu64
761side0: up, 1: down
771action0: compra, 1: venta
782limitu16, décimas de centavo (995 son 99,5¢)
808sharesu64
888expiresAti64
962maxFeeBpsu16

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.

DesplazamientoBytesCampoCodificación
04prefix"PMO4"
432owner
3632market
688idu64
761side0: up, 1: down
772limitu16, décimas de centavo: el peor precio de cualquier ejecución
798budgetu64, micro-USDC: lo máximo que paga en total, comisión incluida
878expiresAti64
952maxFeeBpsu16

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.

DesplazamientoBytesCampoCodificación
04prefix"PMS1"
432owner
3632market
688idu64
761merge0: división, 1: combinación
778sharesu64, pares en unidades de acción
858expiresAti64

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

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
  }
}
Una compra límite PMO2 y una compra de mercado PMO4 de 5 $ con un cap de 60¢ (limit en décimas).

Cuando el servidor rechaza

Error 400Solución
Orders must be signed by your trading session; reload the pageAñade signed.
Malformed signed orderUn 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 closeexpiresAt está en el pasado o es posterior al cierre.
The signed order expires too soon to settle; sign it againexpiresAt está a menos de 5 segundos en el futuro.
Your trading session expired; sign in to trading againkey 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 againUna orden límite quedaría en el libro más allá del final de la sesión.
The order signature is not validLos 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 orderEl límite o las acciones firmados difieren de los de la orden.
The signed order exceeds the amountUna compra de mercado PMO2/PMO3 firmó más acciones de las que su importe paga al cap.
The signed order does not match the amountUn presupuesto PMO4 no es amount × 1000000.
Only a market buy is signed with a budgetform 4 en una orden límite o una venta.
Market buys by budget are not available yet; reload the pageUna orden PMO4 cuando budgetBuys está desactivado.
Trading fees changed; reload the page and place the order againmaxFeeBps está por debajo de la tasa actual: vuelve a leer GET /api/funds.
This order was already submittedEl id ya se usó: firma cada intento nuevo con un id nuevo.
An order can spend at most $… in this trading sessionLa orden supera el maxTrade de la sesión.
Your trading session limit is used up; sign in to trading againAbre 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.