API
Подпись ордеров
Как ключ торговой сессии Seeryx подписывает ордера: сообщения PMO2, PMO3 и PMO4 и пары PMS1 поле за полем, с кодом на TypeScript и Python.
Обзор
Каждая сделка на seeryx.com проходит расчёт в Solana через программу Seeryx, а программа принимает только ордера, подписанные для вашего счёта. Кошелёк подписывает один раз — чтобы открыть торговую сессию; затем ключ сессии подписывает каждый ордер, разделение и объединение коротким двоичным сообщением. Сервер проверяет подпись до того, как ордер попадёт в стакан, а программа проверяет её ещё раз при расчёте исполнения.
- Пополните счёт один раз. Ваш счёт в программе открывается первым пополнением (Пополнения и выводы).
- Откройте сессию со своим ключом: одна подпись кошелька, без SOL.
- Подписывайте каждый ордер этим ключом и отправляйте подпись вместе с ордером в поле
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 | string | Id ордера 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 | 4 | 3: лимит между целыми центами (PMO3). 4: рыночная покупка по бюджету (PMO4). Для PMO2 отсутствует. |
Форматы сообщений
Ключ сессии подписывает эти байты: 4-байтовый ASCII-префикс и поля по порядку, целые числа в little-endian (borsh). Форма выбирается по ордеру:
| Ордер | Форма | Байты |
|---|---|---|
| Лимитный ордер или продажа по целому центу; рыночная продажа | PMO2 | 97 |
| Лимитный ордер или продажа между целыми центами (ниже 1¢, выше 99¢) | PMO3 | 98 |
Рыночная покупка, если включён budgetBuys в GET /api/funds (на seeryx.com включён) | PMO4 | 97 |
| Рыночная покупка, если budgetBuys выключен | PMO2 или PMO3, доли рассчитываются из amount | |
| Разделение или объединение | PMS1 | 93 |
PMO2: ордер по целому центу
| Смещение | Байты | Поле | Кодирование |
|---|---|---|---|
| 0 | 4 | prefix | "PMO2" (ASCII) |
| 4 | 32 | owner | публичный ключ вашего кошелька |
| 36 | 32 | market | аккаунт рынка |
| 68 | 8 | id | u64 |
| 76 | 1 | side | 0 — up, 1 — down |
| 77 | 1 | action | 0 — покупка, 1 — продажа |
| 78 | 1 | limit | u8, целые центы 1–99 |
| 79 | 8 | shares | u64, единицы долей (1 доля = 10000) |
| 87 | 8 | expiresAt | i64, секунды Unix |
| 95 | 2 | maxFeeBps | u16 |
PMO3: ордер между целыми центами
| Смещение | Байты | Поле | Кодирование |
|---|---|---|---|
| 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 — покупка, 1 — продажа |
| 78 | 2 | limit | u16, в десятых цента (995 — это 99,5¢) |
| 80 | 8 | shares | u64 |
| 88 | 8 | expiresAt | i64 |
| 96 | 2 | maxFeeBps | u16 |
PMO4: рыночная покупка по бюджету
Без action (это всегда покупка) и без числа долей. Программа удерживает ордер в рамках бюджета по всем исполнениям, поэтому он тратит всю сумму до cap, насколько бы ниже cap ни проходили исполнения.
| Смещение | Байты | Поле | Кодирование |
|---|---|---|---|
| 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, в десятых цента: худшая цена любого исполнения |
| 79 | 8 | budget | u64, микро-USDC: максимальная общая сумма с учётом комиссии |
| 87 | 8 | expiresAt | i64 |
| 95 | 2 | maxFeeBps | u16 |
PMS1: разделение или объединение
Его signed — { id, shares, expiresAt, key, signature }; для разделения shares — это сумма × 10000.
| Смещение | Байты | Поле | Кодирование |
|---|---|---|---|
| 0 | 4 | prefix | "PMS1" |
| 4 | 32 | owner | |
| 36 | 32 | market | |
| 68 | 8 | id | u64 |
| 76 | 1 | merge | 0 — разделение, 1 — объединение |
| 77 | 8 | shares | u64, пары в единицах долей |
| 85 | 8 | expiresAt | i64 |
Рыночная покупка 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: 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 };
}Подписанный запрос
{
"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
}
}Когда сервер отклоняет запрос
| Ошибка 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 close | expiresAt в прошлом или позже закрытия. |
| The signed order expires too soon to settle; sign it again | До expiresAt меньше 5 секунд. |
| Your trading session expired; sign in to trading again | key не является вашей сессией в блокчейне, или сессия заканчивается в течение 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 budget | form 4 у лимитного ордера или продажи. |
| Market buys by budget are not available yet; reload the page | Ордер PMO4 при выключенном budgetBuys. |
| Trading fees changed; reload the page and place the order again | maxFeeBps ниже текущего размера комиссии: прочитайте 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) | Ключ был закрыт: откройте новую сессию и подпишите заново. |