Skip to content

Commit 92f7d27

Browse files
markets/: the creator's escrow is a bond again, and voids cap on net cash in
Fifth-round economics review, with measured numbers. Three real incentive bugs, all in settlement. Refusing to resolve had become the creator's dominant strategy. The void payout cap turns unpaid trader value into residual, the creator could claim that residual up to their escrow, and the abandoned-market backstop voids by itself after a week -- so going silent returned 68.5 of 69.3 escrowed where resolving honestly returned 11.3. That is a 57-credit reward for doing nothing, taken from the trader who was right, and it makes the LMSR subsidy a refundable deposit rather than a maker bond. Escrow now returns on a resolution, or on a void an operator decided, and never on one the creator's own inaction produced. The per-outcome cost-basis cap did not actually kill the sustained-pump grief: releasing basis proportionally on a sell meant a trader could pump, sell 51% to bank the gain, and still redeem the rest at full remaining basis for a measured 5.8% risk-free. The same rule taxed a market-neutral position about 12%, because a leg that gained could not offset a leg that lost. Both go away by capping on net cash in over the whole position. A void proposal kept any prior resolvedOutcome, so an oracle could resolve, publicly offer a void ("nobody wins, you get back at most what you paid"), and have the market settle as the original resolution -- taking 100 credits off a holder who had been told they would be refunded. The proposal kind is now an explicit journalled field rather than something inferred from whether an outcome happens to be set, a pending resolution cannot be overwritten by a void proposal, and markets already in the voiding state from an older snapshot fall back to their status rather than settling as a resolution and wedging. Also: the void redemption anchors to when trading stopped rather than when someone clicked -- stamping it at propose time let a late oracle redeem at spot, 40 points from the backstop price on the same market, chosen by waiting; chained disputes can no longer postpone settlement indefinitely (each new disputer reset a 7-day clock, so N free accounts bought N weeks of frozen payouts); voiding markets appear in the closed filter, which is the window holders must object in; a voided market no longer reports a winning outcome; twapWindowMs has a real floor, since 1ms is spot pricing in disguise; and a settlement that throws returns 503 with the reason rather than an unhandled 500. 73 plugin tests, 39 compose tests.
1 parent 5029348 commit 92f7d27

6 files changed

Lines changed: 239 additions & 25 deletions

File tree

