Skip to content

Commit 6659441

Browse files
markets/: fix a zero-payout burn and a loser-triggered void
Fifth review round produced two working proof-of-concepts, both mine. Round 4 made an oracle-initiated void a proposal so holders could object to a cancellation they can only lose on. That added a lifecycle state with no resolvedOutcome -- and every settlement path that assumed one then computed shares[undefined] -> NaN, which Math.max(0, Math.floor()) turns into a silent zero. Upholding a disputed void proposal, or letting its grace expire, paid every holder nothing and burned the whole pool to the house, while conservation balanced perfectly the entire time. A payout that is not a finite number now throws instead of flooring, a market with no declared outcome cannot be resolved at all, and uphold now means whatever the oracle actually proposed. Separately, the dead-oracle backstop computed staleness from timestamps alone, ignoring lifecycle state. A market that had been correctly resolved and was merely past closesAt + settlementWindow could be voided by any authenticated agent -- a loser refunding their own losing bet with no bond, no dispute, not the oracle, not an admin, erasing the winner's payout. The backstop is for an oracle that never acted; one that acted is not dead. Also: disputeWindowMs >= settlementWindowMs is now a boot failure, because that ordering put every resolution inside the backstop window; disputeBondBps and the rate-limit config are validated like every other load-bearing value; /settle and /dispute advance only the market named in the path, which is both immediate and O(1) -- throttling /settle had quietly turned an explicit 'settle now' into a no-op; the dispute grace clock anchors to the latest dispute rather than the first, which was handing late disputers a zero-length window and keeping their bond; and the WebSocket upgrade uses the same origin resolution as HTTP. 69 plugin tests, 39 compose tests.
1 parent 5db70c9 commit 6659441

4 files changed

Lines changed: 193 additions & 26 deletions

File tree

markets/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ the walls point at.
171171
regression test for exactly this attack, and it caught a real bug: the
172172
price path was seeded with `m.history || [seed]`, and an empty array is
173173
truthy, so the TWAP degenerated to the post-pump spot price.
174+
- **A fix is a new attack surface: the state you add needs every branch
175+
that reads the old state re-checked.** Making an oracle-initiated void a
176+
*proposal* (so holders can object to a cancellation they can only lose
177+
on) added a lifecycle state with no `resolvedOutcome`. Every settlement
178+
path that assumed one then computed `shares[undefined]``NaN`, which
179+
`Math.max(0, Math.floor(NaN))` turns into a silent zero: upholding a
180+
disputed void paid every holder nothing and burned the pool to the
181+
house, while conservation still balanced perfectly. Two lessons — a
182+
payout that isn't a finite number must throw rather than floor, and an
183+
adjudication verb ("uphold") means different things depending on what
184+
was proposed.
185+
- **A guard clause that ignores lifecycle state is a guard on nothing.**
186+
The dead-oracle backstop let *anyone* void an abandoned market past a
187+
deadline — but `stale` was computed from timestamps alone, so a market
188+
that had been correctly *resolved* and was merely past that deadline
189+
could be voided by any loser, refunding their own losing bet with no
190+
bond and no dispute, erasing the winner. The backstop exists for an
191+
oracle that never acted; an oracle that acted is not dead.
174192
- **A state change that skips the reducer is a lie the audit trail tells
175193
later.** Adjudicating a wrong resolution assigned `m.resolvedOutcome`
176194
directly in the route handler and then settled. Payouts were correct
@@ -214,7 +232,7 @@ the walls point at.
214232

215233
## Tests
216234

217-
`node --test --test-concurrency=1 markets/test.js`66 tests: LMSR and
235+
`node --test --test-concurrency=1 markets/test.js`69 tests: LMSR and
218236
TWAP math, session/CSRF/rate-limit units, hardened headers, cookie
219237
scoping, prototype-key ids, grants, escrow, stake-first quotes,
220238
quote↔trade parity, slippage guards (including the NaN-fails-closed case),

