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 додасть свій підпис і надішле її. Дочекайтеся підтвердження (будь-який 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 | 4 | 3: лімітна ціна між цілими центами (PMO3). 4: ринкова купівля за бюджетом (PMO4). Для PMO2 відсутнє. |
Формати повідомлень
Ключ сесії підписує такі байти: 4-байтовий префікс ASCII і поля по порядку, цілі числа в порядку little-endian (borsh). Форму вибирайте за типом ордера:
| Ордер | Форма | Байти |
|---|---|---|
| Лімітний ордер або продаж за цілим центом; ринковий продаж | PMO2 | 97 |
| Лімітний ордер або продаж між цілими центами (нижче 1¢, вище 99¢) | PMO3 | 98 |
Ринкова купівля, якщо в GET /api/funds увімкнено budgetBuys (на seeryx.com увімкнено) | PMO4 | 97 |
| Ринкова купівля, якщо budgetBuys вимкнено | PMO2 або PMO3, частки розраховано із суми | |
| Розділення або об’єднання | 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 частка = 10 000) |
| 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 ніколи не повинна мати змоги коштувати більше за свою суму: сервер перевіряє 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: 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 | Байти, власник, ринок або ключ відрізняються від того, що сервер відтворив з ордера. |
| 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 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) | Ключ закрито: відкрийте нову сесію й підпишіть знову. |