LP integration — LP bot¶
Availability
All /v1/lp-bot/* routes return 403 { "error": "lp_disabled" } when LP is
not currently open.
Trust model: the user's main key signs one delegation message authorizing
a server-generated session key, which then signs orders on your behalf on cron
ticks. The session key has no withdraw scope — withdraw is never
delegatable. Stop is instant; delegations expire.
Same endpoints serve both the Earn-modal UI and direct API/bot callers — there is no separate "web" vs "API" path.
Lifecycle¶
1. POST /v1/lp-bot/session/new → server makes a keypair, returns session_pub + canonical message
2. POST /v1/lp-bot/delegate → you sign that message and submit it
3. POST /v1/lp-bot/start → start a basket bot bound to the delegated session
4. GET /v1/lp-bot/status?user=… → poll state
5. POST /v1/lp-bot/stop → stop (optionally revoke the session)
POST /v1/lp-bot/session/new¶
expires_in_secs optional (default 6h; clamped to [300, 2592000]). scope
optional (default 'orders'; MUST include 'orders'). Response:
{
"session_pub": "<base58 server-generated Ed25519 pubkey>",
"expires_at_unix": 1750721600,
"nonce": 1750700001,
"scope": "orders",
"message_text": "Parti LP Delegation\nNetwork: parti-fogo-testnet\nUser: …\nSession: …\nScope: orders\nExpires: …\nNonce: …",
"message_bytes_hex": "<hex of the exact bytes to sign>",
"max_capital_micro": 0
}
Errors: 400 invalid_pubkey, 400 scope_missing.
Canonical delegation message¶
This is the exact canonical delegation message (no trailing newline). The
Network: line (ASCII parti-fogo-testnet / parti-fogo-mainnet) binds the
delegation to one network/env:
Parti LP Delegation
Network: {parti-fogo-testnet | parti-fogo-mainnet}
User: {user}
Session: {session_pub}
Scope: {scope}
Expires: {expires_at_unix}
Nonce: {nonce}
Always sign the exact bytes the server hands back
session/new returns both message_text and message_bytes_hex with the
correct Network: line baked in; signing a self-built message without the tag
fails verification. 64-byte Ed25519, hex-encoded, signed with your main
wallet key.
POST /v1/lp-bot/delegate¶
{
"user": "<base58 pubkey>",
"session_pub": "<from session/new>",
"scope": "orders",
"expires_at_unix": 1750721600,
"nonce": 1750700001,
"signature": "<64-byte hex Ed25519 over the canonical message>",
"max_capital_micro": 0
}
scope optional (default 'orders'; 'withdraw' is rejected). expires_at_unix
must be in [now+5min, now+30d]. nonce must be strictly > the user's prior
delegation nonce.
Success: { "ok": true, "session_pub", "user", "scope": "orders",
"expires_at_unix", "max_capital_micro" }.
Errors: 400 invalid_json, 400 invalid_pubkey, 400 scope_forbidden (scope
contains withdraw), 400 scope_missing, 400 expiry_out_of_range (detail
gives [min, max]), 400 invalid_signature_format, 403 invalid_signature,
422 nonce_stale (detail: "must be > <n>"), 409 session_already_delegated,
500 db_error.
POST /v1/lp-bot/start¶
{
"user": "<base58 pubkey>",
"session_pub": "<delegated session>",
"basket": {
"category": "sports",
"symbol": "…",
"cadence": "…",
"topN": 10,
"volumeFloorUsd": 1000,
"maxSharePct": 0.05,
"totalCapitalUsd": 500
},
"strategy": {
"preset": "balanced",
"halfSpreadBps": 50,
"numLevels": 2,
"rebalanceSec": 15
}
}
topN 1–20 (clamped server-side). maxSharePct is a fraction in [0.01, 1] —
0.05 caps any one market at 5%, not 5. preset ∈
conservative | balanced | aggressive (default halfSpreadBps 200/100/50).
numLevels 1–5. rebalanceSec is advisory; rebalancing runs every minute.
Behavior: validates the session is delegated, active, unrevoked, unexpired, and
owned by user. Any prior active bot for the user is auto-stopped (one active
bot per user — calling start again replaces, it does not 409).
Success: { "ok": true, "config_id": "123", "started_at": "2026-06-24 22:10:13.61+00" }.
Stringly-typed returns
config_id comes back as a string (DB serialises BIGINT as a string) and
started_at is YYYY-MM-DD HH:MM:SS.ssssss+00, not ISO-8601. Coerce with
Number() / new Date(s.replace(' ', 'T')). stop likewise returns
stopped as a string.
Errors: 400 invalid_shape, 400 invalid_pubkey, 404 session_not_delegated,
403 session_revoked, 403 session_expired, 403 user_mismatch.
GET /v1/lp-bot/status?user={pubkey}¶
{
"bot": {
"config_id": 123, "status": "active",
"status_msg": "…", "basket_config": { … }, "strategy_config": { … },
"last_tick_at": "…", "last_tick_summary": { … }, "tick_count": 42,
"started_at": "…", "stopped_at": null, "session_pub": "…"
}
}
status ∈ active | paused | stopped. If the user has never started a bot:
{ "bot": null }. Error: 400 invalid_pubkey.
POST /v1/lp-bot/stop¶
Flips the active config to status='stopped'. With revoke_session: true, also
sets revoked_at so the session can never sign again (recommended). The managed
bot cancels open orders on its next tick.
Success: { "ok": true, "stopped": <config_id> }, or
{ "ok": true, "message": "no active bot" }. Error: 400 invalid_pubkey.
How rewards accrue¶
Once a bot is active, Parti's reward distribution (runs every minute) resolves your
basket against the live /v1/markets list, derives a mid from each market's book,
and places resting maker orders signed by the delegated session key. Resting
liquidity inside the rewards band is scored each minute; the day's scores are
snapshotted at 00:00 UTC and the daily
budget is distributed proportionally. You don't claim — payouts are credited to your
Parti balance. Read accrual with GET /v1/rewards/me?user={pubkey}:
{
"user": "…", "day_key": "2026-06-24",
"today_score": 1234.5, "today_total_score": 9876.0,
"today_projected_micro_usdc": 125000,
"lifetime_micro_usdc": 4200000,
"last_7d_micro_usdc": 980000,
"last_paid_day": "2026-06-23"
}
today_projected_micro_usdcis a projection of the running day; it is only realised intolifetime_micro_usdcafter the 00:00 UTC finaliser runs.
Complete lifecycle example (Node 18+)¶
End-to-end run using only @noble/ed25519 + bs58. Assumes USER is funded and
LP is currently open. The main key signs exactly one message (the
delegation); the server signs all orders thereafter.
import * as ed from '@noble/ed25519';
import bs58 from 'bs58'; // npm i @noble/ed25519 bs58
const BASE = 'https://oracle-api.parti.com'; // production API
const SECRET = ed.utils.randomPrivateKey(); // <-- in practice, your funded wallet's key
const USER = bs58.encode(await ed.getPublicKeyAsync(SECRET));
const post = (p, body) => fetch(BASE + p, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
}).then(async (r) => ({ status: r.status, json: await r.json().catch(() => ({})) }));
// 1) Mint a server-side session key + get the canonical message to sign.
const sn = await post('/v1/lp-bot/session/new', { user: USER, expires_in_secs: 21600 });
const { session_pub, expires_at_unix, nonce, scope, message_bytes_hex } = sn.json;
// 2) Sign the EXACT bytes the server handed back with your MAIN key.
const msgBytes = Uint8Array.from(message_bytes_hex.match(/../g).map((h) => parseInt(h, 16)));
const sigHex = Buffer.from(await ed.signAsync(msgBytes, SECRET)).toString('hex');
// 3) Record the delegation. nonce/expiry/scope MUST match session/new.
const del = await post('/v1/lp-bot/delegate', {
user: USER, session_pub, scope, expires_at_unix, nonce, signature: sigHex,
});
// 4) Start a basket bot bound to the delegated session.
const start = await post('/v1/lp-bot/start', {
user: USER, session_pub,
basket: { category: 'sports', topN: 10, volumeFloorUsd: 1000, maxSharePct: 0.05, totalCapitalUsd: 500 },
strategy: { preset: 'balanced', halfSpreadBps: 100, numLevels: 2, rebalanceSec: 60 },
});
// 5) Poll state.
const st = await fetch(`${BASE}/v1/lp-bot/status?user=${USER}`).then((r) => r.json());
// 6) Read accrued rewards any time.
const rw = await fetch(`${BASE}/v1/rewards/me?user=${USER}`).then((r) => r.json());
// 7) Stop + revoke the session (cancels open orders on the next bot tick).
const stop = await post('/v1/lp-bot/stop', { user: USER, revoke_session: true });
Managed-bot behavior
The managed bot only quotes markets whose 24h volume clears volumeFloorUsd; on a
quiet venue the basket can resolve empty for a tick
(status_msg: "basket empty this tick — will retry") — the lifecycle still
succeeds; the bot just places no liquidity until a qualifying market appears.
Lower volumeFloorUsd (or widen category/topN) to broaden it.