Skip to content

Commit 4a56c21

Browse files
markets/: adjudication, crash-recovery fix, and the void cost cap
Second review round against five adversarial critics. The two findings that were genuinely dangerous: - Crash recovery lost acknowledged writes. Dropping a torn journal tail is only half the job: reopening in append mode over the fragment welds the next fsync'd event onto a partial line, so the boot after that silently drops a real credit movement and reuses its seq. The journal is now truncated to the last complete line before append, torn-line detection is index-based, and the test crashes, writes, and restarts again to prove the write survives. - Disputes could only ever end in a void, which makes disputing a free refund option on any lost bet -- rational play voids every contested market and winners are never paid. Added an admin uphold/void verb, a dispute queue, and a forfeitable bond. Also: a void now pays min(TWAP value, cost basis), which kills the sustained-pump grief the TWAP alone couldn't (a pump held across the whole window makes the TWAP equal the pumped price); positive-window config validation, because twapWindowMs=0 silently degenerated the void back to spot pricing and resurrected the original arbitrage; the CSRF check no longer lets a junk Authorization header mark a cookie-borne request as non-ambient; salted leaderboard pseudonyms; hidden markets are untradable rather than merely unlisted; journal rotation and a seq-contiguity check at boot; price-history compaction that never thins inside the TWAP window; an operator agent-history query for support. A test unit-minting session secrets into the source tree got one committed in 3e07250 -- removed, gitignored, and the test now uses a temp dir. Any deployment sharing that dir should be treated as compromised. 60 plugin tests, 39 compose tests.
1 parent 3e07250 commit 4a56c21

7 files changed

Lines changed: 520 additions & 56 deletions

File tree

markets/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
session.secret
2+
pseudonym.salt
3+
*.jsonl

markets/README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ payout is **capped at their escrow**; and a resolution sits in a
5252
A market whose oracle never acts is **auto-voided at TWAP** after the
5353
settlement window — anyone can trigger it, so funds are never stuck.
5454

55+
Disputes cost a **bond** (`disputeBondCredits`, default 25), forfeited to
56+
the house if the resolution is upheld and returned if it isn't. Without a
57+
bond and an *uphold* verb, disputing is a free refund option on any lost
58+
bet and every rational loser disputes; an operator resolves the queue at
59+
`GET /api/admin/disputes``POST /api/admin/adjudicate`.
60+
61+
**A void never pays a holder more than they paid.** Redemption is
62+
`min(TWAP value, cost basis)` per outcome. The TWAP alone defeats a
63+
last-second pump but not one *held across the whole window*; the cap
64+
makes pumping-to-be-voided unprofitable at any hold duration, and since
65+
it only ever pays less than the TWAP, conservation is untouched.
66+
5567
## What this is not (deliberately)
5668

5769
**Not real money.** A real-money book is a gambling licence, KYC/AML,
@@ -127,6 +139,14 @@ the walls point at.
127139
regression test for exactly this attack, and it caught a real bug: the
128140
price path was seeded with `m.history || [seed]`, and an empty array is
129141
truthy, so the TWAP degenerated to the post-pump spot price.
142+
- **Dropping a torn journal tail is only half of crash recovery.** The
143+
fragment must also be TRUNCATED before reopening for append —
144+
otherwise the next acknowledged, fsync'd event is welded onto the
145+
partial line, and the boot after that silently drops a real credit
146+
movement and reuses its sequence number. A durability design can pass
147+
every "does it survive a restart" test and still fail the one crash it
148+
exists to survive; the regression test now crashes, writes, and
149+
restarts again.
130150
- **Atomicity by construction is fragile and undocumented.** Every
131151
mutating handler awaits auth first, then validates and commits with no
132152
`await` in between, so the event loop makes each trade a transaction.
@@ -140,14 +160,16 @@ the walls point at.
140160

141161
## Tests
142162

