Skip to content

Commit b11d7a5

Browse files
markets/: journal the adjudicated outcome; an oracle void is a proposal
Two more criticals from the fourth review round. A re-resolution assigned m.resolvedOutcome directly in the route handler and then settled. The payouts were 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 in the reducer and nowhere else; one assignment outside it was enough to make the audit trail contradict the money. The outcome now rides in the settle event and there is a test that rebuilds from the journal and checks it. An oracle could void any market that had stopped trading and claw back its whole escrow. A void pays min(TWAP, cost basis), so a trader who is up gets their stake back and one who is down gets less -- traders can only lose -- while the creator recovers the b*ln n they advertised as liquidity. The oracle never had to steal the pool, only refuse to pay it. An oracle void is now a proposal that sits in the same dispute window a resolution does; operators and the abandoned-market backstop still settle immediately. Also: settled positions kept showing as live value, so a user saw the same money twice (open position and settlement receipt); a deployment with no operator took a dispute bond that could never be returned; originFor() used the socket protocol, so behind a TLS-terminating proxy every browser mutation would 403; hidden markets still echoed their text through /quote, /history and settle broadcasts; eventsFor sorted segments lexicographically, so journal.100 came before journal.20; a missing ledger row skipped the session epoch check; /dispute never ticked, so the window could be entered after it closed; and twapWindowMs beyond the never-thinned history window is now refused at boot rather than quietly making the void price steerable. 66 plugin tests, 39 compose tests.
1 parent fbbec27 commit b11d7a5

4 files changed

Lines changed: 157 additions & 17 deletions

File tree

markets/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ the oracle was wrong and we know the right answer), and **void**.
7373
Re-resolution matters because voiding an incorrect resolution refunds the
7474
loser and wipes out whoever actually backed the correct outcome.
7575

76+
An **oracle-initiated void is only a proposal** and sits in the same
77+
dispute window a resolution does. A void pays `min(TWAP, cost basis)`, so
78+
it is a payoff traders can only lose on while the creator recovers their
79+
escrow — the oracle never has to steal the pool, only refuse to pay it.
80+
Operators, and the anyone-can-rescue backstop on an abandoned market,
81+
still settle immediately.
82+
7683
**A deployment with no `admins` cannot adjudicate anything** — every
7784
dispute expires into the oracle's resolution — so the plugin says so
7885
loudly at boot rather than letting the advertised check be quietly
@@ -159,6 +166,17 @@ the walls point at.
159166
regression test for exactly this attack, and it caught a real bug: the
160167
price path was seeded with `m.history || [seed]`, and an empty array is
161168
truthy, so the TWAP degenerated to the post-pump spot price.
169+
- **A state change that skips the reducer is a lie the audit trail tells
170+
later.** Adjudicating a wrong resolution assigned `m.resolvedOutcome`
171+
directly in the route handler and then settled. Payouts were correct
172+
and journalled; the OUTCOME was not — so replaying the journal (the
173+
recovery the boot error itself recommends) restored the oracle's
174+
original wrong answer while the credits sat with the corrected one, and
175+
the settlement receipts recorded the stale value too. store.js already
176+
said "every mutation happens HERE and nowhere else"; one assignment
177+
outside it was enough. Event sourcing only holds if the invariant is
178+
structural — which is the argument for extracting the lifecycle from
179+
the route layer entirely.
162180
- **A self-verifying credential needs an epoch, and the type of what
163181
`verify()` returns is a money bug.** Widening the session verifier from
164182
"returns the agent id" to "returns the claims" without updating its two
@@ -191,7 +209,7 @@ the walls point at.
191209

192210
## Tests
193211

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

