Skip to content

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 scopewithdraw 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

{
  "user": "<base58 pubkey>",
  "expires_in_secs": 21600,
  "scope": "orders",
  "max_capital_micro": 0
}

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. presetconservative | 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": "…"
  }
}

statusactive | paused | stopped. If the user has never started a bot: { "bot": null }. Error: 400 invalid_pubkey.

POST /v1/lp-bot/stop

{ "user": "<base58 pubkey>", "revoke_session": true }

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_usdc is a projection of the running day; it is only realised into lifetime_micro_usdc after 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.