markets/lifecycle.js

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,14 @@ function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustai
5454
const raw = [];
5555
let sum = 0;
5656
for (const [agent, pos] of Object.entries(m.positions)) {
57-
const v = Math.max(0, Math.floor(payoutMicroOf(pos)));
57+
const computed = payoutMicroOf(pos);
58+
// A non-finite payout must be LOUD. Math.max(0, Math.floor(NaN)) is
59+
// NaN and `if (v > 0)` is false, so a broken payout function paid
60+
// every holder zero and burned the pool to the house in silence.
61+
if (!Number.isFinite(computed)) {
62+
throw new Error(`markets: ${m.id} computed a non-finite payout for ${agent} — refusing to settle`);
63+
}
64+
const v = Math.max(0, Math.floor(computed));
5865
if (v > 0) { raw.push([agent, v]); sum += v; }
5966
}
6067
// Belt and braces: solvency is proved (lmsr.js), but if float drift
@@ -117,6 +124,11 @@ const settleResolved = (m, opts = {}) => {
117124
// An adjudicator may settle at a DIFFERENT outcome than the oracle
118125
// declared; that corrected outcome rides in the event.
119126
const outcome = opts.outcome ?? m.resolvedOutcome;
127+
// A disputed VOID PROPOSAL has no resolvedOutcome; resolving it would
128+
// index shares[undefined] and pay every holder nothing.
129+
if (!Number.isInteger(outcome)) {
130+
throw new Error(`markets: ${m.id} has no resolved outcome to settle at`);
131+
}
120132
settle(m, 'resolved', (pos) => pos.shares[outcome], null, { ...opts, outcome });
121133
};
122134
// On a void you receive the LESSER of market value (at the TWAP) and
@@ -154,28 +166,40 @@ function maybeTick() {
154166
tick();
155167
}
156168

169+
function tickOne(m, now = Date.now()) {
170+
if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m);
171+
else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m);
172+
else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) {
173+
log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`);
174+
settleVoid(m);
175+
} else if (m.status === 'disputed'
176+
// Anchor to the LATEST dispute: anchoring to the first gave a
177+
// late disputer a truncated window and took a bond that could
178+
// never be heard.
179+
&& now >= (m.disputes[m.disputes.length - 1].at + disputeGraceMs)) {
180+
// Fall through to WHAT THE ORACLE PROPOSED — a resolution if
181+
// there was one, otherwise the void it proposed. An unadjudicated
182+
// dispute must not cancel a bet you lost, and must not invent a
183+
// resolution that never existed.
184+
log.warn(`markets: ${m.id} dispute expired unadjudicated — the oracle's call stands`);
185+
if (Number.isInteger(m.resolvedOutcome)) settleResolved(m);
186+
else settleVoid(m);
187+
}
188+
}
189+
157190
function tick() {
158191
const now = Date.now();
159192
for (const m of Object.values(state.markets)) {
160193
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-
}
194+
tickOne(m, now);
174195
} catch (e) {
196+
// Isolate one bad market — but a systematic failure (a broken
197+
// dependency, say) silently stalls EVERY settlement, so this is
198+
// logged at error level and never swallowed quietly.
175199
log.error(`markets: tick failed for ${m.id}: ${e.message}`);
176200
}
177201
}
178202
}
179203

180-
return { voidPrices, settle, settleResolved, settleVoid, tick, maybeTick };
204+
return { voidPrices, settle, settleResolved, settleVoid, tick, tickOne, maybeTick };
181205
}

