@@ -62,7 +62,7 @@ import crypto from 'node:crypto';
6262import fs from 'node:fs' ;
6363import path from 'node:path' ;
6464import { lmsrPrices , tradeCostRaw , sharesForBudget , twapPrices , uniformPrices } from './lmsr.js' ;
65- import { createStore , dict } from './store.js' ;
65+ import { createStore , dict , TWAP_PROTECT_MS } from './store.js' ;
6666import {
6767 createSessions , createRateLimiter , isSameOrigin , isAmbientCredential , UI_HEADERS , API_HEADERS ,
6868} from './guard.js' ;
@@ -171,6 +171,12 @@ export async function activate(api) {
171171 throw new Error ( `markets: config.${ name } must be a positive number of milliseconds (got ${ v } )` ) ;
172172 }
173173 }
174+ if ( twapWindowMs > TWAP_PROTECT_MS ) {
175+ throw new Error (
176+ `markets: config.twapWindowMs (${ twapWindowMs } ) must not exceed ${ TWAP_PROTECT_MS } ms — beyond that, `
177+ + 'price-history thinning reaches inside the redemption window and makes the void price steerable by trade timing' ,
178+ ) ;
179+ }
174180 if ( ! Number . isFinite ( disputeBondMicro ) || disputeBondMicro < 0 ) {
175181 throw new Error ( 'markets: config.disputeBondCredits must be a non-negative number' ) ;
176182 }
@@ -231,7 +237,7 @@ export async function activate(api) {
231237 const claims = sessions . verify ( token ) ;
232238 if ( ! claims ) return null ;
233239 const row = state . ledger [ claims . agent ] ;
234- if ( row && ( row . epoch || 0 ) !== claims . epoch ) return null ;
240+ if ( ( row ? row . epoch || 0 : 0 ) !== claims . epoch ) return null ;
235241 return claims . agent ;
236242 }
237243
@@ -262,7 +268,12 @@ export async function activate(api) {
262268 const known = ownOrigin ( ) ;
263269 if ( known ) return known ;
264270 const host = request . headers . host ;
265- return host ? `${ request . protocol || 'http' } ://${ host } ` : null ;
271+ if ( ! host ) return null ;
272+ // Fastify reports the SOCKET's protocol unless trustProxy is on,
273+ // which a plugin cannot set; behind nginx/Caddy that is 'http' while
274+ // the browser's Origin says https, and every mutation would 403.
275+ const proto = request . headers [ 'x-forwarded-proto' ] || request . protocol || 'https' ;
276+ return `${ String ( proto ) . split ( ',' ) [ 0 ] . trim ( ) } ://${ host } ` ;
266277 }
267278
268279 function csrfOk ( request ) {
@@ -492,7 +503,7 @@ export async function activate(api) {
492503 * in the event, and applied by the reducer — so replay never recomputes
493504 * float arithmetic.
494505 */
495- function settle ( m , status , payoutMicroOf , prices , { adjudicatedBy = null , sustained = false } = { } ) {
506+ function settle ( m , status , payoutMicroOf , prices , { adjudicatedBy = null , sustained = false , outcome = null } = { } ) {
496507 const pool = m . subsidyMicro + m . collectedMicro ;
497508 const raw = [ ] ;
498509 let sum = 0 ;
@@ -541,6 +552,10 @@ export async function activate(api) {
541552 type : 'market.settle' ,
542553 marketId : m . id ,
543554 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,
544559 payouts,
545560 bondRefunds,
546561 creatorMicro : creatorFromPool + creatorFee ,
@@ -552,7 +567,12 @@ export async function activate(api) {
552567 broadcast ( 'settle' , m ) ;
553568 }
554569
555- const settleResolved = ( m , opts ) => settle ( m , 'resolved' , ( pos ) => pos . shares [ m . resolvedOutcome ] , null , opts ) ;
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+ } ;
556576 // On a void you receive the LESSER of market value (at the TWAP) and
557577 // what you actually paid. The cap is what finally kills the void
558578 // arbitrage: the TWAP already defeats a last-second pump, but a
@@ -593,6 +613,7 @@ export async function activate(api) {
593613 for ( const m of Object . values ( state . markets ) ) {
594614 try {
595615 if ( m . status === 'resolving' && now >= m . settleAt ) settleResolved ( m ) ;
616+ else if ( m . status === 'voiding' && now >= m . settleAt ) settleVoid ( m ) ;
596617 else if ( m . status === 'open' && now >= m . closesAt + settlementWindowMs ) {
597618 api . log . warn ( `markets: ${ m . id } auto-voiding — no resolution within the settlement window` ) ;
598619 settleVoid ( m ) ;
@@ -656,6 +677,9 @@ export async function activate(api) {
656677 ticker . unref ?. ( ) ;
657678
658679 function broadcast ( type , m ) {
680+ // A withdrawn market must not push its title and description to
681+ // every connected client when it settles.
682+ if ( m . hidden ) return ;
659683 const msg = JSON . stringify ( { type, market : marketOut ( m ) } ) ;
660684 for ( const s of sockets ) {
661685 // Drop a client that isn't draining rather than buffering without
@@ -737,6 +761,7 @@ export async function activate(api) {
737761 for ( const id of byAgent . get ( agent ) || [ ] ) {
738762 const m = state . markets [ id ] ;
739763 if ( ! m ) continue ;
764+ if ( m . status === 'resolved' || m . status === 'void' ) continue ; // paid out; the receipt is the record
740765 const pos = m . positions [ agent ] ;
741766 if ( ! pos || pos . shares . every ( ( s ) => s === 0 ) ) continue ;
742767 positions . push ( {
@@ -919,6 +944,7 @@ export async function activate(api) {
919944 api . fastify . get ( `${ prefix } /api/markets/:id/quote` , async ( request , reply ) => {
920945 const m = state . markets [ request . params . id ] ;
921946 if ( ! m ) return err ( reply , 404 , 'no such market' ) ;
947+ if ( m . hidden ) return err ( reply , 451 , 'this market has been withdrawn by the operator' ) ;
922948 const q = request . query || { } ;
923949 const t = priceTrade ( m , q . side , q . outcome , q . shares , q . spend ) ;
924950 if ( t . error ) return err ( reply , 400 , t . error ) ;
@@ -1014,6 +1040,7 @@ export async function activate(api) {
10141040 api . fastify . get ( `${ prefix } /api/markets/:id/history` , async ( request , reply ) => {
10151041 const m = state . markets [ request . params . id ] ;
10161042 if ( ! m ) return err ( reply , 404 , 'no such market' ) ;
1043+ if ( m . hidden ) return err ( reply , 451 , 'this market has been withdrawn by the operator' ) ;
10171044 return reply . send ( {
10181045 id : m . id ,
10191046 outcomes : m . outcomes ,
@@ -1067,7 +1094,7 @@ export async function activate(api) {
10671094 if ( ! m ) return err ( reply , 404 , 'no such market' ) ;
10681095 tick ( ) ;
10691096 if ( m . status === 'resolved' || m . status === 'void' ) return reply . send ( marketOut ( m ) ) ;
1070- if ( m . status === 'resolving' ) {
1097+ if ( m . status === 'resolving' || m . status === 'voiding' ) {
10711098 return err ( reply , 409 , `settles at ${ new Date ( m . settleAt ) . toISOString ( ) } (dispute window open)` ) ;
10721099 }
10731100 return err ( reply , 409 , `market is ${ displayStatus ( m ) } — nothing to settle` ) ;
@@ -1085,7 +1112,8 @@ export async function activate(api) {
10851112 // 'disputed' is disputable too: latching on the FIRST disputer meant
10861113 // one person paid the bond and every other loser free-rode on the
10871114 // resulting void. Each disputer posts their own.
1088- if ( m . status !== 'resolving' && m . status !== 'disputed' ) {
1115+ tick ( ) ; // otherwise a market past settleAt is still 'resolving' here
1116+ if ( m . status !== 'resolving' && m . status !== 'disputed' && m . status !== 'voiding' ) {
10891117 return err ( reply , 409 , 'only a resolving market can be disputed' ) ;
10901118 }
10911119 const pos = m . positions [ agent ] ;
@@ -1096,6 +1124,11 @@ export async function activate(api) {
10961124 const reason = typeof ( request . body || { } ) . reason === 'string'
10971125 ? request . body . reason . slice ( 0 , 500 ) : '' ;
10981126 if ( ! reason . trim ( ) ) return err ( reply , 400 , 'a reason is required to dispute' ) ;
1127+ // With no operator configured, `sustained` can never become true, so
1128+ // the bond is mathematically unrecoverable. Don't take it.
1129+ if ( ! admins . size ) {
1130+ return err ( reply , 409 , 'this deployment has no operator to adjudicate disputes, so a dispute bond could never be returned' ) ;
1131+ }
10991132 ensureAccount ( agent ) ;
11001133 // Scale with what the dispute puts at risk. A flat bond against a
11011134 // large position is trivially +EV to post: the disputer risks 25 to
@@ -1118,6 +1151,9 @@ export async function activate(api) {
11181151 if ( ! m ) return err ( reply , 404 , 'no such market' ) ;
11191152 const stale = Date . now ( ) >= m . closesAt + settlementWindowMs ;
11201153 if ( m . status === 'resolved' || m . status === 'void' ) return err ( reply , 409 , `market is ${ m . status } ` ) ;
1154+ if ( m . status === 'voiding' && ! admins . has ( agent ) && ! stale ) {
1155+ return err ( reply , 409 , `a void is already proposed; it settles at ${ new Date ( m . settleAt ) . toISOString ( ) } ` ) ;
1156+ }
11211157 // Once disputed, ONLY an admin may settle. Otherwise the oracle
11221158 // answers a dispute against itself by voiding the market, and the
11231159 // dispute is no check on the oracle at all.
@@ -1132,11 +1168,21 @@ export async function activate(api) {
11321168 : 'only the oracle may void before the settlement window expires' ) ;
11331169 }
11341170 if ( ! m . closedAt ) store . commit ( { type : 'market.close' , marketId : m . id } ) ;
1135- // An operator voiding a DISPUTED market has sustained the dispute,
1136- // whichever route they used to do it.
1137- settleVoid ( m , m . status === 'disputed' && admins . has ( agent )
1138- ? { adjudicatedBy : agent , sustained : true } : { } ) ;
1139- api . log . info ( `markets: ${ m . id } voided (redeemed at TWAP)` ) ;
1171+
1172+ // An operator, or the anyone-can-rescue backstop, settles now.
1173+ if ( admins . has ( agent ) || stale ) {
1174+ settleVoid ( m , m . status === 'disputed' && admins . has ( agent )
1175+ ? { adjudicatedBy : agent , sustained : true } : { } ) ;
1176+ api . log . info ( `markets: ${ m . id } voided (redeemed at TWAP)` ) ;
1177+ return reply . send ( marketOut ( m ) ) ;
1178+ }
1179+
1180+ // An ORACLE void is only a proposal: it goes through the same
1181+ // dispute window as a resolution, so holders can object before a
1182+ // cancellation they can only lose on becomes final.
1183+ store . commit ( { type : 'market.propose-void' , marketId : m . id , agent, settleAt : Date . now ( ) + disputeWindowMs } ) ;
1184+ api . log . info ( `markets: ${ m . id } void proposed by the oracle — settles after the dispute window` ) ;
1185+ broadcast ( 'market' , m ) ;
11401186 return reply . send ( marketOut ( m ) ) ;
11411187 } ) ;
11421188
@@ -1247,8 +1293,7 @@ export async function activate(api) {
12471293 // Voiding would refund the loser and wipe out whoever was RIGHT, so
12481294 // a correctable error needs its own verb.
12491295 if ( outcome < 0 || outcome >= m . outcomes . length ) return err ( reply , 400 , 'outcome must be a valid outcome index' ) ;
1250- m . resolvedOutcome = outcome ;
1251- settleResolved ( m , { adjudicatedBy : by , sustained : true } ) ;
1296+ settleResolved ( m , { adjudicatedBy : by , sustained : true , outcome } ) ;
12521297 } else if ( uphold === false ) {
12531298 settleVoid ( m , { adjudicatedBy : by , sustained : true } ) ;
12541299 } else {
0 commit comments