143-
`node --test --test-concurrency=1 markets/test.js`51 tests: LMSR and
163+
`node --test --test-concurrency=1 markets/test.js`60 tests: LMSR and
144164
TWAP math, session/CSRF/rate-limit units, hardened headers, cookie
145165
scoping, prototype-key ids, grants, escrow, stake-first quotes,
146166
quote↔trade parity, slippage guards (including the NaN-fails-closed case),
147167
no-shorting, idempotent retries, 12 concurrent trades, ws privacy,
148168
pagination and search, the full settlement state machine (resolve →
149169
dispute → settle, void, early close, dead-oracle rescue), the
150170
self-dealing and pump-and-void attacks, 12-outcome and no-trade markets,
151-
admin gating, reboot with an open market mid-flight, journal integrity,
152-
corrupt-snapshot boot refusal — and micro-credit-exact conservation
153-
after every single one.
171+
the admin plane (hide-makes-untradable, freeze, journalled adjust, agent
172+
history), both adjudication paths, the sustained-pump void, reboot with
173+
an open market mid-flight, journal integrity, journal-gap and
174+
corrupt-snapshot boot refusal, and torn-tail crash recovery — with
175+
micro-credit-exact conservation asserted after every single one.

markets/plugin.js

Lines changed: 140 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
// in-memory ledger ahead of the durable one.
5858

