The Express/Node backend for Arbiter, a pay-per-question
human-intelligence oracle settled on Stellar/Soroban. Terminates the HTTP
402 payment flow, dispatches questions to workers over SSE, reconciles
answers, and settles on-chain against
arbiter-contract. It's
the sole holder of the platform's admin key — the only component allowed
to call resolve()/refund()/touch() — and, separately, the pooled
fiat-onramp float key (kept deliberately distinct; see "Billing" below).
Originally split out of a monorepo; that monorepo is now retired — this
repo is the sole source of truth for the backend's code going forward,
version-pinned against arbiter-contract's releases rather than kept in
lockstep by hand (see #163). Pre-split history and the round-by-round
build narrative live in the archived
arbiter repo.
Two public deployments exist, and they are deliberately different things:
| Environment | URL | Lifetime | Chain calls |
|---|---|---|---|
| Developer sandbox | https://sandbox.arbiter.xyz |
Long-lived; kept up for integrators | Real Soroban testnet submit()/resolve()/withdraw() round trips |
| Demo deployment | https://demo.arbiter.xyz |
Disposable; may be redeployed or torn down after the SCF submission window | Real Soroban testnet, but not a stable target |
This is the environment to point a real integration at. It runs its own
Soroban testnet contract deployment with its own contractId and
PLATFORM_SECRET, kept distinct from whatever gets redeployed for demo
purposes, and its own backend service and config.js env block. It is
committed to staying up rather than being torn down after a submission
window, and it reuses the existing rateLimit.js / config.rateLimits
machinery at a more generous ceiling than the demo deployment, since it
absorbs sustained integrator traffic rather than one-off demo hits.
GET /health and a real /oracle submit→resolve round trip are expected
to succeed against it in checks run at least a week apart. "Long-lived"
means "not torn down after a specific date" — it is not an uptime SLA.
The public Railway/Vercel deployment referenced in earlier revisions of this README. It is a disposable testnet deployment on free-tier hosting: expect it to be redeployed or torn down after the SCF submission window, not a permanent production environment. Use it to look around, not to build against.
Distinct from both of the above: /oracle/sandbox (backend/src/sandbox.js)
is a purely local simulation. Its own docstring says it "Never touches
stellarClient.js — no chain calls," which is exactly why it's zero-setup,
but also why it can't prove anything about real Soroban RPC latency, real
surge pricing, or a real submit()/resolve()/withdraw() round trip.
It returns canned response shapes with no payment and no chain. Use it to
see a response shape before setting up a wallet; use the developer
sandbox when you need the real round trip.
You do not need Arbiter's own platform key to get test funds. To fund your own integration against the developer sandbox:
- Create and fund a testnet Stellar account with Friendbot:
curl "https://friendbot.stellar.org?addr=<YOUR_TESTNET_ADDRESS>". - Add a trustline for the testnet USDC asset issued by the sandbox's
documented testnet issuer (see the sandbox's
/healthresponse and thearbiter-contracttestnet deployment notes for the current issuer and asset code). - Acquire testnet USDC from the sandbox's documented testnet faucet, or from any testnet DEX path against that issuer, and pay for questions from your own key.
This keeps your integration independent of Arbiter's platform key and of any single hot key's funding.
- Async job-based
/oracle—202immediately once payment is confirmed, clients pollGET /oracle/:jobIdrather than holding a connection open. Three payment methods on the one endpoint: classic pay-per-callsubmit(), a prepaid on-chain balance for a wallet-holding payer, or anAuthorization: BearerAPI key for a payer with no wallet at all (see Billing below) — chosen transparently by what the caller sends. POST /oracle/sandbox— a zero-payment, zero-chain, zero-LLM sandbox endpoint so an integrator can see a real response shape before setting up a wallet.- Live surge pricing, worker category routing, staking/reputation-gated reconciliation fast path (with an on-chain stake-gate opt-in for dispatch eligibility), Web Push for offline workers, structured (pino) logging with request/job correlation, idempotent job creation, and bounded retry/timeout on every external call (Claude, Soroban RPC, Horizon).
- Private worker pools (
privatePools.js) — a payer can whitelist worker addresses (GET/POST /payers/:address/pool,DELETE /payers/:address/pool/:worker, session-token gated). Their questions then go only to, and only take answers from, those workers. This fails closed: if no whitelisted worker is online, the question is refunded rather than sent to the open pool. Payers without a pool are unaffected. - Configurable consensus rules (
consensus.js) —consensusMode: 'numeric-tolerance'withtolerance: { percent }or{ absolute }onPOST /oraclemakes numeric answers ("42", "$42.00", "about 42") within tolerance count as agreeing, without a Claude call. The default'exact'mode is unchanged. The rule used is shown onGET /oracle/:jobId. - Cryptographic worker session auth (
workerAuth.js) — an address-formatworkerIdmust prove control of that key before submitting an answer. - A read-only admin/ops console (
/admin/*, bearer-token gated) — transactions, workers, payers, live on-chain treasury balance, fee revenue, and fraud/trust monitoring. - A SEP-24/SEP-12 fiat anchor client (
anchorClient.js) — Arbiter never holds PII or bank details itself, only resolves and caches the configured anchor's publicstellar.tomlfor the frontend. - Billing (
billing.js) — the non-crypto onramp: a customer pays via Stripe Checkout and is issued an API key instead of ever touching a Stellar wallet. Every fiat-paid question still settles on-chain, from one pooled balance under a dedicated fiat-pool key, through the exact samechargeBalance()path the wallet-based prepaid flow already uses. Credit reservations are atomic and webhook delivery is idempotent per Stripe event id. - Answer provenance (docs/provenance.md): every
settled question commits (sha256 over canonical JSON) to its raw worker
submissions and any LLM prompt/response before
resolve()/refund()is sent. The record is public atGET /oracle/:jobId/provenance, andscripts/verify-provenance.jslets anyone re-derive the consensus and check it against the on-chain payout. - Startup security-posture check (
securityPosture.js): a deployment that looks like production refuses to start withoutSESSION_SECRET, and logs a loud error for wide-openALLOWED_ORIGINSor a non-TLSREDIS_URL. Local dev with every default left alone stays silent.
First-party clients for the agent-facing API (ask → pay → poll, payer session auth, the undo window, and the public reads), each with its own README, tests, and changelog:
- TypeScript / JavaScript:
sdk/typescript(@arbiter-xyz/sdk), for Node 18+ and browsers - Python:
sdk/python(arbiter-sdk), for Python 3.9+
Both can be tried against POST /oracle/sandbox with no wallet at all,
and against the developer sandbox for a real round trip.
Instead of polling GET /oracle/:jobId, a payer or API-key customer can
register a URL that receives a signed POST whenever one of their
questions settles, whether it's resolved or refunded.
Register, list, delete. Authenticate as either:
- an API-key customer:
Authorization: Bearer ak_live_..., or - a wallet payer:
addressplus a sessiontokenfromPOST /payers/:address/session, sent in the JSON body or the query string.
POST /webhooks {"url": "https://example.com/arbiter", "description": "prod"}
GET /webhooks
DELETE /webhooks/:idThe 201 response to POST /webhooks includes the registration's signing
secret (whsec_...). It is shown only once, the same convention as API
keys. Targets must be https and publicly reachable. localhost-style names
and private, loopback, link-local or reserved addresses are rejected at
registration, and checked again against the resolved IP at delivery time
(SSRF / DNS-rebinding guard). Each owner can register up to
WEBHOOK_MAX_PER_OWNER URLs.
Each delivery is a JSON POST with these headers:
X-Arbiter-Event: question.settled
X-Arbiter-Event-Id: evt_... (identical across retries: dedupe on it)
X-Arbiter-Webhook-Id: wh_...
X-Arbiter-Delivery-Attempt: 1..N
X-Arbiter-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
The body is { id, type: "question.settled", createdAt, data: { jobId, ... } }.
data has the same shape as GET /oracle/:jobId.
Verifying a delivery. Compute
HMAC-SHA256(key = your whsec_ secret, message = t + "." + raw_request_body),
hex-encode it, and compare it to v1 in constant time. Use the raw body
bytes exactly as received, before parsing the JSON. Reject a t that is
more than about 5 minutes old. For example, in Node:
const [, t, v1] = /t=(\d+),v1=([0-9a-f]+)/.exec(req.get('X-Arbiter-Signature'));
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'))
&& Math.abs(Date.now() / 1000 - Number(t)) < 300;Retries. Any non-2xx response (redirects are not followed), timeout or
network error is retried with exponential backoff: 5s, 10s, 20s and so on,
up to WEBHOOK_MAX_ATTEMPTS attempts in total. After that, delivery is
abandoned and the failure is logged. A 410 Gone response deactivates the
registration. GET /webhooks shows each registration's last delivery status.
Delivery never blocks or affects settlement, and GET /oracle/:jobId stays
the source of truth.
Every job record carries the question's category, and GET /oracle/:jobId
and GET /admin/transactions return it. GET /payers/:address/questions
also returns spendByCategory and spendByDay (UTC days) buckets for spend
dashboards.
A worker gets a referral code (ARB-XXXXXXXX) from
POST /workers/:address/referral-code with { token }, and tracks each
referee's progress at GET /workers/:address/referrals?token=.... A referee
is pending until established, then qualified. They become disqualified
if their match ratio is under the routing gate, or if collusion detection
flags them together with their referrer. Codes are tracked only by this
backend. There's no on-chain referral payout.
A new worker onboards with one call. They get a session token for the new
address (the challenge is signed offline, so the account doesn't need to
exist yet), then call POST /workers/:address/onboard with
{ token, referralCode }. This redeems the code and returns the sponsored
account + USDC trustline transaction. The worker signs it and submits it to
POST /sponsor/onboard/submit. GET /referrals/:code checks a code before
the worker signs anything.
Each address can redeem one code, and only before it has any answer history.
Self-referral is rejected, and each code has a use cap
(REFERRAL_MAX_USES_PER_CODE). Set REFERRAL_REQUIRED_FOR_SPONSORED_ONBOARDING=true
to require a referral code for sponsored onboarding.
collusion.js scores pairs of workers across the quorums they share. It
combines five signals with a weighted noisy-OR:
- identical wrong answers
- agreement above what their individual match ratios predict
- answers submitted within
COLLUSION_SYNC_WINDOW_MSof each other - a referral link between them
- a shared connection IP (stored only as an HMAC)
When a pair's score reaches COLLUSION_FLAG_SCORE, the pair is flagged. Any
quorum where a flagged pair gives the same answer skips the reconcile fast
path and goes to review. When a pair's score reaches COLLUSION_SUSPEND_SCORE,
both workers are also dropped from routing. Like the other routing gates, this
one fails open. Operators review pairs at GET /admin/collusion and
GET /admin/collusion/workers/:workerId, and clear them with
POST /admin/collusion/clear. After a pair is cleared, only a worse score
suspends it again. These are heuristics: nothing here slashes stake.
A team is a named group of Stellar addresses that share one view of their
question history and spend. It adds no new credential: every call
authenticates as a member with address plus a session token from
POST /payers/:address/session, in the JSON body or the query string.
POST /teams {"name": "Acme research"}
GET /teams teams you belong to
GET /teams/:teamId
PATCH /teams/:teamId {"name": "..."} (admin)
DELETE /teams/:teamId (owner)
POST /teams/:teamId/members {"members": ["G..."], "role": "member"}
PATCH /teams/:teamId/members/:member {"role": "admin"} (owner)
DELETE /teams/:teamId/members/:member remove a member, or leave
GET /teams/:teamId/questions combined history + spendByMemberEach team has exactly one owner, plus any number of admins and
members. Admins add and remove plain members. Only the owner manages
admins. Setting another member's role to owner transfers ownership, and
the previous owner becomes an admin. The owner can't leave or be removed
until ownership is transferred. People who aren't members get a 404 for a
team. Limits: 100 members per team, 20 teams per address.
Operators can pause dispatch for holidays or planned maintenance:
POST /admin/maintenance-windows {"startsAt": "2026-12-24T00:00:00Z", "endsAt": "2026-12-27T00:00:00Z", "reason": "holiday"}
GET /admin/maintenance-windows
DELETE /admin/maintenance-windows/:id (deleting an active window ends the pause now)
GET /maintenance public: current pause + upcoming windowsWhile a window is active, requests to POST /oracle that would create a
question get 503 with Retry-After and resumesAt, before anyone is
charged. Questions aren't queued through a pause, because the contract's
refund_timeout() would make a paid question refundable long before a
holiday ends. So a question that was already paid for when the window opened
is refunded (reconciliationMethod: "dispatch-paused") instead of being
dispatched. This covers step 2 of the classic flow and jobs still in their
undo-window hold. Questions that were already dispatched finish normally.
Sandbox requests are unaffected. Windows that overlap or touch are merged
into a single pause.
.github/dependabot.yml runs a weekly npm update job. Minor and patch bumps
are grouped into one PR, and each major bump gets its own. It's scoped to
the repo root (/) because this split-out repo carries a single npm package.
Nothing is auto-merged: these PRs need human review.
Load-test tooling (scripts/loadtest.js), measured limits, and the current
bottleneck (serialized on-chain settlement) are in
docs/capacity/README.md.
- #108: A public status page reporting real uptime/incident history