Skip to content

Commit 5db70c9

Browse files
markets/: extract lifecycle.js so state changes cannot skip the reducer
The settlement state machine was inline in the route layer, which is exactly how the last critical happened: an adjudication assigned m.resolvedOutcome directly, the payouts were journalled and the outcome was not, and replay restored the oracle's original wrong answer while the credits sat with the corrected one. lifecycle.js now owns settle/settleResolved/settleVoid/voidPrices/tick as a factory over injected dependencies, with one rule stated at the top: nothing in it assigns to a market field, everything goes through commit(), and anything needing new state adds an event type. That makes the invariant structural rather than a comment, and takes ~250 lines out of plugin.js. Worth noting from the extraction: tick()'s per-market try/catch quietly swallowed a ReferenceError from the half-wired module, so sixteen markets simply never settled and the only symptom was assertion failures downstream. Per-item error isolation is right for one bad market and wrong for a broken dependency.
1 parent b11d7a5 commit 5db70c9

3 files changed

Lines changed: 200 additions & 141 deletions

File tree

markets/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ Open `{prefix}/` for the UI; the JSON API is under `{prefix}/api`.
3030
| `POST /api/admin/{freeze,adjust,hide}` | operator plane (journalled) |
3131
| `WS {prefix}/ws` | `{market,trade,settle}` events |
3232

33+
**Layout.** `lmsr.js` (AMM math + TWAP), `store.js` (journal, snapshot,
34+
reducer), `lifecycle.js` (the settlement state machine), `guard.js`
35+
(sessions, CSRF, rate limiting, headers), `ui.js`, `plugin.js` (policy +
36+
routes).
37+
3338
## The economics, in six lines
3439

3540
- Money is integer micro-credits; **costs round up, payouts round down**,