markets/plugin.js

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export async function activate(api) {
147147
const settlementWindowMs = num(cfg.settlementWindowMs, 7 * 24 * 3600 * 1000);
148148
const twapWindowMs = num(cfg.twapWindowMs, 30 * 60 * 1000);
149149
const sessionTtlMs = num(cfg.sessionTtlMs, 12 * 3600 * 1000);
150+
const rateCapacity = num(cfg.rateCapacity, 120);
151+
const rateRefillPerSec = num(cfg.rateRefillPerSec, 2);
150152
const allowInsiderTrading = cfg.allowInsiderTrading === true;
151153
const admins = new Set(Array.isArray(cfg.admins) ? cfg.admins : []);
152154

@@ -173,6 +175,22 @@ export async function activate(api) {
173175
throw new Error(`markets: config.${name} must be a positive number of milliseconds (got ${v})`);
174176
}
175177
}
178+
// Get these two the wrong way round and a resolution is still inside
179+
// its dispute window when the abandoned-market backstop opens — which
180+
// made every resolution voidable by every loser.
181+
if (disputeWindowMs >= settlementWindowMs) {
182+
throw new Error(
183+
`markets: config.disputeWindowMs (${disputeWindowMs}) must be shorter than `
184+
+ `settlementWindowMs (${settlementWindowMs})`,
185+
);
186+
}
187+
if (!Number.isFinite(disputeBondBps) || disputeBondBps < 0 || disputeBondBps > 10_000) {
188+
throw new Error('markets: config.disputeBondBps must be 0..10000');
189+
}
190+
if (!Number.isFinite(rateCapacity) || rateCapacity <= 0
191+
|| !Number.isFinite(rateRefillPerSec) || rateRefillPerSec <= 0) {
192+
throw new Error('markets: config.rateCapacity and config.rateRefillPerSec must be positive');
193+
}
176194
if (twapWindowMs > TWAP_PROTECT_MS) {
177195
throw new Error(
178196
`markets: config.twapWindowMs (${twapWindowMs}) must not exceed ${TWAP_PROTECT_MS}ms — beyond that, `
@@ -190,7 +208,7 @@ export async function activate(api) {
190208
const { state } = store;
191209
const sessions = createSessions({ dir, ttlMs: sessionTtlMs });
192210
const pseudonymSalt = readOrCreateSecret(path.join(dir, 'pseudonym.salt'));
193-
const limiter = createRateLimiter({ capacity: num(cfg.rateCapacity, 120), refillPerSec: num(cfg.rateRefillPerSec, 2) });
211+
const limiter = createRateLimiter({ capacity: rateCapacity, refillPerSec: rateRefillPerSec });
194212

195213
/** agent → Set(marketId) — so /api/me is O(your markets), not O(all). */
196214
const byAgent = new Map();
@@ -496,7 +514,7 @@ export async function activate(api) {
496514
// The settlement state machine lives in lifecycle.js — see that file
497515
// for why it is not inline here (a state change that skipped the
498516
// reducer made the audit trail contradict the money).
499-
const { voidPrices, settleResolved, settleVoid, tick, maybeTick } = createLifecycle({
517+
const { voidPrices, settleResolved, settleVoid, tick, tickOne, maybeTick } = createLifecycle({
500518
state,
501519
commit: store.commit,
502520
broadcast: (type, m) => broadcast(type, m),
@@ -511,7 +529,7 @@ export async function activate(api) {
511529
// Reject cross-origin upgrades: public data today, but an unchecked
512530
// origin makes any future per-agent field on the wire a leak.
513531
const wsOrigin = request.headers && request.headers.origin;
514-
if (wsOrigin && !isSameOrigin({ headers: { origin: wsOrigin } }, ownOrigin())) {
532+
if (wsOrigin && !isSameOrigin({ headers: { origin: wsOrigin } }, originFor(request))) {
515533
try { socket.close(1008, 'cross-origin'); } catch { /* gone */ }
516534
return;
517535
}
@@ -965,7 +983,10 @@ export async function activate(api) {
965983
api.fastify.post(`${prefix}/api/markets/:id/settle`, jsonOpts(512), async (request, reply) => {
966984
const m = state.markets[request.params.id];
967985
if (!m) return err(reply, 404, 'no such market');
968-
tick();
986+
// Advance THIS market only: a full scan here was a free O(all
987+
// markets) job for any anonymous caller, and throttling it instead
988+
// turned an explicit "settle now" into a silent no-op.
989+
tickOne(m);
969990
if (m.status === 'resolved' || m.status === 'void') return reply.send(marketOut(m));
970991
if (m.status === 'resolving' || m.status === 'voiding') {
971992
return err(reply, 409, `settles at ${new Date(m.settleAt).toISOString()} (dispute window open)`);
@@ -985,7 +1006,7 @@ export async function activate(api) {
9851006
// 'disputed' is disputable too: latching on the FIRST disputer meant
9861007
// one person paid the bond and every other loser free-rode on the
9871008
// resulting void. Each disputer posts their own.
988-
tick(); // otherwise a market past settleAt is still 'resolving' here
1009+
tickOne(m); // otherwise a market past settleAt is still 'resolving' here
9891010
if (m.status !== 'resolving' && m.status !== 'disputed' && m.status !== 'voiding') {
9901011
return err(reply, 409, 'only a resolving market can be disputed');
9911012
}
@@ -1022,7 +1043,10 @@ export async function activate(api) {
10221043
if (!agent) return reply;
10231044
const m = state.markets[request.params.id];
10241045
if (!m) return err(reply, 404, 'no such market');
1025-
const stale = Date.now() >= m.closesAt + settlementWindowMs;
1046+
// Only an ABANDONED market qualifies for the anyone-can-rescue
1047+
// backstop: resolving/voiding/disputed all mean the oracle acted and
1048+
// a settlement is already pending.
1049+
const stale = m.status === 'open' && Date.now() >= m.closesAt + settlementWindowMs;
10261050
if (m.status === 'resolved' || m.status === 'void') return err(reply, 409, `market is ${m.status}`);
10271051
if (m.status === 'voiding' && !admins.has(agent) && !stale) {
10281052
return err(reply, 409, `a void is already proposed; it settles at ${new Date(m.settleAt).toISOString()}`);
@@ -1159,18 +1183,25 @@ export async function activate(api) {
11591183
const m = state.markets[market];
11601184
if (!m) return err(reply, 404, 'no such market');
11611185
if (m.status !== 'disputed') return err(reply, 409, `market is ${displayStatus(m)}, not disputed`);
1186+
const proposedVoid = !Number.isInteger(m.resolvedOutcome);
11621187
if (uphold === true) {
1163-
settleResolved(m, { adjudicatedBy: by });
1188+
// Uphold whatever the oracle actually proposed — a void proposal
1189+
// has no resolution to uphold.
1190+
if (proposedVoid) settleVoid(m, { adjudicatedBy: by });
1191+
else settleResolved(m, { adjudicatedBy: by });
11641192
} else if (uphold === false && Number.isInteger(outcome)) {
11651193
// RE-RESOLVE: the oracle got it wrong and we know the right answer.
11661194
// Voiding would refund the loser and wipe out whoever was RIGHT, so
11671195
// a correctable error needs its own verb.
11681196
if (outcome < 0 || outcome >= m.outcomes.length) return err(reply, 400, 'outcome must be a valid outcome index');
11691197
settleResolved(m, { adjudicatedBy: by, sustained: true, outcome });
11701198
} else if (uphold === false) {
1199+
if (proposedVoid) {
1200+
return err(reply, 400, 'this market has a void proposed, not a resolution — supply an outcome to resolve it instead');
1201+
}
11711202
settleVoid(m, { adjudicatedBy: by, sustained: true });
11721203
} else {
1173-
return err(reply, 400, 'uphold must be true (resolution stands), or false (void) — with an outcome to re-resolve instead');
1204+
return err(reply, 400, "uphold must be true (the oracle's call stands), or false with an outcome to re-resolve");
11741205
}
11751206
api.log.warn(`markets: admin ${by} adjudicated ${m.id}: ${uphold ? 'upheld' : (Number.isInteger(outcome) ? `re-resolved to ${outcome}` : 'voided')}`);
11761207
return reply.send(marketOut(m));

0 commit comments

Comments
 (0)