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.
- A void refunds what you put in — net of anything you took out. No market price enters the payout, so there is nothing to manipulate.
- 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 refunds each holder their net cash in — everything they paid
into the market, less everything they took back out, and no market price
at all. The sum of those refunds is exactly what the pool collected, so
it is funded by construction; traders who cashed out at a profit are
covered by the creator's b·ln n escrow, which is what that escrow is
for. See Findings for the four wrong answers that preceded this one.
The creator's escrow is forfeited only when a market is abandoned — i.e. rescued by the backstop because nobody ever settled it. On every other path they recover it, capped at what they put up. Tying it to the outcome instead made a false resolution strictly dominate an honest void.
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. - An escrow that returns on every path is a deposit, not a bond. The
creator escrows
b·ln nas the LMSR maker loss, and a void redeeming atmin(value, what you paid)turns unpaid trader value into residual the creator could claim — while the abandoned-market backstop voids by itself after a week. So refusing to resolve returned nearly the whole escrow (68.5 of 69.3 measured) where resolving honestly returned a fraction (11.3), a 57-credit reward for silence taken from the trader who was right. Going silent must never beat settling: escrow now comes back on a resolution, or on a void an operator decided, and never on one the creator's own inaction produced. - Four wrong answers before the right one: a void should not use a
price at all. Redeeming a void at spot was a guaranteed arbitrage; at
a TWAP, a sustained pump defeated it; at
min(TWAP, cost basis), a partial sell defeated that (5.8% risk-free) while taxing a hedged position 12%; and even once the pumper could no longer profit, a pump still collapsed an innocent holder's refund to 5.6% of what they paid, with the difference falling to the house. Every version left the payout a function of a number somebody could move. The premise was the bug: "stake refunds are impossible under an AMM" is true of GROSS stakes and false of NET ones.Σ paid − Σ withdrawnover all holders is exactlycollected, so refunding it is funded by construction, with the creator's escrow covering traders who cashed out at a profit — which is what a maker subsidy is. There is no price left to distort. - Put a penalty where the behaviour is, not where it correlates. Slashing the creator's escrow on any void (to stop "go silent and reclaim it") made a FALSE resolution beat an honest void by the whole escrow, and made creating a low-volume market negative-EV, since a creator cannot force the oracle they named to act. The behaviour being punished is ABANDONMENT, so the condition is "did the backstop have to rescue this?" — not "which way did it settle?".
- Any new field on persisted state needs a backfill, and any fallback
written for legacy state needs a test that reaches it. Adding
netInMicrorefunded every pre-upgrade position ZERO on a void — the holder's whole stake to the house, conservation still balancing, all tests green. The migration fallback written for the companion field was dead code: it lived inside a branch where the condition it tested could never be true. Both were caught by review, not by a suite that only ever constructed state through the current code path. - A fix is a new attack surface: the state you add needs every branch
that reads the old state re-checked. Making an oracle-initiated void a
proposal (so holders can object to a cancellation they can only lose
on) added a lifecycle state with no
resolvedOutcome. Every settlement path that assumed one then computedshares[undefined]→NaN, whichMath.max(0, Math.floor(NaN))turns into a silent zero: upholding a disputed void paid every holder nothing and burned the pool to the house, while conservation still balanced perfectly. Two lessons — a payout that isn't a finite number must throw rather than floor, and an adjudication verb ("uphold") means different things depending on what was proposed. - A guard clause that ignores lifecycle state is a guard on nothing.
The dead-oracle backstop let anyone void an abandoned market past a
deadline — but
stalewas computed from timestamps alone, so a market that had been correctly resolved and was merely past that deadline could be voided by any loser, refunding their own losing bet with no bond and no dispute, erasing the winner. The backstop exists for an oracle that never acted; an oracle that acted is not dead. - 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 — 75 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.