главная

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 добавит свою подпись и отправит её. Дождитесь подтверждения (любой Solana RPC, getSignatureStatuses), затем подписывайте ордера ключом. tradingSession в chain.ts делает всё это и хранит ключ в session.json.

Подписанный объект

ПолеТипОписание
idобязательноstringId ордера u64 в виде десятичной строки, уникальный для вашего кошелька и никогда не используемый повторно. В примерах это время в миллисекундах × 2^20 плюс 20 случайных бит.
limitобязательноnumberПодписываемая цена. Лимитный ордер подписывает свой limit, рыночный — свой cap: в целых центах в PMO2, в десятых цента с form: 3 или form: 4.
sharesнеобязательноstringЕдиницы долей (1 доля = 10000) в виде десятичной строки, во всех формах, кроме PMO4. Лимитный ордер или продажа подписывает ровно shares × 10000; рыночная покупка PMO2/PMO3 — максимальное число единиц, которое её amount покупает по 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
Рыночная покупка, если включён budgetBuys в GET /api/funds (на seeryx.com включён)PMO497
Рыночная покупка, если budgetBuys выключенPMO2 или PMO3, доли рассчитываются из amount
Разделение или объединениеPMS193

PMO2: ордер по целому центу

СмещениеБайтыПолеКодирование
04prefix"PMO2" (ASCII)
432ownerпубличный ключ вашего кошелька
3632marketаккаунт рынка
688idu64
761side0 — up, 1 — down
771action0 — покупка, 1 — продажа
781limitu8, целые центы 1–99
798sharesu64, единицы долей (1 доля = 10000)
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 ни при каких условиях не должна стоить больше своего amount: сервер проверяет units × limit / 10 + fee(units, limit) ≤ amount × 1000000 (limit в десятых цента, комиссия округляется вниз до микро-USDC). affordable в message.ts находит наибольшее такое units. Такой ордер останавливается, набрав эти единицы (reason: "share_limit"), а остаток суммы остаётся в ваших средствах; PMO4 на этом никогда не останавливается.

Аккаунт рынка

market — адрес программы (PDA), производный от 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 againДо expiresAt меньше 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Байты, owner, market или key отличаются от того, что сервер восстановил из ордера.
The signed order does not match the orderПодписанные limit или shares отличаются от значений ордера.
The signed order exceeds the amountРыночная покупка PMO2/PMO3 подписала больше долей, чем её amount оплачивает по 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)Ключ был закрыт: откройте новую сессию и подпишите заново.