Skip to content

Commit 76af048

Browse files
markets/: round three — dispute economics, session revocation, real takedown
Five adversarial critics, third pass. The findings that mattered: Disputing was still a free refund option on any lost bet, and the winner paid for it (measured by the security reviewer on a live server: loser 0.00, winner -67.33). Three things were wrong at once -- the bond came back on any void, only the FIRST disputer paid one because the status latched, and an unadjudicated dispute defaulted to void. Now every holder posts their own bond, it scales with the position, it returns only when an operator sustains the dispute, and silence falls through to the oracle's resolution instead of cancelling the market. Added a re-resolve verb too: voiding an incorrect resolution refunds the loser and wipes out whoever was actually right. Journal rotation plus the store's own printed recovery advice was a silent total ledger wipe -- rotation retires the live journal, so "delete the snapshot to rebuild from journal.jsonl" rebuilt from an empty file into a brand-new zero-balance ledger. Replay now spans every segment, and so does the operator's agent-history query, which was returning a confidently empty answer to "what happened to my account". Sessions could not be revoked: sign-out cleared the cookie but the token is self-verifying, so a captured copy worked for the full 12 hours. Tokens now carry an epoch that sign-out, freeze and revoke bump. Also: the oracle could void its way out of a dispute it was losing, and could cancel a live market's open bets; admin.adjust accepted 1e303 and turned a balance into Infinity that the journal replayed as null forever; idempotency keys had no request fingerprint, so a reused key swallowed a different trade and reported success; the WebSocket origin check was claimed in round one and never actually existed; boot replay was quadratic; hide left the withdrawn market's text served at its URL. UI: the wrong-outcome cash-out bug survived on the detail page (the handler ignored the index it rendered); the pick leaked across markets so a 12-outcome selection could render a ticket on "undefined"; a transient 429 ejected the user mid-bet-slip and looked like a logout; the WS pagination guard was inverted. Plus the disclosures that were simply absent -- the dispute bond is now stated before it is taken, the credits are labelled play money, markets carry rules and a visible oracle, and Void takes a typed confirmation like Resolve. 62 plugin tests, 39 compose tests.
1 parent 1c0c756 commit 76af048

6 files changed

Lines changed: 396 additions & 120 deletions

File tree

markets/README.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,22 @@ 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`.
55+
Disputes cost a **bond**`max(disputeBondCredits, disputeBondBps` of the
56+
disputed position`)`, default 25 credits or 20% — returned only if an
57+
operator **sustains** the dispute, forfeited otherwise, including when
58+
nobody adjudicates in time. Every holder posts their own bond, and an
59+
unadjudicated dispute falls through to the **oracle's resolution, not a
60+
void**. Each of those is load-bearing: refunding on any void, latching on
61+
the first disputer, or defaulting to void made disputing a free refund
62+
option on any lost bet — paid for out of the winner's payout — so every
63+
rational loser disputes and correct resolutions never stand.
64+
65+
An operator works the queue at `GET /api/admin/disputes`
66+
`POST /api/admin/adjudicate`, which has three verbs: **uphold** (the
67+
resolution stands), **re-resolve** (`uphold:false` with an `outcome`
68+
the oracle was wrong and we know the right answer), and **void**.
69+
Re-resolution matters because voiding an incorrect resolution refunds the
70+
loser and wipes out whoever actually backed the correct outcome.
6071

6172
**A void never pays a holder more than they paid.** Redemption is
6273
`min(TWAP value, cost basis)` per outcome. The TWAP alone defeats a
@@ -160,7 +171,7 @@ the walls point at.
160171

161172
## Tests
162173

163-
`node --test --test-concurrency=1 markets/test.js`60 tests: LMSR and
174+
`node --test --test-concurrency=1 markets/test.js`62 tests: LMSR and
164175
TWAP math, session/CSRF/rate-limit units, hardened headers, cookie
165176
scoping, prototype-key ids, grants, escrow, stake-first quotes,
166177
quote↔trade parity, slippage guards (including the NaN-fails-closed case),

markets/guard.js

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,19 @@ export function createSessions({ dir, ttlMs }) {
5252

5353
const sign = (payload) => crypto.createHmac('sha256', secret).update(payload).digest();
5454

55-
function mint(agent) {
55+
function mint(agent, epoch = 0) {
5656
const exp = Date.now() + ttlMs;
57-
const payload = b64url(JSON.stringify({ agent, exp }));
57+
const payload = b64url(JSON.stringify({ agent, exp, epoch }));
5858
return `v1.${payload}.${b64url(sign(payload))}`;
5959
}
6060

61-
/** @returns {string|null} the agent id, or null if absent/forged/expired */
61+
/**
62+
* @returns {{agent:string, exp:number, epoch:number}|null} the claims,
63+
* or null if absent/forged/expired. The caller must still compare
64+
* `epoch` against the agent's current epoch — that comparison is what
65+
* makes sign-out, freeze and revoke actually terminate a session,
66+
* since a self-verifying token is otherwise valid for its whole TTL.
67+
*/
6268
function verify(token) {
6369
if (typeof token !== 'string' || !token.startsWith('v1.')) return null;
6470
const [, payload, mac] = token.split('.');
@@ -72,7 +78,7 @@ export function createSessions({ dir, ttlMs }) {
7278
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
7379
} catch { return null; }
7480
if (!claims.agent || typeof claims.exp !== 'number' || Date.now() > claims.exp) return null;
75-
return claims.agent;
81+
return { agent: claims.agent, exp: claims.exp, epoch: claims.epoch || 0 };
7682
}
7783

7884
return { mint, verify, ttlMs };
@@ -91,13 +97,23 @@ export function createSessions({ dir, ttlMs }) {
9197
*/
9298
export function isSameOrigin(request, ownOrigin) {
9399
const site = request.headers['sec-fetch-site'];
94-
if (site) return site === 'same-origin' || site === 'none';
95100
const origin = request.headers.origin;
96-
if (!origin) return true;
97-
if (!ownOrigin) return false; // origin claimed but we can't verify it → refuse
98-
try {
99-
return new URL(origin).origin === new URL(ownOrigin).origin;
100-
} catch { return false; }
101+
// When both are present they must AGREE. Letting Sec-Fetch-Site alone
102+
// decide makes the Origin check dead code and trusts any intermediary
103+
// that rewrites headers.
104+
if (site && !(site === 'same-origin' || site === 'none')) return false;
105+
if (origin) {
106+
if (!ownOrigin) return false; // an origin is claimed and we can't verify it
107+
try {
108+
if (new URL(origin).origin !== new URL(ownOrigin).origin) return false;
109+
} catch { return false; }
110+
return true;
111+
}
112+
// No Origin header: trust only an explicit same-origin/none fetch
113+
// signal. A request with neither header is not a browser request, and
114+
// the caller only reaches here when the credential is ambient — so
115+
// refusing costs nothing and closes the header-stripping case.
116+
return site === 'same-origin' || site === 'none';
101117
}
102118

103119
/** True when the credential is ambient (cookie / TLS cert), i.e. a browser

0 commit comments

Comments
 (0)