markets/plugin.js

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import crypto from 'node:crypto';
6262
import fs from 'node:fs';
6363
import path from 'node:path';
6464
import { lmsrPrices, tradeCostRaw, sharesForBudget, twapPrices, uniformPrices } from './lmsr.js';
65-
import { createStore, dict } from './store.js';
65+
import { createStore, dict, TWAP_PROTECT_MS } from './store.js';
6666
import {
6767
createSessions, createRateLimiter, isSameOrigin, isAmbientCredential, UI_HEADERS, API_HEADERS,
6868
} from './guard.js';
@@ -171,6 +171,12 @@ export async function activate(api) {
171171
throw new Error(`markets: config.${name} must be a positive number of milliseconds (got ${v})`);
172172
}
173173
}
174+
if (twapWindowMs > TWAP_PROTECT_MS) {
175+
throw new Error(
176+
`markets: config.twapWindowMs (${twapWindowMs}) must not exceed ${TWAP_PROTECT_MS}ms — beyond that, `
177+
+ 'price-history thinning reaches inside the redemption window and makes the void price steerable by trade timing',
178+
);
179+
}
174180
if (!Number.isFinite(disputeBondMicro) || disputeBondMicro < 0) {
175181
throw new Error('markets: config.disputeBondCredits must be a non-negative number');
176182
}
@@ -231,7 +237,7 @@ export async function activate(api) {
231237
const claims = sessions.verify(token);
232238
if (!claims) return null;
233239
const row = state.ledger[claims.agent];
234-
if (row && (row.epoch || 0) !== claims.epoch) return null;
240+
if ((row ? row.epoch || 0 : 0) !== claims.epoch) return null;
235241
return claims.agent;
236242
}
237243

@@ -262,7 +268,12 @@ export async function activate(api) {
262268
const known = ownOrigin();
263269
if (known) return known;
264270
const host = request.headers.host;
265-
return host ? `${request.protocol || 'http'}://${host}` : null;
271+
if (!host) return null;
272+
// Fastify reports the SOCKET's protocol unless trustProxy is on,
273+
// which a plugin cannot set; behind nginx/Caddy that is 'http' while
274+
// the browser's Origin says https, and every mutation would 403.
275+
const proto = request.headers['x-forwarded-proto'] || request.protocol || 'https';
276+
return `${String(proto).split(',')[0].trim()}://${host}`;
266277
}
267278

