|
7 | 7 | // admins: ['https://alice.example/profile/card#me'] } }] |
8 | 8 | // |
9 | 9 | // Layout: lmsr.js (the AMM math + TWAP), store.js (journal + snapshot + |
10 | | -// reducer), guard.js (sessions, CSRF, rate limiting, headers), ui.js (the |
11 | | -// trading UI), this file (policy + routes). |
| 10 | +// reducer), lifecycle.js (the settlement state machine), guard.js |
| 11 | +// (sessions, CSRF, rate limiting, headers), ui.js (the trading UI), this |
| 12 | +// file (policy + routes). |
12 | 13 | // |
13 | 14 | // MONEY. Integer micro-credits (1 credit = 1e6 micro). Costs round up, |
14 | 15 | // proceeds and payouts round down, fees round up — every rounding |
@@ -63,6 +64,7 @@ import fs from 'node:fs'; |
63 | 64 | import path from 'node:path'; |
64 | 65 | import { lmsrPrices, tradeCostRaw, sharesForBudget, twapPrices, uniformPrices } from './lmsr.js'; |
65 | 66 | import { createStore, dict, TWAP_PROTECT_MS } from './store.js'; |
| 67 | +import { createLifecycle } from './lifecycle.js'; |
66 | 68 | import { |
67 | 69 | createSessions, createRateLimiter, isSameOrigin, isAmbientCredential, UI_HEADERS, API_HEADERS, |
68 | 70 | } from './guard.js'; |
@@ -491,145 +493,16 @@ export async function activate(api) { |
491 | 493 | } |
492 | 494 |
|
493 | 495 | // ----------------------------------------------------------- lifecycle |
494 | | - /** Prices to redeem a voided market at: the TWAP over the window ending |
495 | | - * at close (see the header — this is what kills the void front-run). */ |
496 | | - function voidPrices(m) { |
497 | | - const endT = m.closedAt || Math.min(m.closesAt, Date.now()); |
498 | | - return twapPrices(m.history, endT - twapWindowMs, endT, m.outcomes.length); |
499 | | - } |
500 | | - |
501 | | - /** |
502 | | - * Compute and journal a settlement. Payouts are derived here, RECORDED |
503 | | - * in the event, and applied by the reducer — so replay never recomputes |
504 | | - * float arithmetic. |
505 | | - */ |
506 | | - function settle(m, status, payoutMicroOf, prices, { adjudicatedBy = null, sustained = false, outcome = null } = {}) { |
507 | | - const pool = m.subsidyMicro + m.collectedMicro; |
508 | | - const raw = []; |
509 | | - let sum = 0; |
510 | | - for (const [agent, pos] of Object.entries(m.positions)) { |
511 | | - const v = Math.max(0, Math.floor(payoutMicroOf(pos))); |
512 | | - if (v > 0) { raw.push([agent, v]); sum += v; } |
513 | | - } |
514 | | - // Belt and braces: solvency is proved (lmsr.js), but if float drift |
515 | | - // ever put us over the pool, everyone takes the same haircut rather |
516 | | - // than the last claimant absorbing all of it. |
517 | | - let scale = 1; |
518 | | - if (sum > pool) { |
519 | | - scale = pool / sum; |
520 | | - api.log.error(`markets: ${m.id} conservation clamp — payouts ${sum} > pool ${pool}; pro-rata ${scale}`); |
521 | | - } |
522 | | - const payouts = {}; |
523 | | - let paid = 0; |
524 | | - for (const [agent, v] of raw) { |
525 | | - const p = Math.floor(v * scale); |
526 | | - if (p > 0) { payouts[agent] = p; paid += p; } |
527 | | - } |
528 | | - |
529 | | - // The creator may recover AT MOST what they escrowed. Anything left |
530 | | - // beyond that is other people's money and goes to the house — which |
531 | | - // is what makes "resolve to an outcome nobody holds" unprofitable. |
532 | | - const residual = pool - paid; |
533 | | - const creatorFromPool = Math.max(0, Math.min(residual, m.subsidyMicro)); |
534 | | - const houseFromPool = residual - creatorFromPool; |
535 | | - const houseFee = Math.floor((m.feesMicro * houseFeeShareBps) / 10_000); |
536 | | - const creatorFee = m.feesMicro - houseFee; |
537 | | - |
538 | | - // Dispute bonds return ONLY when an operator SUSTAINED the dispute — |
539 | | - // whether that meant voiding or re-resolving. Inferring it from a |
540 | | - // void status was wrong twice over: a re-resolution vindicates the |
541 | | - // disputer but isn't a void, and an unadjudicated grace-expiry void |
542 | | - // would hand the bond back for free. |
543 | | - const bondRefunds = {}; |
544 | | - let bondToHouse = 0; |
545 | | - for (const d of m.disputes || []) { |
546 | | - if (!d.bondMicro) continue; |
547 | | - if (sustained) bondRefunds[d.agent] = (bondRefunds[d.agent] || 0) + d.bondMicro; |
548 | | - else bondToHouse += d.bondMicro; |
549 | | - } |
550 | | - |
551 | | - store.commit({ |
552 | | - type: 'market.settle', |
553 | | - marketId: m.id, |
554 | | - 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, |
559 | | - payouts, |
560 | | - bondRefunds, |
561 | | - creatorMicro: creatorFromPool + creatorFee, |
562 | | - houseMicro: houseFromPool + houseFee + bondToHouse, |
563 | | - house: HOUSE, |
564 | | - adjudicatedBy, |
565 | | - prices: prices ? prices.map((p) => Number(p.toFixed(6))) : null, |
566 | | - }); |
567 | | - broadcast('settle', m); |
568 | | - } |
569 | | - |
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 | | - }; |
576 | | - // On a void you receive the LESSER of market value (at the TWAP) and |
577 | | - // what you actually paid. The cap is what finally kills the void |
578 | | - // arbitrage: the TWAP already defeats a last-second pump, but a |
579 | | - // *sustained* pump held across the whole window makes the TWAP equal |
580 | | - // the pumped price, and against a dead oracle that is a profitable |
581 | | - // grief funded by the creator's escrow. Capping at cost basis means no |
582 | | - // holder can ever exit a void for more than they put in, so pumping to |
583 | | - // be voided is never profitable at any hold duration. It only ever |
584 | | - // pays LESS than the TWAP, so conservation is strictly preserved. |
585 | | - const settleVoid = (m, opts) => { |
586 | | - const p = voidPrices(m); |
587 | | - settle(m, 'void', |
588 | | - (pos) => pos.shares.reduce((a, s, i) => a + Math.min(s * p[i], pos.costMicro[i]), 0), |
589 | | - p, opts); |
590 | | - }; |
591 | | - |
592 | | - /** |
593 | | - * Advance every market whose deadline has passed. Runs on a timer AND |
594 | | - * lazily before reads, so a settlement is never waiting on a tick. |
595 | | - * |
596 | | - * The auto-void arm is the DEAD-ORACLE BACKSTOP: a market whose oracle |
597 | | - * never acts (typo, abandoned, malicious) would otherwise lock every |
598 | | - * trader's credits forever, since trading also stops at close. After |
599 | | - * settlementWindow anyone's request advances it to a TWAP void. |
600 | | - */ |
601 | | - // Settlement on the request path is bounded to once a second: a mass |
602 | | - // expiry otherwise turns an anonymous GET into a multi-second stall |
603 | | - // (one fsync per newly-due market). |
604 | | - let lastTick = 0; |
605 | | - function maybeTick() { |
606 | | - if (Date.now() - lastTick < 1000) return; |
607 | | - lastTick = Date.now(); |
608 | | - tick(); |
609 | | - } |
610 | | - |
611 | | - function tick() { |
612 | | - const now = Date.now(); |
613 | | - for (const m of Object.values(state.markets)) { |
614 | | - try { |
615 | | - if (m.status === 'resolving' && now >= m.settleAt) settleResolved(m); |
616 | | - else if (m.status === 'voiding' && now >= m.settleAt) settleVoid(m); |
617 | | - else if (m.status === 'open' && now >= m.closesAt + settlementWindowMs) { |
618 | | - api.log.warn(`markets: ${m.id} auto-voiding — no resolution within the settlement window`); |
619 | | - settleVoid(m); |
620 | | - } else if (m.status === 'disputed' && now >= (m.disputes[0].at + disputeGraceMs)) { |
621 | | - // Fall through to the ORACLE'S RESOLUTION, not to a void: an |
622 | | - // unadjudicated dispute must not be a way to cancel a bet you |
623 | | - // lost. The disputer forfeits their bond; a genuinely wrong |
624 | | - // resolution needs an admin to say so before the grace expires. |
625 | | - api.log.warn(`markets: ${m.id} dispute expired unadjudicated — the resolution stands`); |
626 | | - settleResolved(m); |
627 | | - } |
628 | | - } catch (e) { |
629 | | - api.log.error(`markets: tick failed for ${m.id}: ${e.message}`); |
630 | | - } |
631 | | - } |
632 | | - } |
| 496 | + // The settlement state machine lives in lifecycle.js — see that file |
| 497 | + // for why it is not inline here (a state change that skipped the |
| 498 | + // reducer made the audit trail contradict the money). |
| 499 | + const { voidPrices, settleResolved, settleVoid, tick, maybeTick } = createLifecycle({ |
| 500 | + state, |
| 501 | + commit: store.commit, |
| 502 | + broadcast: (type, m) => broadcast(type, m), |
| 503 | + log: api.log, |
| 504 | + cfg: { twapWindowMs, houseFeeShareBps, settlementWindowMs, disputeGraceMs, HOUSE }, |
| 505 | + }); |
633 | 506 |
|
634 | 507 | // --------------------------------------------------------- websocket |
635 | 508 | const sockets = new Set(); |
|
0 commit comments