import { Connection, Keypair } from "@solana/web3.js"; import { EventEmitter } from "events"; import { PythPrice } from "../pyth/types.js"; import { MarketState, PoolType } from "../types.js"; import { getMarketAddress } from "../accounts/pda.js"; import { parseMarketAccount } from "../accounts/market.js"; import { createProgramWithKeypair, PitProgram } from "../program.js"; import { safeToNumber } from "../math.js"; import { MarketWatcherConfig, WatcherStatus, OutcomeChangeEvent, SettledEvent, WatcherError, } from "./types.js"; import { PriceMonitor } from "./PriceMonitor.js"; import { SettlementChecker } from "./SettlementChecker.js"; import { TransactionBuilder } from "./TransactionBuilder.js"; /** * MarketWatcher - Automated market monitoring and settlement (Orchestrator) * * This class coordinates PriceMonitor, SettlementChecker, and TransactionBuilder * to watch a single market, monitoring price changes and automatically calling * mark_extreme when outcomes change, and settle_market when the market expires. * * @example * ```typescript * const watcher = new MarketWatcher({ * connection: new Connection(RPC_URL), * signer: keeperKeypair, * weekNumber: 1, * }); * * watcher.on('priceUpdate', (price) => { * console.log(`Price: $${price.price}`); * }); * * watcher.on('outcomeChange', (event) => { * console.log(`${event.poolType} outcome: ${event.outcome}`); * }); * * watcher.on('settled', (event) => { * console.log(`Market ${event.weekNumber} settled`); * }); * * await watcher.start(); * ``` */ export class MarketWatcher extends EventEmitter { private connection: Connection; private signer: Keypair; private weekNumber: number; private verbose: boolean; private program: PitProgram; private isRunning = false; private market: MarketState | null = null; // Component instances private priceMonitor: PriceMonitor; private settlementChecker: SettlementChecker; private txBuilder: TransactionBuilder; constructor(config: MarketWatcherConfig) { super(); this.connection = config.connection; this.signer = config.signer; this.weekNumber = config.weekNumber; this.verbose = config.verbose ?? false; this.program = createProgramWithKeypair(config.connection, config.signer); // Initialize components this.priceMonitor = new PriceMonitor( config.hermesUrl ?? "https://hermes.pyth.network", config.priceCheckInterval ?? 2000, config.verbose ?? false ); this.settlementChecker = new SettlementChecker( config.settlementCheckInterval ?? 10000, config.verbose ?? false ); this.txBuilder = new TransactionBuilder( this.connection, this.signer, this.program, config.verbose ?? false ); } /** * Start watching the market * * This will: * 1. Fetch initial market state * 2. Start price monitoring loop * 3. Start settlement check loop */ async start(): Promise { if (this.isRunning) { throw new Error("MarketWatcher is already running"); } this.isRunning = true; this.log(`Starting MarketWatcher for week ${this.weekNumber}`); // Fetch initial market state await this.refreshMarket(); if (!this.market) { throw new Error(`Market ${this.weekNumber} not found`); } if (!this.market.highPool.isInitialized) { throw new Error(`Market ${this.weekNumber} high pool not initialized`); } if (!this.market.lowPool.isInitialized) { throw new Error(`Market ${this.weekNumber} low pool not initialized`); } this.log( `Market loaded. End timestamp: ${safeToNumber(this.market.endTimestamp, "endTimestamp")}` ); this.log(`Strikes: ${this.market.highPool.strikes.map((s) => safeToNumber(s, "strike"))}`); // Start PriceMonitor this.priceMonitor.start( this.market, async (poolType: PoolType, newOutcome: number, priceInCents: number) => { await this.handleOutcomeChange(poolType, newOutcome, priceInCents); }, (price: PythPrice) => { this.emit("priceUpdate", price); } ); // Start SettlementChecker this.settlementChecker.start( this.market, async () => { await this.handleSettlementNeeded(); }, () => { this.log("Both pools settled, stopping watcher"); this.emit("stopped"); this.stop(); } ); } /** * Stop watching the market */ stop(): void { this.isRunning = false; this.priceMonitor.stop(); this.settlementChecker.stop(); this.log(`Stopped MarketWatcher for week ${this.weekNumber}`); } /** * Get current watcher status */ getStatus(): WatcherStatus { const lastPrice = this.priceMonitor.getLastPrice(); return { weekNumber: this.weekNumber, isRunning: this.isRunning, currentHighOutcome: this.market?.highPool.winningOutcome ?? 0, currentLowOutcome: this.market?.lowPool.winningOutcome ?? 0, marketEndTimestamp: this.market ? safeToNumber(this.market.endTimestamp, "marketEndTimestamp") : 0, highPoolSettled: this.market?.highPool.isSettled ?? false, lowPoolSettled: this.market?.lowPool.isSettled ?? false, lastPrice: lastPrice, strikes: this.market ? [ safeToNumber(this.market.highPool.strikes[0], "strike0"), safeToNumber(this.market.highPool.strikes[1], "strike1"), safeToNumber(this.market.highPool.strikes[2], "strike2"), ] : [0, 0, 0], }; } // Event type overloads override on(event: "priceUpdate", listener: (price: PythPrice) => void): this; override on(event: "outcomeChange", listener: (data: OutcomeChangeEvent) => void): this; override on(event: "settled", listener: (data: SettledEvent) => void): this; override on(event: "error", listener: (error: WatcherError) => void): this; override on(event: "stopped", listener: () => void): this; // eslint-disable-next-line @typescript-eslint/no-explicit-any override on(event: string | symbol, listener: (...args: any[]) => void): this { return super.on(event, listener); } // --- Private methods --- private async refreshMarket(): Promise { try { const marketPda = getMarketAddress(this.weekNumber); const accountInfo = await this.connection.getAccountInfo(marketPda); if (!accountInfo) { this.market = null; return; } // Skip 8-byte discriminator this.market = parseMarketAccount(accountInfo.data.slice(8)); } catch (err) { this.emitError("fetch", err as Error); } } private async handleOutcomeChange( poolType: PoolType, newOutcome: number, priceInCents: number ): Promise { if (!this.market) return; const previousOutcome = poolType === PoolType.High ? this.market.highPool.winningOutcome : this.market.lowPool.winningOutcome; this.log( `Outcome change detected: ${poolType === PoolType.High ? "High" : "Low"} pool, ` + `outcome ${previousOutcome} -> ${newOutcome}, price: ${priceInCents} cents` ); try { const signature = await this.txBuilder.markExtreme(this.weekNumber, poolType, newOutcome); // Refresh market state after successful mark_extreme await this.refreshMarket(); const event: OutcomeChangeEvent = { poolType, outcome: newOutcome, previousOutcome, signature, priceInCents, }; this.emit("outcomeChange", event); this.log(`mark_extreme success: ${signature}`); } catch (err) { this.emitError("markExtreme", err as Error); } } private async handleSettlementNeeded(): Promise { if (!this.market) return; const highOutcome = this.market.highPool.winningOutcome; const lowOutcome = this.market.lowPool.winningOutcome; this.log(`Settlement needed: High outcome=${highOutcome}, Low outcome=${lowOutcome}`); try { const signature = await this.txBuilder.settleMarket(this.weekNumber); // Refresh market state after successful settlement await this.refreshMarket(); // Emit settled event for High pool const highEvent: SettledEvent = { weekNumber: this.weekNumber, poolType: PoolType.High, finalOutcome: highOutcome, signature, }; this.emit("settled", highEvent); // Emit settled event for Low pool const lowEvent: SettledEvent = { weekNumber: this.weekNumber, poolType: PoolType.Low, finalOutcome: lowOutcome, signature, }; this.emit("settled", lowEvent); this.log(`settle_market success: ${signature}`); } catch (err) { this.emitError("settle", err as Error); } } private emitError(context: "price" | "markExtreme" | "settle" | "fetch", error: Error): void { const watcherError: WatcherError = { message: error.message, error, context, }; this.emit("error", watcherError); this.log(`Error in ${context}: ${error.message}`); } private log(message: string): void { if (this.verbose) { console.log(`[MarketWatcher:${this.weekNumber}] ${message}`); } } }