головна сторінка

API

Підписання ордерів

Як ключ торгової сесії Seeryx підписує ордери: повідомлення PMO2, PMO3 і PMO4 та пари PMS1 поле за полем, з кодом на TypeScript і Python.

Огляд

Кожна угода на seeryx.com проходить розрахунок у мережі Solana через програму Seeryx, а програма приймає лише ордери, підписані для вашого акаунта. Ваш гаманець підписує один раз — щоб відкрити торгову сесію; далі ключ сесії підписує кожен ордер, розділення й об’єднання як коротке двійкове повідомлення. Сервер перевіряє підпис, перш ніж ордер потрапить у книгу, а програма перевіряє його ще раз під час розрахунку за виконанням.

  1. Поповніть рахунок один раз. Ваш рахунок у програмі відкривається з першим поповненням («Поповнення й виведення»).
  2. Відкрийте сесію з власним ключем: один підпис гаманця, без SOL.
  3. Підписуйте кожен ордер цим ключем і надсилайте підпис разом з ордером як signed.

Відкрийте торгову сесію

Створіть пару ключів Ed25519, а потім викличте POST /api/relay:

ПолеТипОпис
actionобов’язкове"session"
keyобов’язковеstringПублічний ключ сесії в base58.
expiresAtобов’язковеnumberСекунди Unix, щонайбільше через maxSessionTtl з GET /api/funds від цього моменту. Приклади використовують цей ліміт мінус 2 хвилини.
limitобов’язковеnumberСкільки USDC сесія може витратити на купівлі й розділення загалом.
maxTradeобов’язковеnumberСкільки USDC може витратити один ордер; не більше за limit.

Відповідь — { "transaction": "<base64>" }: транзакція Solana v0, у якій платником комісії є Seeryx, а всі слоти підписів порожні. Підпишіть її гаманцем, не надсилаючи, і передайте через POST /api/relay/submit: Seeryx додасть свій підпис і надішле її. Дочекайтеся підтвердження (будь-який RPC Solana, getSignatureStatuses), а потім підписуйте ордери ключем. tradingSession у chain.ts робить усе це й зберігає ключ у session.json.

Підписаний об’єкт

ПолеТипОпис
idобов’язковеstringІдентифікатор ордера u64 як десятковий текст, унікальний для вашого гаманця й ніколи не повторюваний. Приклади використовують час у мілісекундах × 2^20 плюс 20 випадкових бітів.
limitобов’язковеnumberПідписана ціна. Лімітний ордер підписує свій limit, ринковий — свій cap: цілі центи в PMO2, десяті частки цента з form: 3 або form: 4.
sharesнеобов’язковеstringОдиниці часток (1 частка = 10 000) як десятковий текст, у всіх формах, крім PMO4. Лімітний ордер або продаж підписує рівно свої shares × 10000; ринкова купівля PMO2/PMO3 — найбільшу кількість одиниць, яку її сума купує за cap з урахуванням комісії.
budgetнеобов’язковеstringЛише PMO4: amount ринкової купівлі в мікро-USDC як десятковий текст, рівно amount × 1000000.
expiresAtобов’язковеnumberСекунди Unix: для лімітного ордера — закриття, для ринкового — 5 хвилин наперед (але ніколи не далі закриття). Див. «Термін дії».
keyобов’язковеstringПублічний ключ сесії в base58.
signatureобов’язковеstringПідпис Ed25519 наведеного нижче повідомлення ключем сесії, 128 шістнадцяткових символів.
maxFeeBpsобов’язковеnumberНайбільший розмір комісії тейкера, який приймає ордер: щонайменше fees.rateBps з GET /api/funds (зараз 700).
formнеобов’язкове3 | 43: лімітна ціна між цілими центами (PMO3). 4: ринкова купівля за бюджетом (PMO4). Для PMO2 відсутнє.

Формати повідомлень

Ключ сесії підписує такі байти: 4-байтовий префікс ASCII і поля по порядку, цілі числа в порядку little-endian (borsh). Форму вибирайте за типом ордера:

ОрдерФормаБайти
Лімітний ордер або продаж за цілим центом; ринковий продажPMO297
Лімітний ордер або продаж між цілими центами (нижче 1¢, вище 99¢)PMO398
Ринкова купівля, якщо в GET /api/funds увімкнено budgetBuys (на seeryx.com увімкнено)PMO497
Ринкова купівля, якщо budgetBuys вимкненоPMO2 або PMO3, частки розраховано із суми
Розділення або об’єднанняPMS193

PMO2: ордер за цілим центом

