Multi-outcome prediction markets (1X2 is the home case) traded against Hanson's Logarithmic Market Scoring Rule: a paper-credit ledger on an append-only journal, a settlement state machine with disputes, live WebSocket prices, and a stake-first trading UI. The FanDuel shape — markets, moving odds, positions, cash-out, settlement — with play money.
plugins: [{ module: 'markets/plugin.js', prefix: '/predict',
config: { baseUrl: 'https://pod.example', grantCredits: 1000,
feeBps: 100, admins: ['https://you.example/#me'] } }]Open {prefix}/ for the UI; the JSON API is under {prefix}/api.
POST /api/session |
exchange a pod bearer for a scoped HttpOnly cookie |
GET /api/me |
balance, positions with P&L, settlement receipts |
GET/POST /api/markets |
list (cursor + filters + search) / create |
GET /api/markets/:id |
detail, price history, your position |
GET /api/markets/:id/quote |
?spend=10 (stake-first) or ?shares=50 |
POST /api/markets/:id/trade |
buy/sell, slippage guards, idempotency keys |
.../close .../resolve .../dispute .../settle .../void |
lifecycle |
GET /api/stats |
public conservation figures + journal seq |
POST /api/admin/adjudicate |
uphold / re-resolve / void a disputed market |
GET /api/admin/disputes |
the operator's dispute queue, soonest deadline first |
GET /api/admin/agent |
one agent's journal history (support / adjudication) |
POST /api/admin/{freeze,adjust,hide} |
operator plane (journalled) |
WS {prefix}/ws |
{market,trade,settle} events |
Layout. lmsr.js (AMM math + TWAP), store.js (journal, snapshot,
reducer), lifecycle.js (the settlement state machine), guard.js
(sessions, CSRF, rate limiting, headers), ui.js, plugin.js (policy +
routes).
- Money is integer micro-credits; costs round up, payouts round down, so float drift always favours the pool, never breaks it.
- The creator escrows
b·ln n— LMSR's worst-case maker loss — so the book is always solvent: settlement provably fits in escrow + collected. - A share of the winning outcome redeems for 1 credit. No shorting, so outstanding shares never go negative.
- Void redeems at a TWAP over the window before close, not at spot (see Findings — this is what makes voids non-exploitable).
- The creator's settlement claim is capped at their own escrow; residual beyond it goes to the house, so resolving to an outcome nobody holds wins the oracle nothing.
- Conservation is asserted end-to-end after every settlement path:
GET /api/stats → creditsInSystemequals the sum of all grants, exactly.
The oracle is a named agent, and three separate mechanisms bound what a dishonest one can do: they may not trade in their own market; their payout is capped at their escrow; and a resolution sits in a dispute window during which any holder can park it for an operator. A market whose oracle never acts is auto-voided at TWAP after the settlement window — anyone can trigger it, so funds are never stuck.
Disputes cost a bond — max(disputeBondCredits, disputeBondBps of the
disputed position), default 25 credits or 20% — returned only if an
operator sustains the dispute, forfeited otherwise, including when
nobody adjudicates in time. Every holder posts their own bond, and an
unadjudicated dispute falls through to the oracle's resolution, not a
void. Each of those is load-bearing: refunding on any void, latching on
the first disputer, or defaulting to void made disputing a free refund
option on any lost bet — paid for out of the winner's payout — so every
rational loser disputes and correct resolutions never stand.
An operator works the queue in the Operator panel (shown in the UI to
any agent in config.admins) or directly at GET /api/admin/disputes →
POST /api/admin/adjudicate, which has three verbs: uphold (the
resolution stands), re-resolve (uphold:false with an outcome —
the oracle was wrong and we know the right answer), and void.
Re-resolution matters because voiding an incorrect resolution refunds the
loser and wipes out whoever actually backed the correct outcome.
An oracle-initiated void is only a proposal and sits in the same
dispute window a resolution does. A void pays min(TWAP, cost basis), so
it is a payoff traders can only lose on while the creator recovers their
escrow — the oracle never has to steal the pool, only refuse to pay it.
Operators, and the anyone-can-rescue backstop on an abandoned market,
still settle immediately.
A deployment with no admins cannot adjudicate anything — every
dispute expires into the oracle's resolution — so the plugin says so
loudly at boot rather than letting the advertised check be quietly
inert.
A void never pays a holder more than they paid. Redemption is
min(TWAP value, cost basis) per outcome. The TWAP alone defeats a
last-second pump but not one held across the whole window; the cap
makes pumping-to-be-voided unprofitable at any hold duration, and since
it only ever pays less than the TWAP, conservation is untouched.
Not real money. A real-money book is a gambling licence, KYC/AML,
segregated customer funds, and mandated responsible-gambling tooling — an
organisation, not a plugin. The boundary here is honest: everything above
the ledger is the code a licensed operator would need; the ledger is
where regulated custody would mount. Not an orderbook — LMSR quotes
every size at every moment, which is what long-tail markets need.
Not sybil-resistant: pods are self-serve, so the faucet is mintable
by registration. Set grantCredits: 0 and fund via POST /api/admin/adjust for any competitive deployment.
The deliverable of this repo. What the api gave, what it didn't, and what the walls point at.
api.ws.route()must beawaited insideactivate. Calling it fire-and-forget deadlocks the entire server boot —listen()never resolves, with no error and no log line. relay/ and webrtc/ both happen to await it, so nothing had exposed that it is load-bearing; AGENT.md documents the signature but not the requirement. Cost an afternoon of bisecting. Either the contract should be documented, or a non-awaited call should be safe.- Hooks added via
api.fastifyare NOT scoped to the plugin's routes.api.fastify.addHook('onRequest', …)runs for every request the server handles — core's and other plugins'. An unguarded rate-limit hook here returned 429 to the metrics and dashboard plugins in the compose suite; an unguardedonSendwas rewriting CORS headers server-wide. Every hook must gate on its own prefix by hand.api.prefixexists, so the loader has everything it needs to scope this. Second consumer of this edge (metrics/ noted it from the other side). - The host's CORS defaults are wrong for money routes. The server
reflects the request Origin with
Access-Control-Allow-Credentials: true, andgetAgenthonours ambient WebID-TLS certificates — so any origin could drive an authenticated state change and read the reply. Sensible for LDP, dangerous for a plugin holding balances. A plugin cannot set server-level CORS, so it must override per-response and enforce same-origin itself for ambient credentials.api.cors(or a documented per-prefix override) is the missing seam. - A pod bearer is the wrong credential for a browser app, and the api
offers no alternative. The obvious UI flow ("paste your token") puts a
pod-wide credential in
localStorageon an origin that also serves user-uploaded HTML — one stored XSS anywhere on the host and the attacker owns the pod. This plugin mints its own scoped, expiring session (capability/'s HMAC shape) behind an HttpOnly cookie, which every browser-facing plugin will have to reinvent.api.auth.mintScoped ({ agent, scope, ttl })would be the shared primitive. - No
api.rateLimit. The host's limiter isglobal: false, so plugin routes get none, and unauthenticated bodies are parsed before the 401. Every plugin exposing an anonymous endpoint needs its own bucket (guard.js here). Route-levelbodyLimitat least is reachable. - Event sourcing had to be hand-rolled, and was worth it. The
repo-standard "one JSON blob, atomic temp+rename" (shortlink/, otp/,
relay/) rewrites all state per mutation — O(entire state) per trade —
keeps no audit trail, and turns a torn write into a silent total reset.
Balances need all three fixed, so store.js is a journal (append +
fsync, the durable record) plus a periodic snapshot, with corruption a
boot failure rather than a wipe. This is the fourth stateful plugin to
outgrow the blob; a documented
api.storage.journal()would stop everyone rediscovering fsync-and-rename semantics. - Stake-refund voids are impossible under an AMM, and voiding at spot
is exploitable. Refunding stakes over-draws the pool, because early
sellers already left with pool money. But redeeming at the final
price is a guaranteed arbitrage: by strict convexity, buying x shares
costs strictly less than
x·p_final(measured: 1930.69 for shares that redeem at 2000.00), so buy-then-void extractsb·ln nrisk-free, partly out of other holders' redemptions. The fix is to redeem at a time-weighted average over the window before close: still conserving — the boundΣqᵢrᵢ ≤ C(q)holds for any probability vectorr, by the Gibbs variational principle, not just the spot one — but a last-second pump barely moves it, so the pump is a pure loss. There is a regression test for exactly this attack, and it caught a real bug: the price path was seeded withm.history || [seed], and an empty array is truthy, so the TWAP degenerated to the post-pump spot price. - A state change that skips the reducer is a lie the audit trail tells
later. Adjudicating a wrong resolution assigned
m.resolvedOutcomedirectly in the route handler and then settled. Payouts were correct and journalled; the OUTCOME was not — so replaying the journal (the recovery the boot error itself recommends) restored the oracle's original wrong answer while the credits sat with the corrected one, and the settlement receipts recorded the stale value too. store.js already said "every mutation happens HERE and nowhere else"; one assignment outside it was enough. Event sourcing only holds if the invariant is structural — which is the argument for extracting the lifecycle from the route layer entirely. - A self-verifying credential needs an epoch, and the type of what
verify()returns is a money bug. Widening the session verifier from "returns the agent id" to "returns the claims" without updating its two callers made every cookie session authenticate as the string[object Object]: one shared ledger row for every browser user, a phantom grant minted against that key, a rate limiter keyed on a fresh object per request (so, disabled), and a conservation invariant that was silently false and would have replayed that way forever. Sixty-two green tests missed it because they asserted status codes and never once asserted which agent a cookie resolved to. Test the identity, not the 200. - Dropping a torn journal tail is only half of crash recovery. The fragment must also be TRUNCATED before reopening for append — otherwise the next acknowledged, fsync'd event is welded onto the partial line, and the boot after that silently drops a real credit movement and reuses its sequence number. A durability design can pass every "does it survive a restart" test and still fail the one crash it exists to survive; the regression test now crashes, writes, and restarts again.
- Atomicity by construction is fragile and undocumented. Every
mutating handler awaits auth first, then validates and commits with no
awaitin between, so the event loop makes each trade a transaction. One innocentawaitinside that window reintroduces TOCTOU. It holds today (audited per handler, and a concurrent-trade test pins it) but it is a comment, not a mechanism — a real store wants transactions. - No
api.events.onResourceChangebites here as it does in sparql/ and rss/: match results already live in pods, but the oracle cannot be "this pod resource says 2-1" — a human agent must post the resolution. Auto-settlement from pod data is the natural next seam.
node --test --test-concurrency=1 markets/test.js — 66 tests: LMSR and
TWAP math, session/CSRF/rate-limit units, hardened headers, cookie
scoping, prototype-key ids, grants, escrow, stake-first quotes,
quote↔trade parity, slippage guards (including the NaN-fails-closed case),
no-shorting, idempotent retries, 12 concurrent trades, ws privacy,
pagination and search, the full settlement state machine (resolve →
dispute → settle, void, early close, dead-oracle rescue), the
self-dealing and pump-and-void attacks, 12-outcome and no-trade markets,
the admin plane (hide-makes-untradable, freeze, journalled adjust, agent
history), both adjudication paths, the sustained-pump void, reboot with
an open market mid-flight, journal integrity, journal-gap and
corrupt-snapshot boot refusal, and torn-tail crash recovery — with
micro-credit-exact conservation asserted after every single one.