5757// in-memory ledger ahead of the durable one.
5858
5959import crypto from 'node:crypto' ;
60+ import fs from 'node:fs' ;
61+ import path from 'node:path' ;
6062import { lmsrPrices , tradeCostRaw , sharesForBudget , twapPrices , uniformPrices } from './lmsr.js' ;
6163import { createStore , dict } from './store.js' ;
6264import {
@@ -104,6 +106,17 @@ export function randomId(len = 8) {
104106/** An agent id is a WebID (http/https URL) or a DID — the two shapes
105107 * getAgent can ever return. Rejecting anything else at creation stops a
106108 * 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+
107120export function isAgentId ( s ) {
108121 if ( typeof s !== 'string' || ! s || s . length > 512 ) return false ;
109122 if ( s . startsWith ( 'did:' ) ) return / ^ d i d : [ a - z 0 - 9 ] + : [ \w . : % - ] + $ / i. test ( s ) ;
@@ -122,7 +135,8 @@ export async function activate(api) {
122135 const grantMicro = Math . round ( num ( cfg . grantCredits , 1000 ) * MICRO ) ;
123136 const feeBps = num ( cfg . feeBps , 100 ) ;
124137 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 ) ;
126140 const disputeGraceMs = num ( cfg . disputeGraceMs , 7 * 24 * 3600 * 1000 ) ;
127141 const settlementWindowMs = num ( cfg . settlementWindowMs , 7 * 24 * 3600 * 1000 ) ;
128142 const twapWindowMs = num ( cfg . twapWindowMs , 30 * 60 * 1000 ) ;
@@ -142,13 +156,28 @@ export async function activate(api) {
142156 for ( const a of admins ) {
143157 if ( ! isAgentId ( a ) ) throw new Error ( `markets: config.admins contains a non-agent id: ${ a } ` ) ;
144158 }
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+ }
145173 if ( / [ " ' < > ] / . test ( prefix ) ) throw new Error ( `markets: refusing an unsafe prefix: ${ prefix } ` ) ;
146174
147175 // -------------------------------------------------- store & security
148176 const dir = api . storage . pluginDir ( ) ;
149177 const store = createStore ( { dir, log : api . log , prices : lmsrPrices } ) ;
150178 const { state } = store ;
151179 const sessions = createSessions ( { dir, ttlMs : sessionTtlMs } ) ;
180+ const pseudonymSalt = readOrCreateSecret ( path . join ( dir , 'pseudonym.salt' ) ) ;
152181 const limiter = createRateLimiter ( { capacity : num ( cfg . rateCapacity , 120 ) , refillPerSec : num ( cfg . rateRefillPerSec , 2 ) } ) ;
153182
154183 /** agent → Set(marketId) — so /api/me is O(your markets), not O(all). */
@@ -211,7 +240,12 @@ export async function activate(api) {
211240 * credential is ambient (cookie/TLS cert), because those are exactly
212241 * the credentials a cross-origin page can borrow. */
213242 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 ;
215249 return isSameOrigin ( request , ownOrigin ( ) ) ;
216250 }
217251
@@ -316,7 +350,16 @@ export async function activate(api) {
316350 outcomes : m . outcomes ,
317351 prices : prices . map ( ( p ) => Number ( p . toFixed ( 6 ) ) ) ,
318352 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 ,
319360 tradable : tradable ( m ) ,
361+ canResolve : m . status === 'open' ,
362+ canVoid : m . status !== 'resolved' && m . status !== 'void' ,
320363 closesAt : new Date ( m . closesAt ) . toISOString ( ) ,
321364 createdAt : m . createdAt ,
322365 creator : m . creator ,
@@ -421,7 +464,7 @@ export async function activate(api) {
421464 * in the event, and applied by the reducer — so replay never recomputes
422465 * float arithmetic.
423466 */
424- function settle ( m , status , payoutMicroOf , prices ) {
467+ function settle ( m , status , payoutMicroOf , prices , { adjudicatedBy = null } = { } ) {
425468 const pool = m . subsidyMicro + m . collectedMicro ;
426469 const raw = [ ] ;
427470 let sum = 0 ;
@@ -453,23 +496,46 @@ export async function activate(api) {
453496 const houseFee = Math . floor ( ( m . feesMicro * houseFeeShareBps ) / 10_000 ) ;
454497 const creatorFee = m . feesMicro - houseFee ;
455498
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+
456509 store . commit ( {
457510 type : 'market.settle' ,
458511 marketId : m . id ,
459512 status,
460513 payouts,
514+ bondRefunds,
461515 creatorMicro : creatorFromPool + creatorFee ,
462- houseMicro : houseFromPool + houseFee ,
516+ houseMicro : houseFromPool + houseFee + bondToHouse ,
463517 house : HOUSE ,
518+ adjudicatedBy,
464519 prices : prices ? prices . map ( ( p ) => Number ( p . toFixed ( 6 ) ) ) : null ,
465520 } ) ;
466521 broadcast ( 'settle' , m ) ;
467522 }
468523
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 ) => {
471535 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 ) ;
473539 } ;
474540
475541 /**
@@ -787,6 +853,9 @@ export async function activate(api) {
787853 // contract (see header). Do not introduce one.
788854 const m = state . markets [ request . params . id ] ;
789855 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' ) ;
790859 if ( ! tradable ( m ) ) return err ( reply , 409 , `market is ${ displayStatus ( m ) } — trading has stopped` ) ;
791860 if ( ! allowInsiderTrading && ( agent === m . oracle || agent === m . creator ) ) {
792861 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) {
925994 if ( ! pos || pos . shares . every ( ( s ) => s === 0 ) ) return err ( reply , 403 , 'only a holder may dispute' ) ;
926995 const reason = typeof ( request . body || { } ) . reason === 'string'
927996 ? 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+ } ) ;
9291005 api . log . warn ( `markets: ${ m . id } disputed by ${ agent } : ${ reason } ` ) ;
9301006 broadcast ( 'market' , m ) ;
9311007 return reply . send ( marketOut ( m ) ) ;
@@ -962,7 +1038,7 @@ export async function activate(api) {
9621038 // market escrows subsidy + collected + fees, and all three are paid
9631039 // out at settlement. Omitting fees here would make the conservation
9641040 // 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 ) ;
9661042 open ++ ;
9671043 }
9681044 return reply . send ( {
@@ -992,9 +1068,12 @@ export async function activate(api) {
9921068 return reply . send ( { leaderboard : top } ) ;
9931069 } ) ;
9941070
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. */
9961075 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 ) } ` ;
9981077 }
9991078
10001079 // ---- admin ----------------------------------------------------------
@@ -1029,6 +1108,56 @@ export async function activate(api) {
10291108 return reply . send ( { ok : true , agent, balance : balanceOf ( agent ) / MICRO } ) ;
10301109 } ) ;
10311110
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+
10321161 api . fastify . post ( `${ prefix } /api/admin/hide` , jsonOpts ( 1024 ) , async ( request , reply ) => {
10331162 const by = await adminOnly ( request , reply ) ;
10341163 if ( ! by ) return reply ;
0 commit comments