| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 3e07250 commit 4a56c21
7 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,3 @@ | |||
| 1 | + session.secret | ||
| 2 | + pseudonym.salt | ||
| 3 | + *.jsonl | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -52,6 +52,18 @@ payout is **capped at their escrow**; and a resolution sits in a | |||
| 52 | 52 | A market whose oracle never acts is **auto-voided at TWAP** after the | |
| 53 | 53 | settlement window — anyone can trigger it, so funds are never stuck. | |
| 54 | 54 | ||
| 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 | + | ||
| 55 | 67 | ## What this is not (deliberately) | |
| 56 | 68 | ||
| 57 | 69 | **Not real money.** A real-money book is a gambling licence, KYC/AML, | |
@@ -127,6 +139,14 @@ the walls point at. | |||
| 127 | 139 | regression test for exactly this attack, and it caught a real bug: the | |
| 128 | 140 | price path was seeded with `m.history || [seed]`, and an empty array is | |
| 129 | 141 | 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. | ||
| 130 | 150 | - **Atomicity by construction is fragile and undocumented.** Every | |
| 131 | 151 | mutating handler awaits auth first, then validates and commits with no | |
| 132 | 152 | `await` in between, so the event loop makes each trade a transaction. | |
@@ -140,14 +160,16 @@ the walls point at. | |||
| 140 | 160 | ||
| 141 | 161 | ## Tests | |
| 142 | 162 | ||
| 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 | ||
| 144 | 164 | TWAP math, session/CSRF/rate-limit units, hardened headers, cookie | |
| 145 | 165 | scoping, prototype-key ids, grants, escrow, stake-first quotes, | |
| 146 | 166 | quote↔trade parity, slippage guards (including the NaN-fails-closed case), | |
| 147 | 167 | no-shorting, idempotent retries, 12 concurrent trades, ws privacy, | |
| 148 | 168 | pagination and search, the full settlement state machine (resolve → | |
| 149 | 169 | dispute → settle, void, early close, dead-oracle rescue), the | |
| 150 | 170 | 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. | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -57,6 +57,8 @@ | |||
| 57 | 57 | // in-memory ledger ahead of the durable one. | |
| 58 | 58 | ||
| 59 | 59 | import crypto from 'node:crypto'; | |
| 60 | + import fs from 'node:fs'; | ||
| 61 | + import path from 'node:path'; | ||
| 60 | 62 | import { lmsrPrices, tradeCostRaw, sharesForBudget, twapPrices, uniformPrices } from './lmsr.js'; | |
| 61 | 63 | import { createStore, dict } from './store.js'; | |
| 62 | 64 | import { | |
@@ -104,6 +106,17 @@ export function randomId(len = 8) { | |||
| 104 | 106 | /** An agent id is a WebID (http/https URL) or a DID — the two shapes | |
| 105 | 107 | * getAgent can ever return. Rejecting anything else at creation stops a | |
| 106 | 108 | * 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 | + | ||
| 107 | 120 | export function isAgentId(s) { | |
| 108 | 121 | if (typeof s !== 'string' || !s || s.length > 512) return false; | |
| 109 | 122 | if (s.startsWith('did:')) return /^did:[a-z0-9]+:[\w.:%-]+$/i.test(s); | |
@@ -122,7 +135,8 @@ export async function activate(api) { | |||
| 122 | 135 | const grantMicro = Math.round(num(cfg.grantCredits, 1000) * MICRO); | |
| 123 | 136 | const feeBps = num(cfg.feeBps, 100); | |
| 124 | 137 | 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); | ||
| 126 | 140 | const disputeGraceMs = num(cfg.disputeGraceMs, 7 * 24 * 3600 * 1000); | |
| 127 | 141 | const settlementWindowMs = num(cfg.settlementWindowMs, 7 * 24 * 3600 * 1000); | |
| 128 | 142 | const twapWindowMs = num(cfg.twapWindowMs, 30 * 60 * 1000); | |
@@ -142,13 +156,28 @@ export async function activate(api) { | |||
| 142 | 156 | for (const a of admins) { | |
| 143 | 157 | if (!isAgentId(a)) throw new Error(`markets: config.admins contains a non-agent id: ${a}`); | |
| 144 | 158 | } | |
| 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 | + } | ||
| 145 | 173 | if (/["'<>]/.test(prefix)) throw new Error(`markets: refusing an unsafe prefix: ${prefix}`); | |
| 146 | 174 | ||
| 147 | 175 | // -------------------------------------------------- store & security | |
| 148 | 176 | const dir = api.storage.pluginDir(); | |
| 149 | 177 | const store = createStore({ dir, log: api.log, prices: lmsrPrices }); | |
| 150 | 178 | const { state } = store; | |
| 151 | 179 | const sessions = createSessions({ dir, ttlMs: sessionTtlMs }); | |
| 180 | + const pseudonymSalt = readOrCreateSecret(path.join(dir, 'pseudonym.salt')); | ||
| 152 | 181 | const limiter = createRateLimiter({ capacity: num(cfg.rateCapacity, 120), refillPerSec: num(cfg.rateRefillPerSec, 2) }); | |
| 153 | 182 | ||
| 154 | 183 | /** agent → Set(marketId) — so /api/me is O(your markets), not O(all). */ | |
@@ -211,7 +240,12 @@ export async function activate(api) { | |||
| 211 | 240 | * credential is ambient (cookie/TLS cert), because those are exactly | |
| 212 | 241 | * the credentials a cross-origin page can borrow. */ | |
| 213 | 242 | 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; | ||
| 215 | 249 | return isSameOrigin(request, ownOrigin()); | |
| 216 | 250 | } | |
| 217 | 251 | ||
@@ -316,7 +350,16 @@ export async function activate(api) { | |||
| 316 | 350 | outcomes: m.outcomes, | |
| 317 | 351 | prices: prices.map((p) => Number(p.toFixed(6))), | |
| 318 | 352 | 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, | ||
| 319 | 360 | tradable: tradable(m), | |
| 361 | + canResolve: m.status === 'open', | ||
| 362 | + canVoid: m.status !== 'resolved' && m.status !== 'void', | ||
| 320 | 363 | closesAt: new Date(m.closesAt).toISOString(), | |
| 321 | 364 | createdAt: m.createdAt, | |
| 322 | 365 | creator: m.creator, | |
@@ -421,7 +464,7 @@ export async function activate(api) { | |||
| 421 | 464 | * in the event, and applied by the reducer — so replay never recomputes | |
| 422 | 465 | * float arithmetic. | |
| 423 | 466 | */ | |
| 424 | - function settle(m, status, payoutMicroOf, prices) { | ||
| 467 | + function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null } = {}) { | ||
| 425 | 468 | const pool = m.subsidyMicro + m.collectedMicro; | |
| 426 | 469 | const raw = []; | |
| 427 | 470 | let sum = 0; | |
@@ -453,23 +496,46 @@ export async function activate(api) { | |||
| 453 | 496 | const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000); | |
| 454 | 497 | const creatorFee = m.feesMicro - houseFee; | |
| 455 | 498 | ||
| 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 | + | ||
| 456 | 509 | store.commit({ | |
| 457 | 510 | type: 'market.settle', | |
| 458 | 511 | marketId: m.id, | |
| 459 | 512 | status, | |
| 460 | 513 | payouts, | |
| 514 | + bondRefunds, | ||
| 461 | 515 | creatorMicro: creatorFromPool + creatorFee, | |
| 462 | - houseMicro: houseFromPool + houseFee, | ||
| 516 | + houseMicro: houseFromPool + houseFee + bondToHouse, | ||
| 463 | 517 | house: HOUSE, | |
| 518 | + adjudicatedBy, | ||
| 464 | 519 | prices: prices ? prices.map((p) => Number(p.toFixed(6))) : null, | |
| 465 | 520 | }); | |
| 466 | 521 | broadcast('settle', m); | |
| 467 | 522 | } | |
| 468 | 523 | ||
| 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) => { | ||
| 471 | 535 | 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); | ||
| 473 | 539 | }; | |
| 474 | 540 | ||
| 475 | 541 | /** | |
@@ -787,6 +853,9 @@ export async function activate(api) { | |||
| 787 | 853 | // contract (see header). Do not introduce one. | |
| 788 | 854 | const m = state.markets[request.params.id]; | |
| 789 | 855 | 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'); | ||
| 790 | 859 | if (!tradable(m)) return err(reply, 409, `market is ${displayStatus(m)} — trading has stopped`); | |
| 791 | 860 | if (!allowInsiderTrading && (agent === m.oracle || agent === m.creator)) { | |
| 792 | 861 | 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) { | |||
| 925 | 994 | if (!pos || pos.shares.every((s) => s === 0)) return err(reply, 403, 'only a holder may dispute'); | |
| 926 | 995 | const reason = typeof (request.body || {}).reason === 'string' | |
| 927 | 996 | ? 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 | + }); | ||
| 929 | 1005 | api.log.warn(`markets: ${m.id} disputed by ${agent}: ${reason}`); | |
| 930 | 1006 | broadcast('market', m); | |
| 931 | 1007 | return reply.send(marketOut(m)); | |
@@ -962,7 +1038,7 @@ export async function activate(api) { | |||
| 962 | 1038 | // market escrows subsidy + collected + fees, and all three are paid | |
| 963 | 1039 | // out at settlement. Omitting fees here would make the conservation | |
| 964 | 1040 | // 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); | ||
| 966 | 1042 | open++; | |
| 967 | 1043 | } | |
| 968 | 1044 | return reply.send({ | |
@@ -992,9 +1068,12 @@ export async function activate(api) { | |||
| 992 | 1068 | return reply.send({ leaderboard: top }); | |
| 993 | 1069 | }); | |
| 994 | 1070 | ||
| 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. */ | ||
| 996 | 1075 | 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)}`; | ||
| 998 | 1077 | } | |
| 999 | 1078 | ||
| 1000 | 1079 | // ---- admin ---------------------------------------------------------- | |
@@ -1029,6 +1108,56 @@ export async function activate(api) { | |||
| 1029 | 1108 | return reply.send({ ok: true, agent, balance: balanceOf(agent) / MICRO }); | |
| 1030 | 1109 | }); | |
| 1031 | 1110 | ||
| 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 | + | ||
| 1032 | 1161 | api.fastify.post(`${prefix}/api/admin/hide`, jsonOpts(1024), async (request, reply) => { | |
| 1033 | 1162 | const by = await adminOnly(request, reply); | |
| 1034 | 1163 | if (!by) return reply; | |
| Back | FazBrowse Home | New Git URL |
0 commit comments