markets/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,22 @@ 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+
- **An escrow that returns on every path is a deposit, not a bond.** The
175+
creator escrows `b·ln n` as the LMSR maker loss, and a void redeeming
176+
at `min(value, what you paid)` turns unpaid trader value into residual
177+
the creator could claim — while the abandoned-market backstop voids by
178+
itself after a week. So *refusing to resolve* returned nearly the whole
179+
escrow (68.5 of 69.3 measured) where resolving honestly returned a
180+
fraction (11.3), a 57-credit reward for silence taken from the trader
181+
who was right. Going silent must never beat settling: escrow now comes
182+
back on a resolution, or on a void an operator decided, and never on
183+
one the creator's own inaction produced.
184+
- **Cap a redemption on NET CASH IN, not on cost basis.** A per-outcome
185+
cost cap let a trader pump, sell part of the position to bank the gain,
186+
and still redeem the remainder at its full remaining basis — a measured
187+
5.8% risk-free — and it taxed a perfectly hedged position ~12%, because
188+
a leg that gained could not offset a leg that lost. One number per
189+
position, `Σ in − Σ out`, fixes both and is equally conserving.
174190
- **A fix is a new attack surface: the state you add needs every branch
175191
that reads the old state re-checked.** Making an oracle-initiated void a
176192
*proposal* (so holders can object to a cancellation they can only lose
@@ -232,7 +248,7 @@ the walls point at.
232248

233249
## Tests
234250

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

markets/lifecycle.js

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export function createLifecycle({ state, commit, broadcast, log, cfg }) {
4040
/** Prices to redeem a voided market at: the TWAP over the window ending
4141
* at close (see the header — this is what kills the void front-run). */
4242
function voidPrices(m) {
43-
const endT = m.closedAt || Math.min(m.closesAt, Date.now());
43+
// Anchor to when TRADING stopped, not to when somebody clicked.
44+
// Stamping the anchor at propose time let a late oracle void redeem at
45+
// spot — 40 points from the backstop's price on the same market,
46+
// chosen simply by waiting.
47+
const endT = Math.min(m.closesAt, m.closedAt ?? Infinity, Date.now());
4448
return twapPrices(m.history, endT - twapWindowMs, endT, m.outcomes.length);
4549
}
4650

@@ -50,6 +54,9 @@ function voidPrices(m) {
5054
* float arithmetic.
5155
*/
5256
function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false, outcome = null } = {}) {
57+
// Escrow returns on a resolution, or on a void an operator decided.
58+
// Never on a void produced by the creator's own inaction.
59+
const creatorMayRecover = status === 'resolved' || !!adjudicatedBy;
5360
const pool = m.subsidyMicro + m.collectedMicro;
5461
const raw = [];
5562
let sum = 0;
@@ -79,11 +86,16 @@ function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustai
7986
if (p > 0) { payouts[agent] = p; paid += p; }
8087
}
8188

82-
// The creator may recover AT MOST what they escrowed. Anything left
83-
// beyond that is other people's money and goes to the house — which
84-
// is what makes "resolve to an outcome nobody holds" unprofitable.
89+
// The creator may recover AT MOST what they escrowed, and ONLY when
90+
// the market settled on an answer. Letting them recover it from a void
91+
// made "never resolve" the dominant strategy: the payout cap turns
92+
// unpaid trader value into residual, and the abandoned-market backstop
93+
// voids by itself after a week — so going silent returned nearly the
94+
// whole escrow while resolving honestly returned a fraction of it. The
95+
// escrow is the LMSR maker bond, and a void is exactly the case where
96+
// a bond must be at risk.
8597
const residual = pool - paid;
86-
const creatorFromPool = Math.max(0, Math.min(residual, m.subsidyMicro));
98+
const creatorFromPool = creatorMayRecover ? Math.max(0, Math.min(residual, m.subsidyMicro)) : 0;
8799
const houseFromPool = residual - creatorFromPool;
88100
const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000);
89101
const creatorFee = m.feesMicro - houseFee;
@@ -140,10 +152,18 @@ const settleResolved = (m, opts = {}) => {
140152
// holder can ever exit a void for more than they put in, so pumping to
141153
// be voided is never profitable at any hold duration. It only ever
142154
// pays LESS than the TWAP, so conservation is strictly preserved.
143-
const settleVoid = (m, opts) => {
155+
const settleVoid = (m, opts = {}) => {
144156
const p = voidPrices(m);
145157
settle(m, 'void',
146-
(pos) => pos.shares.reduce((a, s, i) => a + Math.min(s * p[i], pos.costMicro[i]), 0),
158+
// The lesser of market value and what you actually put in, taken
159+
// over the WHOLE position. Capping per outcome let a partial sell
160+
// bank a gain and still redeem the remainder at full basis (a
161+
// measured 5.8% risk-free), and it taxed a market-neutral position
162+
// ~12% for holding two legs with no outcome risk at all.
163+
(pos) => Math.min(
164+
pos.shares.reduce((a, s, i) => a + s * p[i], 0),
165+
Math.max(0, pos.netInMicro || 0),
166+
),
147167
p, opts);
148168
};
149169

@@ -166,24 +186,45 @@ function maybeTick() {
166186
tick();
167187
}
168188

189+
/**
190+
* When an unadjudicated dispute stops holding up settlement.
191+
*
192+
* Anchored to the LATEST dispute so a late disputer gets a real window
193+
* rather than a truncated one — but HARD-CAPPED from the first, because
194+
* each new disputer otherwise pushes the deadline out again: ten dust
195+
* accounts posting the 25-credit floor out of their own free signup
196+
* grants could freeze a settlement for seventy days at no real cost.
197+
*/
198+
function isVoidProposal(m) {
199+
if (m.proposal) return m.proposal === 'void';
200+
// Pre-`proposal` state: fall back to the status, which is the only
201+
// other record of what was proposed.
202+
return m.status === 'voiding';
203+
}
204+
205+
/** When an unadjudicated dispute stops holding up settlement. Exported
206+
* so the operator queue advertises the deadline the machine uses — the
207+
* two drifting apart made the queue wrong exactly when it mattered. */
208+
function disputeDeadline(m) {
209+
const first = m.disputes[0].at;
210+
const last = m.disputes[m.disputes.length - 1].at;
211+
return Math.min(last + disputeGraceMs, first + disputeGraceMs * 3);
212+
}
213+
169214
function tickOne(m, now = Date.now()) {
170215
if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m);
171216
else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m);
172217
else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) {
173218
log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`);
174219
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)) {
220+
} else if (m.status === 'disputed' && now >= disputeDeadline(m)) {
180221
// Fall through to WHAT THE ORACLE PROPOSED — a resolution if
181222
// there was one, otherwise the void it proposed. An unadjudicated
182223
// dispute must not cancel a bet you lost, and must not invent a
183224
// resolution that never existed.
184225
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);
226+
if (isVoidProposal(m)) settleVoid(m);
227+
else settleResolved(m);
187228
}
188229
}
189230

@@ -201,5 +242,5 @@ function tick() {
201242
}
202243
}
203244

204-
return { voidPrices, settle, settleResolved, settleVoid, tick, tickOne, maybeTick };
245+
return { voidPrices, settle, settleResolved, settleVoid, tick, tickOne, maybeTick, disputeDeadline };
205246
}

markets/plugin.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,18 @@ export async function activate(api) {
170170
// a value that would silently disable a defence.
171171
for (const [name, v] of Object.entries({
172172
disputeWindowMs, disputeGraceMs, settlementWindowMs, twapWindowMs, sessionTtlMs,
173+
snapshotIntervalMs: num(cfg.snapshotIntervalMs, 30_000),
173174
})) {
174175
if (!Number.isFinite(v) || v <= 0) {
175176
throw new Error(`markets: config.${name} must be a positive number of milliseconds (got ${v})`);
176177
}
177178
}
179+
// "> 0" is not the property that matters: a 1ms window gives the last
180+
// price full weight, i.e. the TWAP IS spot — the very arbitrage the
181+
// window exists to prevent.
182+
if (twapWindowMs < 60_000) {
183+
throw new Error('markets: config.twapWindowMs must be at least 60000ms — a shorter window is spot pricing in disguise');
184+
}
178185
// Get these two the wrong way round and a resolution is still inside
179186
// its dispute window when the abandoned-market backstop opens — which
180187
// made every resolution voidable by every loser.
@@ -432,7 +439,7 @@ export async function activate(api) {
432439
rawStatus: m.status,
433440
tradable: tradable(m),
434441
canResolve: m.status === 'open',
435-
canVoid: m.status !== 'resolved' && m.status !== 'void',
442+
canVoid: m.status === 'open',
436443
closesAt: new Date(m.closesAt).toISOString(),
437444
createdAt: m.createdAt,
438445
creator: m.creator,
@@ -528,7 +535,9 @@ export async function activate(api) {
528535
// The settlement state machine lives in lifecycle.js — see that file
529536
// for why it is not inline here (a state change that skipped the
530537
// reducer made the audit trail contradict the money).
531-
const { voidPrices, settleResolved, settleVoid, tick, tickOne, maybeTick } = createLifecycle({
538+
const {
539+
voidPrices, settleResolved, settleVoid, tick, tickOne, maybeTick, disputeDeadline,
540+
} = createLifecycle({
532541
state,
533542
commit: store.commit,
534543
broadcast: (type, m) => broadcast(type, m),
@@ -810,7 +819,10 @@ export async function activate(api) {
810819
if (search) all = all.filter((m) => m.title.toLowerCase().includes(search)
811820
|| (m.description || '').toLowerCase().includes(search));
812821
if (wanted === 'open') all = all.filter((m) => tradable(m));
813-
else if (wanted === 'closed') all = all.filter((m) => !tradable(m) && (m.status === 'open' || m.status === 'resolving' || m.status === 'disputed'));
822+
else if (wanted === 'closed') {
823+
all = all.filter((m) => !tradable(m)
824+
&& ['open', 'resolving', 'voiding', 'disputed'].includes(m.status));
825+
}
814826
else if (wanted === 'settled') all = all.filter((m) => m.status === 'resolved' || m.status === 'void');
815827

816828
// Deterministic total order (createdAt desc, id desc) so the cursor
@@ -1000,7 +1012,12 @@ export async function activate(api) {
10001012
// Advance THIS market only: a full scan here was a free O(all
10011013
// markets) job for any anonymous caller, and throttling it instead
10021014
// turned an explicit "settle now" into a silent no-op.
1003-
tickOne(m);
1015+
try {
1016+
tickOne(m);
1017+
} catch (e) {
1018+
api.log.error(`markets: ${m.id} cannot settle: ${e.message}`);
1019+
return err(reply, 503, `this market cannot be settled automatically and needs an operator: ${e.message}`);
1020+
}
10041021
if (m.status === 'resolved' || m.status === 'void') return reply.send(marketOut(m));
10051022
if (m.status === 'resolving' || m.status === 'voiding') {
10061023
return err(reply, 409, `settles at ${new Date(m.settleAt).toISOString()} (dispute window open)`);
@@ -1020,7 +1037,11 @@ export async function activate(api) {
10201037
// 'disputed' is disputable too: latching on the FIRST disputer meant
10211038
// one person paid the bond and every other loser free-rode on the
10221039
// resulting void. Each disputer posts their own.
1023-
tickOne(m); // otherwise a market past settleAt is still 'resolving' here
1040+
try {
1041+
tickOne(m); // otherwise a market past settleAt is still 'resolving' here
1042+
} catch (e) {
1043+
api.log.error(`markets: ${m.id} cannot settle: ${e.message}`);
1044+
}
10241045
if (m.status !== 'resolving' && m.status !== 'disputed' && m.status !== 'voiding') {
10251046
return err(reply, 409, 'only a resolving market can be disputed');
10261047
}
@@ -1062,6 +1083,9 @@ export async function activate(api) {
10621083
// a settlement is already pending.
10631084
const stale = m.status === 'open' && Date.now() >= m.closesAt + settlementWindowMs;
10641085
if (m.status === 'resolved' || m.status === 'void') return err(reply, 409, `market is ${m.status}`);
1086+
if (m.status === 'resolving' && !admins.has(agent)) {
1087+
return err(reply, 409, 'this market has a resolution pending — only the operator can turn that into a void');
1088+
}
10651089
if (m.status === 'voiding' && !admins.has(agent) && !stale) {
10661090
return err(reply, 409, `a void is already proposed; it settles at ${new Date(m.settleAt).toISOString()}`);
10671091
}
@@ -1197,7 +1221,7 @@ export async function activate(api) {
11971221
const m = state.markets[market];
11981222
if (!m) return err(reply, 404, 'no such market');
11991223
if (m.status !== 'disputed') return err(reply, 409, `market is ${displayStatus(m)}, not disputed`);
1200-
const proposedVoid = !Number.isInteger(m.resolvedOutcome);
1224+
const proposedVoid = m.proposal === 'void';
12011225
if (uphold === true) {
12021226
// Uphold whatever the oracle actually proposed — a void proposal
12031227
// has no resolution to uphold.
@@ -1234,7 +1258,7 @@ export async function activate(api) {
12341258
disputeDetail: (m.disputes || []).map((d) => ({
12351259
agent: d.agent, reason: d.reason, at: new Date(d.at).toISOString(), bond: (d.bondMicro || 0) / MICRO,
12361260
})),
1237-
autoVoidsAt: new Date((m.disputes[0]?.at || Date.now()) + disputeGraceMs).toISOString(),
1261+
autoSettlesAt: new Date(disputeDeadline(m)).toISOString(),
12381262
}));
12391263
return reply.send({ disputes: queue });
12401264
});

markets/store.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,13 @@ export function applyEvent(state, ev, prices) {
155155
const m = state.markets[ev.marketId];
156156
const r = row(state, ev.agent);
157157
const pos = m.positions[ev.agent]
158-
|| (m.positions[ev.agent] = { shares: m.outcomes.map(() => 0), costMicro: m.outcomes.map(() => 0) });
158+
|| (m.positions[ev.agent] = {
159+
shares: m.outcomes.map(() => 0), costMicro: m.outcomes.map(() => 0), netInMicro: 0,
160+
});
161+
// Net cash in: what this agent has actually put into this market,
162+
// net of everything taken back out. It is the cap on a void
163+
// redemption — see lifecycle.js.
164+
pos.netInMicro = (pos.netInMicro || 0) + (ev.side === 'buy' ? ev.totalMicro : -ev.totalMicro);
159165
if (ev.side === 'buy') {
160166
r.balanceMicro -= ev.totalMicro;
161167
m.collectedMicro += ev.costMicro;
@@ -195,6 +201,8 @@ export function applyEvent(state, ev, prices) {
195201
case 'market.propose-void': {
196202
const m = state.markets[ev.marketId];
197203
m.status = 'voiding';
204+
m.proposal = 'void';
205+
m.resolvedOutcome = null; // a void proposal abandons any prior call
198206
m.settleAt = ev.settleAt;
199207
m.closesAt = Math.min(m.closesAt, ev.t);
200208
m.closedAt = m.closedAt || ev.t;
@@ -204,6 +212,7 @@ export function applyEvent(state, ev, prices) {
204212
case 'market.resolve': {
205213
const m = state.markets[ev.marketId];
206214
m.status = 'resolving';
215+
m.proposal = 'resolve';
207216
m.resolvedOutcome = ev.outcome;
208217
m.settleAt = ev.settleAt;
209218
m.closesAt = Math.min(m.closesAt, ev.t);
@@ -254,6 +263,9 @@ export function applyEvent(state, ev, prices) {
254263
row(state, m.creator).balanceMicro += ev.creatorMicro;
255264
if (ev.houseMicro) row(state, ev.house).balanceMicro += ev.houseMicro;
256265
m.status = ev.status;
266+
// A void has no winner; leaving a stale outcome on it showed
267+
// integrators a winning outcome on a cancelled market.
268+
if (ev.status === 'void') m.resolvedOutcome = null;
257269
m.resolvedAt = new Date(ev.t).toISOString();
258270
m.settledPrices = ev.prices || null;
259271
break;

0 commit comments

Comments
 (0)