markets/lifecycle.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// markets/lifecycle.js — the settlement state machine.
2+
//
3+
// Extracted from the route layer for one structural reason: a market's
4+
// lifecycle fields must only ever change inside store.js's reducer, via a
5+
// journalled event. When this logic lived inline in a handler, an
6+
// adjudication assigned `m.resolvedOutcome` directly — payouts were
7+
// journalled, the OUTCOME was not — so replaying the journal restored the
8+
// oracle's original wrong answer while the credits sat with the corrected
9+
// one. The audit trail contradicted the money.
10+
//
11+
// The rule this module exists to enforce: NOTHING here assigns to a
12+
// market field. Everything goes through `commit()`, and the reducer does
13+
// the mutating. Anything that needs to change state adds an event type.
14+
//
15+
// open --closesAt--> (closed: no trading)
16+
// | |
17+
// | oracle resolve | nobody resolves within settlementWindow
18+
// | oracle propose-void v
19+
// v auto-void at TWAP (anyone may trigger)
20+
// resolving / voiding --window--> settled
21+
// |
22+
// | a holder disputes (bonded)
23+
// v
24+
// disputed --operator: uphold / re-resolve / void--> settled
25+
// --grace, unadjudicated--> the resolution STANDS
26+
27+
import { twapPrices } from './lmsr.js';
28+
29+
/**
30+
* @param {object} deps
31+
* @param {object} deps.state the live store state
32+
* @param {Function} deps.commit store.commit — the ONLY way to mutate
33+
* @param {Function} deps.broadcast (type, market) => void
34+
* @param {object} deps.log api.log
35+
* @param {object} deps.cfg { twapWindowMs, houseFeeShareBps, settlementWindowMs, disputeGraceMs, house }
36+
*/
37+
export function createLifecycle({ state, commit, broadcast, log, cfg }) {
38+
const { twapWindowMs, houseFeeShareBps, settlementWindowMs, disputeGraceMs, HOUSE } = cfg;
39+
40+
/** Prices to redeem a voided market at: the TWAP over the window ending
41+
* at close (see the header — this is what kills the void front-run). */
42+
function voidPrices(m) {
43+
const endT = m.closedAt || Math.min(m.closesAt, Date.now());
44+
return twapPrices(m.history, endT - twapWindowMs, endT, m.outcomes.length);
45+
}
46+
47+
/**
48+
* Compute and journal a settlement. Payouts are derived here, RECORDED
49+
* in the event, and applied by the reducer — so replay never recomputes
50+
* float arithmetic.
51+
*/
52+
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false, outcome = null } = {}) {
53+
const pool = m.subsidyMicro + m.collectedMicro;
54+
const raw = [];
55+
let sum = 0;
56+
for (const [agent, pos] of Object.entries(m.positions)) {
57+
const v = Math.max(0, Math.floor(payoutMicroOf(pos)));
58+
if (v > 0) { raw.push([agent, v]); sum += v; }
59+
}
60+
// Belt and braces: solvency is proved (lmsr.js), but if float drift
61+
// ever put us over the pool, everyone takes the same haircut rather
62+
// than the last claimant absorbing all of it.
63+
let scale = 1;
64+
if (sum > pool) {
65+
scale = pool / sum;
66+
log.error(`markets: ${m.id} conservation clamp — payouts ${sum} > pool ${pool}; pro-rata ${scale}`);
67+
}
68+
const payouts = {};
69+
let paid = 0;
70+
for (const [agent, v] of raw) {
71+
const p = Math.floor(v * scale);
72+
if (p > 0) { payouts[agent] = p; paid += p; }
73+
}
74+
75+
// The creator may recover AT MOST what they escrowed. Anything left
76+
// beyond that is other people's money and goes to the house — which
77+
// is what makes "resolve to an outcome nobody holds" unprofitable.
78+
const residual = pool - paid;
79+
const creatorFromPool = Math.max(0, Math.min(residual, m.subsidyMicro));
80+
const houseFromPool = residual - creatorFromPool;
81+
const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000);
82+
const creatorFee = m.feesMicro - houseFee;
83+
84+
// Dispute bonds return ONLY when an operator SUSTAINED the dispute —
85+
// whether that meant voiding or re-resolving. Inferring it from a
86+
// void status was wrong twice over: a re-resolution vindicates the
87+
// disputer but isn't a void, and an unadjudicated grace-expiry void
88+
// would hand the bond back for free.
89+
const bondRefunds = {};
90+
let bondToHouse = 0;
91+
for (const d of m.disputes || []) {
92+
if (!d.bondMicro) continue;
93+
if (sustained) bondRefunds[d.agent] = (bondRefunds[d.agent] || 0) + d.bondMicro;
94+
else bondToHouse += d.bondMicro;
95+
}
96+
97+
commit({
98+
type: 'market.settle',
99+
marketId: m.id,
100+
status,
101+
// Journalled so replay reproduces the settled outcome. Assigning
102+
// m.resolvedOutcome outside the reducer made the audit trail
103+
// contradict the money after a restore.
104+
outcome,
105+
payouts,
106+
bondRefunds,
107+
creatorMicro: creatorFromPool + creatorFee,
108+
houseMicro: houseFromPool + houseFee + bondToHouse,
109+
house: HOUSE,
110+
adjudicatedBy,
111+
prices: prices ? prices.map((p) => Number(p.toFixed(6))) : null,
112+
});
113+
broadcast('settle', m);
114+
}
115+
116+
const settleResolved = (m, opts = {}) => {
117+
// An adjudicator may settle at a DIFFERENT outcome than the oracle
118+
// declared; that corrected outcome rides in the event.
119+
const outcome = opts.outcome ?? m.resolvedOutcome;
120+
settle(m, 'resolved', (pos) => pos.shares[outcome], null, { ...opts, outcome });
121+
};
122+
// On a void you receive the LESSER of market value (at the TWAP) and
123+
// what you actually paid. The cap is what finally kills the void
124+
// arbitrage: the TWAP already defeats a last-second pump, but a
125+
// *sustained* pump held across the whole window makes the TWAP equal
126+
// the pumped price, and against a dead oracle that is a profitable
127+
// grief funded by the creator's escrow. Capping at cost basis means no
128+
// holder can ever exit a void for more than they put in, so pumping to
129+
// be voided is never profitable at any hold duration. It only ever
130+
// pays LESS than the TWAP, so conservation is strictly preserved.
131+
const settleVoid = (m, opts) => {
132+
const p = voidPrices(m);
133+
settle(m, 'void',
134+
(pos) => pos.shares.reduce((a, s, i) => a + Math.min(s * p[i], pos.costMicro[i]), 0),
135+
p, opts);
136+
};
137+
138+
/**
139+
* Advance every market whose deadline has passed. Runs on a timer AND
140+
* lazily before reads, so a settlement is never waiting on a tick.
141+
*
142+
* The auto-void arm is the DEAD-ORACLE BACKSTOP: a market whose oracle
143+
* never acts (typo, abandoned, malicious) would otherwise lock every
144+
* trader's credits forever, since trading also stops at close. After
145+
* settlementWindow anyone's request advances it to a TWAP void.
146+
*/
147+
// Settlement on the request path is bounded to once a second: a mass
148+
// expiry otherwise turns an anonymous GET into a multi-second stall
149+
// (one fsync per newly-due market).
150+
let lastTick = 0;
151+
function maybeTick() {
152+
if (Date.now() - lastTick < 1000) return;
153+
lastTick = Date.now();
154+
tick();
155+
}
156+
157+
function tick() {
158+
const now = Date.now();
159+
for (const m of Object.values(state.markets)) {
160+
try {
161+
if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m);
162+
else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m);
163+
else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) {
164+
log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`);
165+
settleVoid(m);
166+
} else if (m.status === 'disputed' && now >= (m.disputes[0].at + disputeGraceMs)) {
167+
// Fall through to the ORACLE'S RESOLUTION, not to a void: an
168+
// unadjudicated dispute must not be a way to cancel a bet you
169+
// lost. The disputer forfeits their bond; a genuinely wrong
170+
// resolution needs an admin to say so before the grace expires.
171+
log.warn(`markets: ${m.id} dispute expired unadjudicated — the resolution stands`);
172+
settleResolved(m);
173+
}
174+
} catch (e) {
175+
log.error(`markets: tick failed for ${m.id}: ${e.message}`);
176+
}
177+
}
178+
}
179+
180+
return { voidPrices, settle, settleResolved, settleVoid, tick, maybeTick };
181+
}

markets/plugin.js

Lines changed: 14 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
// admins: ['https://alice.example/profile/card#me'] } }]
88
//
99
// Layout: lmsr.js (the AMM math + TWAP), store.js (journal + snapshot +
10-
// reducer), guard.js (sessions, CSRF, rate limiting, headers), ui.js (the
11-
// trading UI), this file (policy + routes).
10+
// reducer), lifecycle.js (the settlement state machine), guard.js
11+
// (sessions, CSRF, rate limiting, headers), ui.js (the trading UI), this
12+
// file (policy + routes).
1213
//
1314
// MONEY. Integer micro-credits (1 credit = 1e6 micro). Costs round up,
1415
// proceeds and payouts round down, fees round up — every rounding
@@ -63,6 +64,7 @@ import fs from 'node:fs';
6364
import path from 'node:path';
6465
import { lmsrPrices, tradeCostRaw, sharesForBudget, twapPrices, uniformPrices } from './lmsr.js';
6566
import { createStore, dict, TWAP_PROTECT_MS } from './store.js';
67+
import { createLifecycle } from './lifecycle.js';
6668
import {
6769
createSessions, createRateLimiter, isSameOrigin, isAmbientCredential, UI_HEADERS, API_HEADERS,
6870
} from './guard.js';
@@ -491,145 +493,16 @@ export async function activate(api) {
491493
}
492494

493495
// ----------------------------------------------------------- lifecycle
494-
/** Prices to redeem a voided market at: the TWAP over the window ending
495-
* at close (see the header — this is what kills the void front-run). */
496-
function voidPrices(m) {
497-
const endT = m.closedAt || Math.min(m.closesAt, Date.now());
498-
return twapPrices(m.history, endT - twapWindowMs, endT, m.outcomes.length);
499-
}
500-
501-
/**
502-
* Compute and journal a settlement. Payouts are derived here, RECORDED
503-
* in the event, and applied by the reducer — so replay never recomputes
504-
* float arithmetic.
505-
*/
506-
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false, outcome = null } = {}) {
507-
const pool = m.subsidyMicro + m.collectedMicro;
508-
const raw = [];
509-
let sum = 0;
510-
for (const [agent, pos] of Object.entries(m.positions)) {
511-
const v = Math.max(0, Math.floor(payoutMicroOf(pos)));
512-
if (v > 0) { raw.push([agent, v]); sum += v; }
513-
}
514-
// Belt and braces: solvency is proved (lmsr.js), but if float drift
515-
// ever put us over the pool, everyone takes the same haircut rather
516-
// than the last claimant absorbing all of it.
517-
let scale = 1;
518-
if (sum > pool) {
519-
scale = pool / sum;
520-
api.log.error(`markets: ${m.id} conservation clamp — payouts ${sum} > pool ${pool}; pro-rata ${scale}`);
521-
}
522-
const payouts = {};
523-
let paid = 0;
524-
for (const [agent, v] of raw) {
525-
const p = Math.floor(v * scale);
526-
if (p > 0) { payouts[agent] = p; paid += p; }
527-
}
528-
529-
// The creator may recover AT MOST what they escrowed. Anything left
530-
// beyond that is other people's money and goes to the house — which
531-
// is what makes "resolve to an outcome nobody holds" unprofitable.
532-
const residual = pool - paid;
533-
const creatorFromPool = Math.max(0, Math.min(residual, m.subsidyMicro));
534-
const houseFromPool = residual - creatorFromPool;
535-
const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000);
536-
const creatorFee = m.feesMicro - houseFee;
537-
538-
// Dispute bonds return ONLY when an operator SUSTAINED the dispute —
539-
// whether that meant voiding or re-resolving. Inferring it from a
540-
// void status was wrong twice over: a re-resolution vindicates the
541-
// disputer but isn't a void, and an unadjudicated grace-expiry void
542-
// would hand the bond back for free.
543-
const bondRefunds = {};
544-
let bondToHouse = 0;
545-
for (const d of m.disputes || []) {
546-
if (!d.bondMicro) continue;
547-
if (sustained) bondRefunds[d.agent] = (bondRefunds[d.agent] || 0) + d.bondMicro;
548-
else bondToHouse += d.bondMicro;
549-
}
550-
551-
store.commit({
552-
type: 'market.settle',
553-
marketId: m.id,
554-
status,
555-
// Journalled so replay reproduces the settled outcome. Assigning
556-
// m.resolvedOutcome outside the reducer made the audit trail
557-
// contradict the money after a restore.
558-
outcome,
559-
payouts,
560-
bondRefunds,
561-
creatorMicro: creatorFromPool + creatorFee,
562-
houseMicro: houseFromPool + houseFee + bondToHouse,
563-
house: HOUSE,
564-
adjudicatedBy,
565-
prices: prices ? prices.map((p) => Number(p.toFixed(6))) : null,
566-
});
567-
broadcast('settle', m);
568-
}
569-
570-
const settleResolved = (m, opts = {}) => {
571-
// An adjudicator may settle at a DIFFERENT outcome than the oracle
572-
// declared; that corrected outcome rides in the event.
573-
const outcome = opts.outcome ?? m.resolvedOutcome;
574-
settle(m, 'resolved', (pos) => pos.shares[outcome], null, { ...opts, outcome });
575-
};
576-
// On a void you receive the LESSER of market value (at the TWAP) and
577-
// what you actually paid. The cap is what finally kills the void
578-
// arbitrage: the TWAP already defeats a last-second pump, but a
579-
// *sustained* pump held across the whole window makes the TWAP equal
580-
// the pumped price, and against a dead oracle that is a profitable
581-
// grief funded by the creator's escrow. Capping at cost basis means no
582-
// holder can ever exit a void for more than they put in, so pumping to
583-
// be voided is never profitable at any hold duration. It only ever
584-
// pays LESS than the TWAP, so conservation is strictly preserved.
585-
const settleVoid = (m, opts) => {
586-
const p = voidPrices(m);
587-
settle(m, 'void',
588-
(pos) => pos.shares.reduce((a, s, i) => a + Math.min(s * p[i], pos.costMicro[i]), 0),
589-
p, opts);
590-
};
591-
592-
/**
593-
* Advance every market whose deadline has passed. Runs on a timer AND
594-
* lazily before reads, so a settlement is never waiting on a tick.
595-
*
596-
* The auto-void arm is the DEAD-ORACLE BACKSTOP: a market whose oracle
597-
* never acts (typo, abandoned, malicious) would otherwise lock every
598-
* trader's credits forever, since trading also stops at close. After
599-
* settlementWindow anyone's request advances it to a TWAP void.
600-
*/
601-
// Settlement on the request path is bounded to once a second: a mass
602-
// expiry otherwise turns an anonymous GET into a multi-second stall
603-
// (one fsync per newly-due market).
604-
let lastTick = 0;
605-
function maybeTick() {
606-
if (Date.now() - lastTick < 1000) return;
607-
lastTick = Date.now();
608-
tick();
609-
}
610-
611-
function tick() {
612-
const now = Date.now();
613-
for (const m of Object.values(state.markets)) {
614-
try {
615-
if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m);
616-
else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m);
617-
else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) {
618-
api.log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`);
619-
settleVoid(m);
620-
} else if (m.status === 'disputed' && now >= (m.disputes[0].at + disputeGraceMs)) {
621-
// Fall through to the ORACLE'S RESOLUTION, not to a void: an
622-
// unadjudicated dispute must not be a way to cancel a bet you
623-
// lost. The disputer forfeits their bond; a genuinely wrong
624-
// resolution needs an admin to say so before the grace expires.
625-
api.log.warn(`markets: ${m.id} dispute expired unadjudicated — the resolution stands`);
626-
settleResolved(m);
627-
}
628-
} catch (e) {
629-
api.log.error(`markets: tick failed for ${m.id}: ${e.message}`);
630-
}
631-
}
632-
}
496+
// The settlement state machine lives in lifecycle.js — see that file
497+
// for why it is not inline here (a state change that skipped the
498+
// reducer made the audit trail contradict the money).
499+
const { voidPrices, settleResolved, settleVoid, tick, maybeTick } = createLifecycle({
500+
state,
501+
commit: store.commit,
502+
broadcast: (type, m) => broadcast(type, m),
503+
log: api.log,
504+
cfg: { twapWindowMs, houseFeeShareBps, settlementWindowMs, disputeGraceMs, HOUSE },
505+
});
633506

634507
// --------------------------------------------------------- websocket
635508
const sockets = new Set();

0 commit comments

Comments
 (0)