ЗсувБайтиПолеКодування
04prefix"PMO2" (ASCII)
432ownerпублічний ключ вашого гаманця
3632marketакаунт ринку
688idu64
761side0 — up, 1 — down
771action0 — купівля, 1 — продаж
781limitu8, цілі центи 1–99
798sharesu64, одиниці часток (1 частка = 10 000)
878expiresAti64, секунди Unix
952maxFeeBpsu16

PMO3: ордер між цілими центами

ЗсувБайтиПолеКодування
04prefix"PMO3"
432owner
3632market
688idu64
761side0 — up, 1 — down
771action0 — купівля, 1 — продаж
782limitu16, десяті частки цента (995 — це 99,5¢)
808sharesu64
888expiresAti64
962maxFeeBpsu16

PMO4: ринкова купівля за бюджетом

Без action (це завжди купівля) і без кількості часток. Програма утримує ордер у межах його бюджету в усіх виконаннях, тож він витрачає всю свою суму в межах cap, хоч би наскільки нижче за cap він виконувався.

ЗсувБайтиПолеКодування
04prefix"PMO4"
432owner
3632market
688idu64
761side0 — up, 1 — down
772limitu16, десяті частки цента: найгірша ціна будь-якого виконання
798budgetu64, мікро-USDC: найбільша сума, яку він сплачує загалом, разом із комісією
878expiresAti64
952maxFeeBpsu16

PMS1: розділення або об’єднання

Його signed — { id, shares, expiresAt, key, signature }; для розділення shares дорівнює сумі × 10000.

ЗсувБайтиПолеКодування
04prefix"PMS1"
432owner
3632market
688idu64
761merge0 — розділення, 1 — об’єднання
778sharesu64, пари в одиницях часток
858expiresAti64

Ринкова купівля PMO2/PMO3 ніколи не повинна мати змоги коштувати більше за свою суму: сервер перевіряє units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit у десятих частках цента, комісія округлюється вниз до мікро-USDC). affordable у message.ts знаходить найбільше таке units. Такий ордер зупиняється, щойно отримає ці одиниці (reason: "share_limit"), а решта суми залишається у ваших коштах; PMO4 на цьому ніколи не зупиняється.

Акаунт ринку

market — адреса програми, похідна від programId з GET /api/funds із seed-значеннями "market" і slug у вигляді байтів UTF-8, доповненого нулями до 32. owner — публічний ключ вашого гаманця, а не ключ сесії.

const id = new Uint8Array(32);
id.set(new TextEncoder().encode(slug));
const [market] = await getProgramDerivedAddress({ programAddress: address(funds.programId), seeds: ['market', id] });

Код

message.ts формує байти (без залежностей; звірено байт за байтом із власним кодуванням програми). chain.ts відкриває сесію, підписує ордери, розділення й об’єднання та надсилає підготовлені транзакції.

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

Підписаний запит

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
  }
}
Лімітна купівля PMO2 і ринкова купівля PMO4 на 5 $ з cap 60¢ (limit у десятих частках цента).

Коли сервер відхиляє запит

Помилка 400Що виправити
Orders must be signed by your trading session; reload the pageДодайте signed.
Malformed signed orderПоле має неправильний тип: id, shares і budget — десятковий текст; limit, expiresAt і maxFeeBps — цілі числа.
The signed order must expire by the round closeexpiresAt у минулому або після закриття.
The signed order expires too soon to settle; sign it againexpiresAt менш ніж на 5 секунд наперед.
Your trading session expired; sign in to trading againkey не є вашою сесією в блокчейні або вона завершується протягом 5 секунд: відкрийте нову сесію.
Your trading session ends before this order would; sign in to trading againЛімітний ордер стояв би в книзі після завершення сесії.
The order signature is not validБайти, власник, ринок або ключ відрізняються від того, що сервер відтворив з ордера.
The signed order does not match the orderПідписані limit або shares відрізняються від значень в ордері.
The signed order exceeds the amountРинкова купівля PMO2/PMO3 підписала більше часток, ніж її сума оплачує за cap.
The signed order does not match the amountБюджет PMO4 не дорівнює amount × 1000000.
Only a market buy is signed with a budgetform 4 у лімітному ордері або продажу.
Market buys by budget are not available yet; reload the pageОрдер PMO4, коли budgetBuys вимкнено.
Trading fees changed; reload the page and place the order againmaxFeeBps нижчий за поточний розмір комісії: прочитайте GET /api/funds ще раз.
This order was already submittedЦей id уже використовувався: підписуйте кожну нову спробу з новим id.
An order can spend at most $… in this trading sessionОрдер перевищує maxTrade сесії.
Your trading session limit is used up; sign in to trading againВідкрийте нову сесію, за потреби — з більшим limit.
Trading is paused after secure disconnect... (trading_paused)Ключ закрито: відкрийте нову сесію й підпишіть знову.