268279
function csrfOk(request) {
@@ -492,7 +503,7 @@ export async function activate(api) {
492503
* in the event, and applied by the reducer — so replay never recomputes
493504
* float arithmetic.
494505
*/
495-
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false } = {}) {
506+
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false, outcome = null } = {}) {
496507
const pool = m.subsidyMicro + m.collectedMicro;
497508
const raw = [];
498509
let sum = 0;
@@ -541,6 +552,10 @@ export async function activate(api) {
541552
type: 'market.settle',
542553
marketId: m.id,
543554
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,
544559
payouts,
545560
bondRefunds,
546561
creatorMicro: creatorFromPool + creatorFee,
@@ -552,7 +567,12 @@ export async function activate(api) {
552567
broadcast('settle', m);
553568
}
554569

555-
const settleResolved = (m, opts) => settle(m, 'resolved', (pos) => pos.shares[m.resolvedOutcome], null, opts);
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+
};
556576
// On a void you receive the LESSER of market value (at the TWAP) and
557577
// what you actually paid. The cap is what finally kills the void
558578
// arbitrage: the TWAP already defeats a last-second pump, but a
@@ -593,6 +613,7 @@ export async function activate(api) {
593613
for (const m of Object.values(state.markets)) {
594614
try {
595615
if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m);
616+
else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m);
596617
else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) {
597618
api.log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`);
598619
settleVoid(m);
@@ -656,6 +677,9 @@ export async function activate(api) {
656677
ticker.unref?.();
657678

658679
function broadcast(type, m) {
680+
// A withdrawn market must not push its title and description to
681+
// every connected client when it settles.
682+
if (m.hidden) return;
659683
const msg = JSON.stringify({ type, market: marketOut(m) });
660684
for (const s of sockets) {
661685
// Drop a client that isn't draining rather than buffering without
@@ -737,6 +761,7 @@ export async function activate(api) {
737761
for (const id of byAgent.get(agent) || []) {
738762
const m = state.markets[id];
739763
if (!m) continue;
764+
if (m.status === 'resolved' || m.status === 'void') continue; // paid out; the receipt is the record
740765
const pos = m.positions[agent];
741766
if (!pos || pos.shares.every((s) => s === 0)) continue;
742767
positions.push({
@@ -919,6 +944,7 @@ export async function activate(api) {
919944
api.fastify.get(`${prefix}/api/markets/:id/quote`, async (request, reply) => {
920945
const m = state.markets[request.params.id];
921946
if (!m) return err(reply, 404, 'no such market');
947+
if (m.hidden) return err(reply, 451, 'this market has been withdrawn by the operator');
922948
const q = request.query || {};
923949
const t = priceTrade(m, q.side, q.outcome, q.shares, q.spend);
924950
if (t.error) return err(reply, 400, t.error);
@@ -1014,6 +1040,7 @@ export async function activate(api) {
10141040
api.fastify.get(`${prefix}/api/markets/:id/history`, async (request, reply) => {
10151041
const m = state.markets[request.params.id];
10161042
if (!m) return err(reply, 404, 'no such market');
1043+
if (m.hidden) return err(reply, 451, 'this market has been withdrawn by the operator');
10171044
return reply.send({
10181045
id: m.id,
10191046
outcomes: m.outcomes,
@@ -1067,7 +1094,7 @@ export async function activate(api) {
10671094
if (!m) return err(reply, 404, 'no such market');
10681095
tick();
10691096
if (m.status === 'resolved' || m.status === 'void') return reply.send(marketOut(m));
1070-
if (m.status === 'resolving') {
1097+
if (m.status === 'resolving' || m.status === 'voiding') {
10711098
return err(reply, 409, `settles at ${new Date(m.settleAt).toISOString()} (dispute window open)`);
10721099
}
10731100
return err(reply, 409, `market is ${displayStatus(m)} — nothing to settle`);
@@ -1085,7 +1112,8 @@ export async function activate(api) {
10851112
// 'disputed' is disputable too: latching on the FIRST disputer meant
10861113
// one person paid the bond and every other loser free-rode on the
10871114
// resulting void. Each disputer posts their own.
1088-
if (m.status !== 'resolving' && m.status !== 'disputed') {
1115+
tick(); // otherwise a market past settleAt is still 'resolving' here
1116+
if (m.status !== 'resolving' && m.status !== 'disputed' && m.status !== 'voiding') {
10891117
return err(reply, 409, 'only a resolving market can be disputed');
10901118
}
10911119
const pos = m.positions[agent];
@@ -1096,6 +1124,11 @@ export async function activate(api) {
10961124
const reason = typeof (request.body || {}).reason === 'string'
10971125
? request.body.reason.slice(0, 500) : '';
10981126
if (!reason.trim()) return err(reply, 400, 'a reason is required to dispute');
1127+
// With no operator configured, `sustained` can never become true, so
1128+
// the bond is mathematically unrecoverable. Don't take it.
1129+
if (!admins.size) {
1130+
return err(reply, 409, 'this deployment has no operator to adjudicate disputes, so a dispute bond could never be returned');
1131+
}
10991132
ensureAccount(agent);
11001133
// Scale with what the dispute puts at risk. A flat bond against a
11011134
// large position is trivially +EV to post: the disputer risks 25 to
@@ -1118,6 +1151,9 @@ export async function activate(api) {
11181151
if (!m) return err(reply, 404, 'no such market');
11191152
const stale = Date.now() >= m.closesAt + settlementWindowMs;
11201153
if (m.status === 'resolved' || m.status === 'void') return err(reply, 409, `market is ${m.status}`);
1154+
if (m.status === 'voiding' && !admins.has(agent) && !stale) {
1155+
return err(reply, 409, `a void is already proposed; it settles at ${new Date(m.settleAt).toISOString()}`);
1156+
}
11211157
// Once disputed, ONLY an admin may settle. Otherwise the oracle
11221158
// answers a dispute against itself by voiding the market, and the
11231159
// dispute is no check on the oracle at all.
@@ -1132,11 +1168,21 @@ export async function activate(api) {
11321168
: 'only the oracle may void before the settlement window expires');
11331169
}
11341170
if (!m.closedAt) store.commit({ type: 'market.close', marketId: m.id });
1135-
// An operator voiding a DISPUTED market has sustained the dispute,
1136-
// whichever route they used to do it.
1137-
settleVoid(m, m.status === 'disputed' && admins.has(agent)
1138-
? { adjudicatedBy: agent, sustained: true } : {});
1139-
api.log.info(`markets: ${m.id} voided (redeemed at TWAP)`);
1171+
1172+
// An operator, or the anyone-can-rescue backstop, settles now.
1173+
if (admins.has(agent) || stale) {
1174+
settleVoid(m, m.status === 'disputed' && admins.has(agent)
1175+
? { adjudicatedBy: agent, sustained: true } : {});
1176+
api.log.info(`markets: ${m.id} voided (redeemed at TWAP)`);
1177+
return reply.send(marketOut(m));
1178+
}
1179+
1180+
// An ORACLE void is only a proposal: it goes through the same
1181+
// dispute window as a resolution, so holders can object before a
1182+
// cancellation they can only lose on becomes final.
1183+
store.commit({ type: 'market.propose-void', marketId: m.id, agent, settleAt: Date.now() + disputeWindowMs });
1184+
api.log.info(`markets: ${m.id} void proposed by the oracle — settles after the dispute window`);
1185+
broadcast('market', m);
11401186
return reply.send(marketOut(m));
11411187
});
11421188

@@ -1247,8 +1293,7 @@ export async function activate(api) {
12471293
// Voiding would refund the loser and wipe out whoever was RIGHT, so
12481294
// a correctable error needs its own verb.
12491295
if (outcome < 0 || outcome >= m.outcomes.length) return err(reply, 400, 'outcome must be a valid outcome index');
1250-
m.resolvedOutcome = outcome;
1251-
settleResolved(m, { adjudicatedBy: by, sustained: true });
1296+
settleResolved(m, { adjudicatedBy: by, sustained: true, outcome });
12521297
} else if (uphold === false) {
12531298
settleVoid(m, { adjudicatedBy: by, sustained: true });
12541299
} else {

markets/store.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ function compactHistory(h, nowT) {
9999
return h;
100100
}
101101

102+
export const TWAP_PROTECT_MS = PROTECT_MS;
103+
102104
export function emptyState() {
103105
return { seq: 0, ledger: dict(), markets: dict(), settlements: dict() };
104106
}
@@ -190,6 +192,15 @@ export function applyEvent(state, ev, prices) {
190192
m.closedAt = m.closedAt || ev.t;
191193
break;
192194
}
195+
case 'market.propose-void': {
196+
const m = state.markets[ev.marketId];
197+
m.status = 'voiding';
198+
m.settleAt = ev.settleAt;
199+
m.closesAt = Math.min(m.closesAt, ev.t);
200+
m.closedAt = m.closedAt || ev.t;
201+
m.resolvedBy = ev.agent;
202+
break;
203+
}
193204
case 'market.resolve': {
194205
const m = state.markets[ev.marketId];
195206
m.status = 'resolving';
@@ -215,6 +226,8 @@ export function applyEvent(state, ev, prices) {
215226
}
216227
case 'market.settle': {
217228
const m = state.markets[ev.marketId];
229+
// Set BEFORE the receipts are written — they record it.
230+
if (ev.outcome !== undefined && ev.outcome !== null) m.resolvedOutcome = ev.outcome;
218231
// Everyone who HELD is given a receipt, not only those who were
219232
// paid: "you lost 12.40 on this" is the settlement a bettor most
220233
// needs to see, and a payout-only list silently drops it.
@@ -460,7 +473,16 @@ export function createStore({ dir, log, prices }) {
460473
if (/^journal\.\d+\.jsonl$/.test(f)) current.add(path.join(dir, f));
461474
}
462475
} catch { /* directory vanished */ }
463-
for (const file of [...current].sort()) {
476+
// Numeric order: lexicographic puts journal.100 before journal.20,
477+
// so a busy account's "most recent 500 events" were the wrong 500.
478+
const ordered = [...current].sort((a, b) => {
479+
const na = /journal\.(\d+)\.jsonl$/.exec(a);
480+
const nb = /journal\.(\d+)\.jsonl$/.exec(b);
481+
if (!na) return 1; // the live journal sorts last
482+
if (!nb) return -1;
483+
return Number(na[1]) - Number(nb[1]);
484+
});
485+
for (const file of ordered) {
464486
try { all.push(...fs.readFileSync(file, 'utf8').split('\n')); } catch { /* rotated away */ }
465487
}
466488
for (const line of all) {

markets/test.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ describe('markets plugin', () => {
197197
disputeWindowMs: 300,
198198
settlementWindowMs: 800,
199199
twapWindowMs: 30 * 60 * 1000,
200+
admins: ['https://operator.example/profile/card#me'],
200201
rateCapacity: 1e9,
201202
rateRefillPerSec: 1e6,
202203
},
@@ -621,7 +622,10 @@ describe('markets plugin', () => {
621622
const live = await json(await call('alice', 'POST', `/markets/${m.id}/void`), 403);
622623
assert.match(live.error, /close the market before voiding/);
623624
await json(await call('alice', 'POST', `/markets/${m.id}/close`), 200);
624-
await json(await call('alice', 'POST', `/markets/${m.id}/void`), 200);
625+
const proposed = await json(await call('alice', 'POST', `/markets/${m.id}/void`), 200);
626+
assert.strictEqual(proposed.status, 'voiding', 'an oracle void is a proposal, not a fait accompli');
627+
await sleep(400);
628+
await json(await call(null, 'POST', `/markets/${m.id}/settle`), 200);
625629
const after = await balance('bob');
626630
assert.ok(after < before,
627631
`pump-and-void must lose money (before ${before}, after ${after})`);
@@ -641,6 +645,8 @@ describe('markets plugin', () => {
641645
const before = await balance('carol');
642646
await json(await call('alice', 'POST', `/markets/${m.id}/close`), 200);
643647
await json(await call('alice', 'POST', `/markets/${m.id}/void`), 200);
648+
await sleep(400); // the oracle's void sits in the dispute window
649+
await json(await call(null, 'POST', `/markets/${m.id}/settle`), 200);
644650
const redeemed = await balance('carol') - before;
645651
assert.ok(redeemed > 8 && redeemed < 20, `redeemed ${redeemed} ≈ 20 shares near 50c`);
646652
await assertConserved('after a holder void');
@@ -971,6 +977,52 @@ describe('markets plugin', () => {
971977
await assertConserved('after a re-resolution');
972978
});
973979

980+
it('a re-resolved outcome survives a restart (it must be in the journal)', async () => {
981+
const m = await json(await call('alice', 'POST', '/markets', {
982+
title: 'Re-resolution must be journalled',
983+
outcomes: ['Home', 'Away'],
984+
closesAt: new Date(Date.now() + 3600e3).toISOString(),
985+
b: 25,
986+
}), 201);
987+
await json(await call('bob', 'POST', `/markets/${m.id}/trade`,
988+
{ side: 'buy', outcome: 0, shares: 8 }), 200);
989+
await json(await call('alice', 'POST', `/markets/${m.id}/resolve`, { outcome: 1 }), 200);
990+
await json(await call('bob', 'POST', `/markets/${m.id}/dispute`, { reason: 'wrong' }), 200);
991+
await json(await call('alice', 'POST', '/admin/adjudicate',
992+
{ market: m.id, uphold: false, outcome: 0 }), 200);
993+
994+
// Rebuild purely from the journal — the recovery the boot error
995+
// itself recommends — and the corrected outcome must still be there.
996+
const { root } = jss;
997+
const port = await probePort();
998+
await jss.close({ keepData: true });
999+
fs.rmSync(path.join(root, '.plugins', 'markets', 'state.json'), { force: true });
1000+
base = `http://127.0.0.1:${port}`;
1001+
mk = `${base}/markets/api`;
1002+
jss = await startJss({
1003+
root, port, idp: true,
1004+
plugins: [{
1005+
module: module_,
1006+
prefix: '/markets',
1007+
config: {
1008+
grantCredits: GRANT, feeBps: 100, baseUrl: base,
1009+
disputeWindowMs: 300, settlementWindowMs: 800, disputeBondCredits: 25,
1010+
rateCapacity: 1e9, rateRefillPerSec: 1e6, admins: [aliceId],
1011+
},
1012+
}],
1013+
});
1014+
const after = await json(await call(null, 'GET', `/markets/${m.id}`), 200);
1015+
assert.strictEqual(after.resolvedOutcome, 0,
1016+
'replay must reproduce the ADJUDICATED outcome, not the oracle’s original');
1017+
await assertConserved('after replaying a re-resolution');
1018+
});
1019+
1020+
it('settled positions stop showing as live value', async () => {
1021+
const positions = (await me('bob')).positions;
1022+
assert.ok(positions.every((x) => x.status !== 'resolved' && x.status !== 'void'),
1023+
'a paid-out market must not also appear as an open position');
1024+
});
1025+
9741026
it('a void never pays a holder more than they paid (kills the sustained pump)', async () => {
9751027
const m = await json(await call('alice', 'POST', '/markets', {
9761028
title: 'Sustained pump attempt',
@@ -984,7 +1036,10 @@ describe('markets plugin', () => {
9841036
await json(await call('bob', 'POST', `/markets/${m.id}/trade`,
9851037
{ side: 'buy', outcome: 0, spend: 150 }), 200);
9861038
await sleep(600);
1039+
await json(await call('alice', 'POST', `/markets/${m.id}/close`), 200);
9871040
await json(await call('alice', 'POST', `/markets/${m.id}/void`), 200);
1041+
await sleep(400);
1042+
await json(await call(null, 'POST', `/markets/${m.id}/settle`), 200);
9881043
const net = await balance('bob') - before;
9891044
assert.ok(net <= 1e-6, `pump-and-hold-then-void must not profit (net ${net})`);
9901045
await assertConserved('after a sustained-pump void');

0 commit comments

Comments
 (0)