5959
import crypto from 'node:crypto';
60+
import fs from 'node:fs';
61+
import path from 'node:path';
6062
import { lmsrPrices, tradeCostRaw, sharesForBudget, twapPrices, uniformPrices } from './lmsr.js';
6163
import { createStore, dict } from './store.js';
6264
import {
@@ -104,6 +106,17 @@ export function randomId(len = 8) {
104106
/** An agent id is a WebID (http/https URL) or a DID — the two shapes
105107
* getAgent can ever return. Rejecting anything else at creation stops a
106108
* typo'd oracle from being an unsatisfiable settlement condition. */
109+
/** A persistent per-deployment secret, created 0600 on first boot. */
110+
function readOrCreateSecret(file) {
111+
try {
112+
return fs.readFileSync(file);
113+
} catch {
114+
const s = crypto.randomBytes(32);
115+
fs.writeFileSync(file, s, { mode: 0o600 });
116+
return s;
117+
}
118+
}
119+
107120
export function isAgentId(s) {
108121
if (typeof s !== 'string' || !s || s.length > 512) return false;
109122
if (s.startsWith('did:')) return /^did:[a-z0-9]+:[\w.:%-]+$/i.test(s);
@@ -122,7 +135,8 @@ export async function activate(api) {
122135
const grantMicro = Math.round(num(cfg.grantCredits, 1000) * MICRO);
123136
const feeBps = num(cfg.feeBps, 100);
124137
const houseFeeShareBps = num(cfg.houseFeeShareBps, 5000);
125-
const disputeWindowMs = num(cfg.disputeWindowMs, 10 * 60 * 1000);
138+
const disputeWindowMs = num(cfg.disputeWindowMs, 60 * 60 * 1000);
139+
const disputeBondMicro = Math.round(num(cfg.disputeBondCredits, 25) * MICRO);
126140
const disputeGraceMs = num(cfg.disputeGraceMs, 7 * 24 * 3600 * 1000);
127141
const settlementWindowMs = num(cfg.settlementWindowMs, 7 * 24 * 3600 * 1000);
128142
const twapWindowMs = num(cfg.twapWindowMs, 30 * 60 * 1000);
@@ -142,13 +156,28 @@ export async function activate(api) {
142156
for (const a of admins) {
143157
if (!isAgentId(a)) throw new Error(`markets: config.admins contains a non-agent id: ${a}`);
144158
}
159+
// These windows are load-bearing, not cosmetic: twapWindowMs = 0 makes
160+
// voidPrices() degenerate to the SPOT price, which resurrects the
161+
// buy-then-void arbitrage the TWAP exists to prevent. Refuse to boot on
162+
// a value that would silently disable a defence.
163+
for (const [name, v] of Object.entries({
164+
disputeWindowMs, disputeGraceMs, settlementWindowMs, twapWindowMs, sessionTtlMs,
165+
})) {
166+
if (!Number.isFinite(v) || v <= 0) {
167+
throw new Error(`markets: config.${name} must be a positive number of milliseconds (got ${v})`);
168+
}
169+
}
170+
if (!Number.isFinite(disputeBondMicro) || disputeBondMicro < 0) {
171+
throw new Error('markets: config.disputeBondCredits must be a non-negative number');
172+
}
145173
if (/["'<>]/.test(prefix)) throw new Error(`markets: refusing an unsafe prefix: ${prefix}`);
146174

147175
// -------------------------------------------------- store & security
148176
const dir = api.storage.pluginDir();
149177
const store = createStore({ dir, log: api.log, prices: lmsrPrices });
150178
const { state } = store;
151179
const sessions = createSessions({ dir, ttlMs: sessionTtlMs });
180+
const pseudonymSalt = readOrCreateSecret(path.join(dir, 'pseudonym.salt'));
152181
const limiter = createRateLimiter({ capacity: num(cfg.rateCapacity, 120), refillPerSec: num(cfg.rateRefillPerSec, 2) });
153182

154183
/** agent → Set(marketId) — so /api/me is O(your markets), not O(all). */
@@ -211,7 +240,12 @@ export async function activate(api) {
211240
* credential is ambient (cookie/TLS cert), because those are exactly
212241
* the credentials a cross-origin page can borrow. */
213242
function csrfOk(request) {
214-
if (!isAmbientCredential(request)) return true;
243+
// A session cookie makes the request ambient REGARDLESS of any
244+
// Authorization header: resolveAgent checks the cookie first, so an
245+
// attacker could otherwise bolt on a junk bearer to look
246+
// "explicitly credentialed", skip this check, and still be
247+
// authenticated by the victim's cookie.
248+
if (!cookieToken(request) && !isAmbientCredential(request)) return true;
215249
return isSameOrigin(request, ownOrigin());
216250
}
217251

@@ -316,7 +350,16 @@ export async function activate(api) {
316350
outcomes: m.outcomes,
317351
prices: prices.map((p) => Number(p.toFixed(6))),
318352
status: displayStatus(m),
353+
// The RAW lifecycle state, distinct from the display status: a
354+
// market past closesAt displays as 'closed' while its raw status is
355+
// still 'open', and that is exactly when the oracle must resolve.
356+
// Without this a client can't tell "closed, awaiting resolution"
357+
// from "settled", and hides the resolve controls at the only moment
358+
// they matter.
359+
rawStatus: m.status,
319360
tradable: tradable(m),
361+
canResolve: m.status === 'open',
362+
canVoid: m.status !== 'resolved' && m.status !== 'void',
320363
closesAt: new Date(m.closesAt).toISOString(),
321364
createdAt: m.createdAt,
322365
creator: m.creator,
@@ -421,7 +464,7 @@ export async function activate(api) {
421464
* in the event, and applied by the reducer — so replay never recomputes
422465
* float arithmetic.
423466
*/
424-
function settle(m, status, payoutMicroOf, prices) {
467+
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null } = {}) {
425468
const pool = m.subsidyMicro + m.collectedMicro;
426469
const raw = [];
427470
let sum = 0;
@@ -453,23 +496,46 @@ export async function activate(api) {
453496
const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000);
454497
const creatorFee = m.feesMicro - houseFee;
455498

499+
// Dispute bonds: refunded if the market ends up VOID (the disputer
500+
// was vindicated), forfeited to the house if the resolution stands.
501+
const bondRefunds = {};
502+
let bondToHouse = 0;
503+
for (const d of m.disputes || []) {
504+
if (!d.bondMicro) continue;
505+
if (status === 'void') bondRefunds[d.agent] = (bondRefunds[d.agent] || 0) + d.bondMicro;
506+
else bondToHouse += d.bondMicro;
507+
}
508+
456509
store.commit({
457510
type: 'market.settle',
458511
marketId: m.id,
459512
status,
460513
payouts,
514+
bondRefunds,
461515
creatorMicro: creatorFromPool + creatorFee,
462-
houseMicro: houseFromPool + houseFee,
516+
houseMicro: houseFromPool + houseFee + bondToHouse,
463517
house: HOUSE,
518+
adjudicatedBy,
464519
prices: prices ? prices.map((p) => Number(p.toFixed(6))) : null,
465520
});
466521
broadcast('settle', m);
467522
}
468523

469-
const settleResolved = (m) => settle(m, 'resolved', (pos) => pos.shares[m.resolvedOutcome], null);
470-
const settleVoid = (m) => {
524+
const settleResolved = (m, opts) => settle(m, 'resolved', (pos) => pos.shares[m.resolvedOutcome], null, opts);
525+
// On a void you receive the LESSER of market value (at the TWAP) and
526+
// what you actually paid. The cap is what finally kills the void
527+
// arbitrage: the TWAP already defeats a last-second pump, but a
528+
// *sustained* pump held across the whole window makes the TWAP equal
529+
// the pumped price, and against a dead oracle that is a profitable
530+
// grief funded by the creator's escrow. Capping at cost basis means no
531+
// holder can ever exit a void for more than they put in, so pumping to
532+
// be voided is never profitable at any hold duration. It only ever
533+
// pays LESS than the TWAP, so conservation is strictly preserved.
534+
const settleVoid = (m, opts) => {
471535
const p = voidPrices(m);
472-
settle(m, 'void', (pos) => pos.shares.reduce((a, s, i) => a + s * p[i], 0), p);
536+
settle(m, 'void',
537+
(pos) => pos.shares.reduce((a, s, i) => a + Math.min(s * p[i], pos.costMicro[i]), 0),
538+
p, opts);
473539
};
474540

475541
/**
@@ -787,6 +853,9 @@ export async function activate(api) {
787853
// contract (see header). Do not introduce one.
788854
const m = state.markets[request.params.id];
789855
if (!m) return err(reply, 404, 'no such market');
856+
// A hidden market must be UNTRADABLE, not merely unlisted: takedown
857+
// that leaves the URL working is not takedown.
858+
if (m.hidden) return err(reply, 403, 'this market has been withdrawn by the operator');
790859
if (!tradable(m)) return err(reply, 409, `market is ${displayStatus(m)} — trading has stopped`);
791860
if (!allowInsiderTrading && (agent === m.oracle || agent === m.creator)) {
792861
return err(reply, 403, 'the creator and oracle of a market may not trade in it');
@@ -925,7 +994,14 @@ export async function activate(api) {
925994
if (!pos || pos.shares.every((s) => s === 0)) return err(reply, 403, 'only a holder may dispute');
926995
const reason = typeof (request.body || {}).reason === 'string'
927996
? request.body.reason.slice(0, 500) : '';
928-
store.commit({ type: 'market.dispute', marketId: m.id, agent, reason });
997+
if (!reason.trim()) return err(reply, 400, 'a reason is required to dispute');
998+
ensureAccount(agent);
999+
if (balanceOf(agent) < disputeBondMicro) {
1000+
return err(reply, 402, `disputing stakes a bond of ${(disputeBondMicro / MICRO).toFixed(2)} credits, forfeited if the resolution is upheld`);
1001+
}
1002+
store.commit({
1003+
type: 'market.dispute', marketId: m.id, agent, reason, bondMicro: disputeBondMicro,
1004+
});
9291005
api.log.warn(`markets: ${m.id} disputed by ${agent}: ${reason}`);
9301006
broadcast('market', m);
9311007
return reply.send(marketOut(m));
@@ -962,7 +1038,7 @@ export async function activate(api) {
9621038
// market escrows subsidy + collected + fees, and all three are paid
9631039
// out at settlement. Omitting fees here would make the conservation
9641040
// figure drift by exactly the fee take.
965-
openPoolMicro += m.subsidyMicro + m.collectedMicro + m.feesMicro;
1041+
openPoolMicro += m.subsidyMicro + m.collectedMicro + m.feesMicro + (m.disputeBondMicro || 0);
9661042
open++;
9671043
}
9681044
return reply.send({
@@ -992,9 +1068,12 @@ export async function activate(api) {
9921068
return reply.send({ leaderboard: top });
9931069
});
9941070

995-
/** Stable pseudonym: a leaderboard should show a rival, not a dossier. */
1071+
/** Stable pseudonym: a leaderboard should show a rival, not a dossier.
1072+
* SALTED with a per-deployment secret — an unsalted hash of a WebID is
1073+
* not a pseudonym at all, since anyone can hash a known WebID and
1074+
* unmask the row. */
9961075
function anonymize(agentId) {
997-
return `anon-${crypto.createHash('sha256').update(agentId).digest('hex').slice(0, 8)}`;
1076+
return `anon-${crypto.createHmac('sha256', pseudonymSalt).update(agentId).digest('hex').slice(0, 8)}`;
9981077
}
9991078

10001079
// ---- admin ----------------------------------------------------------
@@ -1029,6 +1108,56 @@ export async function activate(api) {
10291108
return reply.send({ ok: true, agent, balance: balanceOf(agent) / MICRO });
10301109
});
10311110

1111+
// The adjudication verb. Without it a dispute could only ever end in a
1112+
// void, which makes disputing a free refund option on any lost bet:
1113+
// every rational loser disputes, and correct resolutions never stand.
1114+
api.fastify.post(`${prefix}/api/admin/adjudicate`, jsonOpts(1024), async (request, reply) => {
1115+
const by = await adminOnly(request, reply);
1116+
if (!by) return reply;
1117+
const { market, uphold } = request.body || {};
1118+
const m = state.markets[market];
1119+
if (!m) return err(reply, 404, 'no such market');
1120+
if (m.status !== 'disputed') return err(reply, 409, `market is ${displayStatus(m)}, not disputed`);
1121+
if (uphold === true) settleResolved(m, { adjudicatedBy: by });
1122+
else if (uphold === false) settleVoid(m, { adjudicatedBy: by });
1123+
else return err(reply, 400, 'uphold must be true (the resolution stands) or false (void it)');
1124+
api.log.warn(`markets: admin ${by} ${uphold ? 'upheld' : 'voided'} disputed market ${m.id}`);
1125+
return reply.send(marketOut(m));
1126+
});
1127+
1128+
// Disputes awaiting adjudication — the operator's work queue.
1129+
api.fastify.get(`${prefix}/api/admin/disputes`, async (request, reply) => {
1130+
const by = await adminOnly(request, reply);
1131+
if (!by) return reply;
1132+
const queue = Object.values(state.markets)
1133+
.filter((m) => m.status === 'disputed')
1134+
.map((m) => ({
1135+
...marketOut(m),
1136+
disputeDetail: (m.disputes || []).map((d) => ({
1137+
agent: d.agent, reason: d.reason, at: new Date(d.at).toISOString(), bond: (d.bondMicro || 0) / MICRO,
1138+
})),
1139+
autoVoidsAt: new Date((m.disputes[0]?.at || Date.now()) + disputeGraceMs).toISOString(),
1140+
}));
1141+
return reply.send({ disputes: queue });
1142+
});
1143+
1144+
// Everything an operator needs to answer "what happened to this
1145+
// account?" — the journal, filtered, instead of grep on a server.
1146+
api.fastify.get(`${prefix}/api/admin/agent`, async (request, reply) => {
1147+
const by = await adminOnly(request, reply);
1148+
if (!by) return reply;
1149+
const who = (request.query || {}).agent;
1150+
if (!isAgentId(who)) return err(reply, 400, 'agent must be an agent id');
1151+
const row = state.ledger[who];
1152+
return reply.send({
1153+
agent: who,
1154+
balance: row ? row.balanceMicro / MICRO : 0,
1155+
frozen: !!(row && row.frozen),
1156+
created: row ? row.created : null,
1157+
history: store.eventsFor(who).map((e) => ({ ...e, t: new Date(e.t).toISOString() })),
1158+
});
1159+
});
1160+
10321161
api.fastify.post(`${prefix}/api/admin/hide`, jsonOpts(1024), async (request, reply) => {
10331162
const by = await adminOnly(request, reply);
10341163
if (!by) return reply;

markets/session.secret

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)