diff --git a/changelog.md b/changelog.md index c278d55a..70e1216f 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [2.43.27] - 2026-05-25 + +### Fixed + +- **Kalshi**: Use enriched series titles to normalize contaminated broad-future event titles. Multi-market futures such as Champions League Winner and conference championship winner events no longer inherit current-matchup labels like `PSG vs Arsenal` or `Cleveland vs New York` as their PMXT event title, while true match events keep their matchup titles. +- **Kalshi**: Add regression coverage for contaminated futures, already-sane futures, and true matchup events. + +## [2.43.26] - 2026-05-25 + +### Fixed + +- **SDK streaming**: Remove TypeScript and Python REST fallbacks for `watchOrderBook`, `watchOrderBooks`, `watchTrades`, and `unwatchOrderBook`. Streaming methods now require the hosted `/ws` transport and fail fast if WebSocket transport is unavailable, preventing accidental 30s REST long-poll calls to `/api/{exchange}/watch*`. +- **Python SDK**: Add regression coverage proving streaming methods use WebSocket transport and do not invoke HTTP fallbacks. + ## [2.43.25] - 2026-05-24 ### Added diff --git a/core/COMPLIANCE.md b/core/COMPLIANCE.md index 2e1d79d9..4ae6ec0f 100644 --- a/core/COMPLIANCE.md +++ b/core/COMPLIANCE.md @@ -8,28 +8,28 @@ This document details the feature support and compliance status for each exchang ## Functions Status -| Category | Function | Polymarket | Kalshi | Limitless | Probable | Baozi | Myriad | Opinion | Metaculus | -| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| **Market Data** | `fetchMarkets` | Y | Y | Y | Y | Y | Y | Y | Y | -| | `fetchEvents` | Y | Y | Y | Y | Y | Y | Y | Y | -| | `fetchMarket` | Y | Y | Y | Y | Y | Y | Y | Y | -| | `fetchEvent` | Y | Y | Y | Y | Y | Y | Y | Y | -| **Public Data** | `fetchOHLCV` | Y | Y | Y | Y | Y | Y | Y | - | -| | `fetchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - | -| | `fetchTrades` | Y | Y | Y | Y | Y | Y | - | - | -| **Private Data** | `fetchBalance` | Y | Y | Y | Y | Y | Y | - | - | -| | `fetchPositions` | Y | Y | Y | Y | Y | Y | Y | - | -| | `fetchMyTrades` | Y | Y | Y | Y | - | Y | Y | - | -| **Trading** | `createOrder` | Y | Y | Y | Y | Y | Y | Y | Y | -| | `cancelOrder` | Y | Y | Y | Y | Y | - | Y | Y | -| | `fetchOrder` | Y | Y | Y | Y | Y | - | Y | - | -| | `fetchOpenOrders` | Y | Y | Y | Y | Y | Y | Y | - | -| | `fetchClosedOrders` | - | Y | Y | - | - | - | Y | - | -| | `fetchAllOrders` | - | Y | Y | - | - | - | Y | - | -| **Calculations** | `getExecutionPrice` | Y | Y | Y | Y | Y | Y | Y | - | -| | `getExecutionPriceDetailed` | Y | Y | Y | Y | Y | Y | Y | - | -| **Real-time** | `watchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - | -| | `watchTrades` | Y | Y | Y | - | - | Y | Y | - | +| Category | Function | Polymarket | Kalshi | Limitless | Probable | Baozi | Myriad | Opinion | Metaculus | SuiBets | +| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| **Market Data** | `fetchMarkets` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| | `fetchEvents` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| | `fetchMarket` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| | `fetchEvent` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| **Public Data** | `fetchOHLCV` | Y | Y | Y | Y | Y | Y | Y | - | - | +| | `fetchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - | Y (emulated) | +| | `fetchTrades` | Y | Y | Y | Y | Y | Y | - | - | - | +| **Private Data** | `fetchBalance` | Y | Y | Y | Y | Y | Y | - | - | - | +| | `fetchPositions` | Y | Y | Y | Y | Y | Y | Y | - | Y | +| | `fetchMyTrades` | Y | Y | Y | Y | - | Y | Y | - | - | +| **Trading** | `createOrder` | Y | Y | Y | Y | Y | Y | Y | Y | - | +| | `cancelOrder` | Y | Y | Y | Y | Y | - | Y | Y | - | +| | `fetchOrder` | Y | Y | Y | Y | Y | - | Y | - | - | +| | `fetchOpenOrders` | Y | Y | Y | Y | Y | Y | Y | - | - | +| | `fetchClosedOrders` | - | Y | Y | - | - | - | Y | - | - | +| | `fetchAllOrders` | - | Y | Y | - | - | - | Y | - | - | +| **Calculations** | `getExecutionPrice` | Y | Y | Y | Y | Y | Y | Y | - | - | +| | `getExecutionPriceDetailed` | Y | Y | Y | Y | Y | Y | Y | - | - | +| **Real-time** | `watchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - | - | +| | `watchTrades` | Y | Y | Y | - | - | Y | Y | - | - | ## Legend - **Y** - Supported diff --git a/core/src/errors.ts b/core/src/errors.ts index aa7064ad..c10c4c21 100644 --- a/core/src/errors.ts +++ b/core/src/errors.ts @@ -149,6 +149,15 @@ export class ValidationError extends BaseError { // 5xx Server/Network Errors // ============================================================================ +/** + * 501 Not Implemented - The requested operation is not supported + */ +export class NotSupported extends BaseError { + constructor(message: string, exchange?: string) { + super(message, 501, 'NOT_SUPPORTED', false, exchange); + } +} + /** * 503 Service Unavailable - Network connectivity issues (retryable) */ diff --git a/core/src/exchanges/kalshi/fetcher.ts b/core/src/exchanges/kalshi/fetcher.ts index 4662eba3..9ec2e439 100644 --- a/core/src/exchanges/kalshi/fetcher.ts +++ b/core/src/exchanges/kalshi/fetcher.ts @@ -42,15 +42,23 @@ export interface KalshiRawMarket { export interface KalshiRawEvent { event_ticker: string; title: string; + sub_title?: string; image_url?: string; category?: string; tags?: string[]; series_ticker?: string; + series_title?: string; + mutually_exclusive?: boolean; markets?: KalshiRawMarket[]; [key: string]: unknown; } +interface KalshiSeriesInfo { + title?: string; + tags?: string[]; +} + export interface KalshiRawEventPage { events: KalshiRawEvent[]; cursor?: string | null; @@ -149,7 +157,7 @@ export class KalshiFetcher implements IExchangeFetcher | null = null; + private cachedSeriesMap: Map | null = null; private lastCacheTime: number = 0; constructor(ctx: FetcherContext) { @@ -205,16 +213,16 @@ export class KalshiFetcher implements IExchangeFetcher> { + async fetchRawSeriesMap(): Promise> { try { const data = await this.ctx.callApi('GetSeriesList'); const seriesList = data.series || []; - const map = new Map(); + const map = new Map(); for (const series of seriesList) { - if (series.tags && series.tags.length > 0) { - map.set(series.ticker, series.tags); + if (series.ticker) { + map.set(series.ticker, { + title: typeof series.title === 'string' ? series.title : undefined, + tags: Array.isArray(series.tags) ? series.tags : undefined, + }); } } return map; @@ -394,19 +407,23 @@ export class KalshiFetcher implements IExchangeFetcher 0 && (!event.tags || event.tags.length === 0)) { event.tags = series.tags; } } catch (err: unknown) { - // Non-critical — tags are enrichment only. - logger.warn('kalshi: series tag fetch failed', { + // Non-critical — series metadata is enrichment only. + logger.warn('kalshi: series metadata fetch failed', { series_ticker: event.series_ticker, error: err instanceof Error ? err.message : String(err), }); @@ -438,14 +455,7 @@ export class KalshiFetcher implements IExchangeFetcher= 1000 && useCache) { this.cachedEvents = allEvents; @@ -456,6 +466,40 @@ export class KalshiFetcher implements IExchangeFetcher { + if (events.length === 0) return events; + + try { + const seriesMap = await this.fetchRawSeriesMap(); + this.enrichEventsWithSeriesMap(events, seriesMap); + } catch (err: unknown) { + // Non-critical — callers can still normalize the venue-native title. + logger.warn('kalshi: series list enrichment failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + + return events; + } + + private enrichEventsWithSeriesMap( + events: KalshiRawEvent[], + seriesMap: Map, + ): void { + for (const event of events) { + if (!event.series_ticker) continue; + const seriesInfo = seriesMap.get(event.series_ticker); + if (!seriesInfo) continue; + + if (seriesInfo.title) { + event.series_title = seriesInfo.title; + } + if (seriesInfo.tags?.length && (!event.tags || event.tags.length === 0)) { + event.tags = seriesInfo.tags; + } + } + } + private async fetchActiveEvents(targetMarketCount?: number, status: string = 'open'): Promise { let allEvents: KalshiRawEvent[] = []; let totalMarketCount = 0; diff --git a/core/src/exchanges/kalshi/normalizer.ts b/core/src/exchanges/kalshi/normalizer.ts index 594250d5..e572c2a2 100644 --- a/core/src/exchanges/kalshi/normalizer.ts +++ b/core/src/exchanges/kalshi/normalizer.ts @@ -105,7 +105,7 @@ export class KalshiNormalizer implements IExchangeNormalizer this.deriveOutcomeLabel(market)) + .filter((label): label is string => label != null && label.length >= 3); + + if (candidateLabels.length < 4) return false; + + const normalizedTitle = this.normalizeTitleText(rawTitle); + const containedLabels = new Set(); + for (const label of candidateLabels) { + const normalizedLabel = this.normalizeTitleText(label); + if (normalizedLabel && normalizedTitle.includes(normalizedLabel)) { + containedLabels.add(normalizedLabel); + } + } + + return containedLabels.size >= 2; + } + + private deriveCommonEventTitle(event: KalshiRawEvent, markets: KalshiRawMarket[]): string | null { + const eventTitlePrefix = this.extractEventTitlePrefix(event.title); + if (eventTitlePrefix) { + if (this.hasWinVerb(eventTitlePrefix)) return 'Winner'; + if (this.hasResolutionTerm(eventTitlePrefix)) return eventTitlePrefix; + } + + const candidates = new Map(); + + for (const market of markets) { + const marketTitle = this.cleanLabel(market.title); + if (!marketTitle) continue; + + const outcomeLabel = this.deriveOutcomeLabel(market); + const candidate = this.extractEventTitleFromMarketTitle(marketTitle, outcomeLabel); + if (!candidate) continue; + + candidates.set(candidate, (candidates.get(candidate) ?? 0) + 1); + } + + let best: string | null = null; + let bestCount = 0; + for (const [candidate, count] of candidates.entries()) { + if (count > bestCount) { + best = candidate; + bestCount = count; + } + } + + return best; + } + + private extractEventTitleFromMarketTitle(title: string, outcomeLabel: string | null): string | null { + const escapedOutcome = outcomeLabel ? this.escapeRegExp(outcomeLabel) : '[^?]+?'; + const winPattern = new RegExp(`^Will (?:the )?${escapedOutcome} win (?:the )?(.+?)\\??$`, 'i'); + const winMatch = title.match(winPattern); + if (winMatch?.[1]) { + return this.ensureWinnerTitle(winMatch[1].trim()); + } + + const plainWinnerMatch = title.match(/^(.+? Winner)\??$/i); + if (plainWinnerMatch?.[1]) return plainWinnerMatch[1].trim(); + + const championMatch = title.match(/^(.+? Champion(?:ship)?)\??$/i); + if (championMatch?.[1]) return championMatch[1].trim(); + + return null; + } + + private composeSeriesTitle(seriesTitle: string, commonTitle: string | null): string { + if (!commonTitle) return seriesTitle; + + let title = seriesTitle; + const year = commonTitle.match(/^\s*(20\d{2})\b/)?.[1]; + if (year && !new RegExp(`\\b${year}\\b`).test(title)) { + title = `${year} ${title}`; + } + + if (this.hasResolutionTerm(title)) { + return title; + } + + const resolutionTerm = this.extractResolutionTerm(commonTitle); + if (resolutionTerm) { + return `${title} ${resolutionTerm}`; + } + + return title; + } + + private ensureWinnerTitle(title: string): string { + if (this.hasResolutionTerm(title)) return title; + return `${title} Winner`; + } + + private hasResolutionTerm(title: string): boolean { + return /\b(winner|champion|championship|nominee|nomination|election|finals?|cup|award)\b/i.test(title); + } + + private extractEventTitlePrefix(title: string): string | null { + const match = title.match(/^(.+?)(?:\s*:\s*|\s+[-\u2013\u2014]\s+)(.+)$/u); + const prefix = match?.[1]?.trim(); + if (prefix && this.normalizeTitleText(prefix) === 'series winner') return null; + return prefix || null; + } + + private extractResolutionTerm(title: string): string | null { + const match = title.match(/\b(Winner|Champion|Championship|Nominee|Nomination|Election|Finals?|Cup|Award)\b\s*$/i); + return match?.[1] || null; + } + + private hasWinVerb(title: string): boolean { + return /\bwin(?:s|ning)?\b/i.test(title); + } + + private normalizeTitleText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + private deriveOutcomeLabel(market: KalshiRawMarket): string | null { const yesSubtitle = this.cleanLabel(market.yes_sub_title); if (yesSubtitle) return yesSubtitle; diff --git a/core/src/exchanges/mock/index.ts b/core/src/exchanges/mock/index.ts index 9f109190..6c4946dd 100644 --- a/core/src/exchanges/mock/index.ts +++ b/core/src/exchanges/mock/index.ts @@ -326,12 +326,13 @@ export class MockExchange extends PredictionMarketExchange { return limit !== undefined ? events.slice(offset, offset + limit) : events.slice(offset); } - override async fetchOrderBook(id: string, _limit?: number, _params?: Record): Promise { + override async fetchOrderBook(id: string, limit?: number, _params?: Record): Promise { const resolved = await this.resolveOutcomeAlias(id, _params); id = resolved.outcomeId; const f = new SeededRng(id); const midPrice = round(f.float(0.1, 0.9), 3); const spread = round(f.float(0.005, 0.03), 3); + const depth = limit === undefined ? 8 : Math.max(0, Math.floor(limit)); const buildLevels = (startPrice: number, direction: 1 | -1, count: number): OrderLevel[] => { const levels: OrderLevel[] = []; @@ -348,12 +349,20 @@ export class MockExchange extends PredictionMarketExchange { const bidStart = clamp(round(midPrice - spread / 2, 3), 0.01, 0.99); return { - bids: buildLevels(bidStart, -1, 8).sort((a, b) => b.price - a.price), - asks: buildLevels(askStart, 1, 8).sort((a, b) => a.price - b.price), + bids: buildLevels(bidStart, -1, depth).sort((a, b) => b.price - a.price), + asks: buildLevels(askStart, 1, depth).sort((a, b) => a.price - b.price), timestamp: Date.now(), }; } + override async fetchOrderBooks(outcomeIds: string[]): Promise> { + const books: Record = {}; + for (const outcomeId of outcomeIds) { + books[outcomeId] = await this.fetchOrderBook(outcomeId); + } + return books; + } + override async fetchOHLCV(id: string, params: OHLCVParams): Promise { const f = new SeededRng(id); const resolutionMs: Record = { @@ -385,7 +394,7 @@ export class MockExchange extends PredictionMarketExchange { return candles; } - override async fetchTrades(id: string, _params: TradesParams): Promise { + override async fetchTrades(id: string, params: TradesParams = {}): Promise { const f = new SeededRng(id); const count = f.int(5, 30); const trades: Trade[] = []; @@ -401,7 +410,8 @@ export class MockExchange extends PredictionMarketExchange { outcomeId: id, }); } - return trades.sort((a, b) => b.timestamp - a.timestamp); + const sorted = trades.sort((a, b) => b.timestamp - a.timestamp); + return params.limit === undefined ? sorted : sorted.slice(0, Math.max(0, Math.floor(params.limit))); } override async fetchBalance(_address?: string): Promise { diff --git a/core/src/exchanges/suibets/api.ts b/core/src/exchanges/suibets/api.ts new file mode 100644 index 00000000..55150eb9 --- /dev/null +++ b/core/src/exchanges/suibets/api.ts @@ -0,0 +1,17 @@ +/** + * SuiBets P2P Sports Betting API Reference + * + * This file documents the SuiBets REST API endpoints used by the fetcher. + * It is NOT wired into defineImplicitApi — the fetcher calls these endpoints + * directly via FetcherContext.http (the rate-limited HTTP client). + * + * Base URL: https://suibets.replit.app + * + * Endpoints: + * GET /api/p2p/offers - List open P2P offers (status, matchId, sport, limit, offset) + * GET /api/p2p/offers/:id - Get a single P2P offer by ID + * GET /api/p2p/my?wallet=... - Get user activity (created offers, matched bets, parlays) + * GET /api/events/upcoming - List upcoming sports events (sport, limit) + */ + +// No runtime exports — this file serves as API documentation only. diff --git a/core/src/exchanges/suibets/config.ts b/core/src/exchanges/suibets/config.ts new file mode 100644 index 00000000..d83c8b05 --- /dev/null +++ b/core/src/exchanges/suibets/config.ts @@ -0,0 +1,42 @@ +export const SUIBETS_BASE_URL = 'https://suibets.replit.app'; + +// SuiBets is a P2P sports betting platform on Sui blockchain. +// Platform takes a 2% fee on settled markets. +export const SUIBETS_PLATFORM_FEE = 0.02; + +// Sui uses MIST as its base unit; 1 SUI = 1,000,000,000 MIST +export const MIST_PER_SUI = 1e9; + +// Prices represent probabilities in the range [0.01, 0.99] +export const MIN_PRICE = 0.01; +export const MAX_PRICE = 0.99; + +// Minimum delay between outbound requests (milliseconds) +export const RATE_LIMIT_MS = 300; + +// Allowlist of permitted hostnames for SSRF protection +export const ALLOWED_HOSTS: readonly string[] = ['suibets.replit.app']; + +/** + * Validates that the given URL's hostname is in the ALLOWED_HOSTS allowlist. + * Throws if the hostname is not permitted, to prevent SSRF. + */ +export function validateBaseUrl(url: string): void { + const parsed = new URL(url); + if (!ALLOWED_HOSTS.includes(parsed.hostname)) { + throw new Error( + `Base URL hostname "${parsed.hostname}" is not in the SSRF allowlist. ` + + `Permitted hosts: ${ALLOWED_HOSTS.join(', ')}`, + ); + } +} + +export interface SuibetsApiConfig { + baseUrl: string; +} + +export function getSuibetsConfig(baseUrlOverride?: string): SuibetsApiConfig { + const baseUrl = baseUrlOverride ?? SUIBETS_BASE_URL; + validateBaseUrl(baseUrl); + return { baseUrl }; +} diff --git a/core/src/exchanges/suibets/errors.ts b/core/src/exchanges/suibets/errors.ts new file mode 100644 index 00000000..8e57f952 --- /dev/null +++ b/core/src/exchanges/suibets/errors.ts @@ -0,0 +1,91 @@ +import axios from 'axios'; +import { ErrorMapper } from '../../utils/error-mapper'; +import { + AuthenticationError, + ExchangeNotAvailable, + NetworkError, + RateLimitExceeded, +} from '../../errors'; + +/** + * Maps SuiBets API errors to PMXT unified error classes. + * + * SuiBets is a read-only public API, so error mapping focuses on + * network errors and rate limits. Error responses are expected in the form: + * { error: string } + * or: + * { message: string } + */ +export class SuibetsErrorMapper extends ErrorMapper { + constructor() { + super('SuiBets'); + } + + protected extractErrorMessage(error: unknown): string { + if (axios.isAxiosError(error) && error.response?.data) { + const data = error.response.data; + if (typeof data === 'string') { + return `[${error.response.status}] ${data}`; + } + if (typeof data === 'object' && data !== null) { + const obj = data as Record; + if (typeof obj.error === 'string') { + return `[${error.response.status}] ${obj.error}`; + } + if (typeof obj.message === 'string') { + return `[${error.response.status}] ${obj.message}`; + } + } + } + return super.extractErrorMessage(error); + } + + mapError(error: unknown): ReturnType { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + + if (status === 429) { + const retryAfter = error.response?.headers?.['retry-after']; + const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : undefined; + return new RateLimitExceeded( + this.extractErrorMessage(error), + retryAfterSeconds, + this.exchangeName, + ); + } + + if (status === 401 || status === 403) { + return new AuthenticationError(this.extractErrorMessage(error), this.exchangeName); + } + + if (status !== undefined && status >= 500) { + return new ExchangeNotAvailable( + `Exchange error (${status}): ${this.extractErrorMessage(error)}`, + this.exchangeName, + ); + } + + if (!status) { + return new NetworkError( + `Network error: ${this.extractErrorMessage(error)}`, + this.exchangeName, + ); + } + } + + if (error instanceof Error && !axios.isAxiosError(error)) { + const nodeErr = error as Error & { code?: string }; + if ( + nodeErr.code === 'ECONNREFUSED' || + nodeErr.code === 'ENOTFOUND' || + nodeErr.code === 'ETIMEDOUT' + ) { + return new NetworkError(`Network error: ${error.message}`, this.exchangeName); + } + } + + return super.mapError(error); + } +} + +export const suibetsErrorMapper = new SuibetsErrorMapper(); diff --git a/core/src/exchanges/suibets/fetcher.ts b/core/src/exchanges/suibets/fetcher.ts new file mode 100644 index 00000000..6a542045 --- /dev/null +++ b/core/src/exchanges/suibets/fetcher.ts @@ -0,0 +1,196 @@ +import { MarketFilterParams, EventFetchParams } from '../../BaseExchange'; +import { IExchangeFetcher, FetcherContext } from '../interfaces'; +import { suibetsErrorMapper } from './errors'; + +export interface SuibetsRawOffer { + id: string; + matchId: string; + matchName: string; + sport: string; + homeTeam: string; + awayTeam: string; + creatorWallet: string; + creatorTeam: string; + creatorOdds: number; + creatorStake: number; + takerStake: number; + remainingStake?: number; + matchDate: string; + expiresAt: string; + status: string; + totalMatched?: number; + currency?: string; + isOnchain?: boolean; + onchainOfferId?: string; + leagueName?: string; +} + +export interface SuibetsRawEvent { + id: string; + name: string; + homeTeam: string; + awayTeam: string; + sport: string; + leagueName?: string; + matchDate: string; + status: string; + offers?: SuibetsRawOffer[]; +} + +export class SuibetsFetcher implements IExchangeFetcher { + private readonly ctx: FetcherContext; + private readonly baseUrl: string; + + constructor(ctx: FetcherContext, baseUrl: string) { + this.ctx = ctx; + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + /** + * Performs a GET request via the rate-limited HTTP client provided by the + * base class. All errors are mapped to pmxt unified error types. + */ + private async get(path: string, params?: Record): Promise { + try { + const url = new URL(path, this.baseUrl); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + const response = await this.ctx.http.get(url.toString(), { + maxContentLength: 5 * 1024 * 1024, + }); + return response.data as T; + } catch (error: unknown) { + throw suibetsErrorMapper.mapError(error); + } + } + + /** + * Fetches raw P2P bet offers from the SuiBets API. + * + * When `params.query` is set, filtering is applied client-side after + * fetching because the API does not support full-text search. + */ + async fetchRawMarkets(params?: MarketFilterParams): Promise { + if (params?.marketId) { + const id = params.marketId.replace(/^suibets:/, ''); + const data = await this.get<{ offer?: SuibetsRawOffer } | SuibetsRawOffer>( + `/api/p2p/offers/${id}`, + ); + const offer = + (data as { offer?: SuibetsRawOffer }).offer ?? (data as SuibetsRawOffer); + return offer ? [offer] : []; + } + + const baseParams: Record = { + status: params?.status === 'all' ? 'all' : 'OPEN', + limit: String(params?.limit ?? 50), + offset: String(params?.offset ?? 0), + }; + + const queryParams: Record = params?.eventId + ? { ...baseParams, matchId: params.eventId.replace(/^suibets:/, '') } + : { ...baseParams }; + + const data = await this.get<{ offers?: SuibetsRawOffer[] } | SuibetsRawOffer[]>( + '/api/p2p/offers', + queryParams, + ); + const offers: SuibetsRawOffer[] = + (data as { offers?: SuibetsRawOffer[] }).offers ?? + (Array.isArray(data) ? (data as SuibetsRawOffer[]) : []); + + if (!params?.query) { + return offers; + } + + // Client-side text filter: the API has no search endpoint. + const q = params.query.toLowerCase(); + return offers.filter( + o => + o.matchName?.toLowerCase().includes(q) || + o.homeTeam?.toLowerCase().includes(q) || + o.awayTeam?.toLowerCase().includes(q) || + o.sport?.toLowerCase().includes(q), + ); + } + + /** + * Fetches raw events by grouping active P2P offers by their matchId. + * + * SuiBets has no dedicated events endpoint; events are synthesised from + * the offers list so each unique match becomes one event. + */ + async fetchRawEvents(params: EventFetchParams): Promise { + const queryParams: Record = { + status: 'OPEN', + limit: String(params.limit ?? 100), + }; + + const data = await this.get<{ offers?: SuibetsRawOffer[] } | SuibetsRawOffer[]>( + '/api/p2p/offers', + queryParams, + ); + const offers: SuibetsRawOffer[] = + (data as { offers?: SuibetsRawOffer[] }).offers ?? + (Array.isArray(data) ? (data as SuibetsRawOffer[]) : []); + + // Group offers by matchId using a Map; each entry is built immutably. + const byMatch = new Map(); + for (const offer of offers) { + if (!offer.matchId) continue; + const existing = byMatch.get(offer.matchId) ?? []; + byMatch.set(offer.matchId, [...existing, offer]); + } + + const q = params.query?.toLowerCase(); + const events: SuibetsRawEvent[] = []; + + for (const [matchId, matchOffers] of byMatch) { + const first = matchOffers[0]; + + if (q) { + const matches = + first.matchName?.toLowerCase().includes(q) || + first.homeTeam?.toLowerCase().includes(q) || + first.awayTeam?.toLowerCase().includes(q) || + first.sport?.toLowerCase().includes(q); + if (!matches) continue; + } + + events.push({ + id: matchId, + name: first.matchName || `${first.homeTeam} vs ${first.awayTeam}`, + homeTeam: first.homeTeam, + awayTeam: first.awayTeam, + sport: first.sport, + leagueName: first.leagueName, + matchDate: first.matchDate, + status: 'active', + offers: matchOffers, + }); + } + + return events; + } + + /** + * Fetches raw positions (created offers, matched bets, parlays) for a + * given Sui wallet address. + */ + async fetchRawPositions(walletAddress: string): Promise { + const data = await this.get<{ + createdOffers?: unknown[]; + matchedBets?: unknown[]; + parlays?: unknown[]; + }>('/api/p2p/my', { wallet: walletAddress }); + + return [ + ...(data.createdOffers ?? []), + ...(data.matchedBets ?? []), + ...(data.parlays ?? []), + ]; + } +} diff --git a/core/src/exchanges/suibets/index.ts b/core/src/exchanges/suibets/index.ts new file mode 100644 index 00000000..e025aea3 --- /dev/null +++ b/core/src/exchanges/suibets/index.ts @@ -0,0 +1,144 @@ +import { + PredictionMarketExchange, + MarketFilterParams, + EventFetchParams, + ExchangeCredentials, +} from '../../BaseExchange'; +import { UnifiedMarket, UnifiedEvent, OrderBook, Position } from '../../types'; +import { AuthenticationError } from '../../errors'; +import { getSuibetsConfig, SuibetsApiConfig, RATE_LIMIT_MS, validateBaseUrl } from './config'; +import { SuibetsFetcher, SuibetsRawOffer } from './fetcher'; +import { SuibetsNormalizer } from './normalizer'; +import { suibetsErrorMapper } from './errors'; +import { fromOutcomeId } from './utils'; +import { FetcherContext } from '../interfaces'; + +export interface SuibetsCredentials extends ExchangeCredentials { + /** Sui wallet address for fetching personal positions */ + walletAddress?: string; + /** Override API base URL (default: https://suibets.replit.app) */ + baseUrl?: string; +} + +/** + * SuiBets — Decentralised P2P sports betting on Sui blockchain. + * + * Maps P2P bet offers to the pmxt unified market model: + * - Market = one P2P offer (creator side vs taker side) + * - Event = a sports match (groups all offers for that match) + * - Outcome = creator's pick (YES) or opposite (NO) + * - Price = implied probability derived from the offer odds + * + * Usage: + * ```ts + * import pmxt from 'pmxtjs'; + * const exchange = new pmxt.SuiBets(); + * const markets = await exchange.fetchMarkets({ limit: 20 }); + * ``` + */ +export class SuiBetsExchange extends PredictionMarketExchange { + protected override readonly capabilityOverrides = { + fetchOrderBook: 'emulated' as const, + createOrder: false as const, + cancelOrder: false as const, + fetchOrder: false as const, + fetchOpenOrders: false as const, + fetchBalance: false as const, + fetchPositions: true as const, + watchOrderBook: false as const, + watchTrades: false as const, + }; + + private readonly config: SuibetsApiConfig; + private readonly fetcher: SuibetsFetcher; + private readonly normalizer: SuibetsNormalizer; + private readonly walletAddress?: string; + + constructor(credentials?: SuibetsCredentials) { + super(credentials); + this.rateLimit = RATE_LIMIT_MS; + this.walletAddress = credentials?.walletAddress; + + if (credentials?.baseUrl) { + validateBaseUrl(credentials.baseUrl); + } + + this.config = getSuibetsConfig(credentials?.baseUrl); + + const ctx: FetcherContext = { + http: this.http, + callApi: this.callApi.bind(this), + getHeaders: () => ({}), + }; + + this.fetcher = new SuibetsFetcher(ctx, this.config.baseUrl); + this.normalizer = new SuibetsNormalizer(); + } + + get name(): string { + return 'SuiBets'; + } + + // SuiBets is a public API -- no request signing required + protected override sign(): Record { + return {}; + } + + // ------------------------------------------------------------------------- + // Market Data + // ------------------------------------------------------------------------- + + protected async fetchMarketsImpl(params?: MarketFilterParams): Promise { + const raw = await this.fetcher.fetchRawMarkets(params); + return raw + .map(r => this.normalizer.normalizeMarket(r)) + .filter((m): m is UnifiedMarket => m !== null); + } + + protected async fetchEventsImpl(params: EventFetchParams): Promise { + const raw = await this.fetcher.fetchRawEvents(params); + return raw + .map(r => this.normalizer.normalizeEvent(r)) + .filter((e): e is UnifiedEvent => e !== null); + } + + /** + * Emulated order book derived from offer odds. + * + * Bid side: what buyers pay to back the creator's pick (YES price). + * Ask side: what sellers want to take the opposite side (NO price). + */ + async fetchOrderBook(outcomeId: string): Promise { + const { offerId } = fromOutcomeId(outcomeId); + const markets = await this.fetchMarketsImpl({ marketId: `suibets:${offerId}` }); + const market = markets[0]; + if (!market) return { bids: [], asks: [], timestamp: Date.now() }; + + const yes = market.outcomes[0]; + const no = market.outcomes[1]; + const size = market.liquidity; + + return { + bids: [{ price: yes.price, size }], + asks: [{ price: no.price, size }], + timestamp: Date.now(), + }; + } + + // ------------------------------------------------------------------------- + // Positions (read-only -- requires walletAddress) + // ------------------------------------------------------------------------- + + async fetchPositions(): Promise { + const wallet = this.walletAddress; + if (!wallet) { + throw new AuthenticationError( + 'fetchPositions() requires a walletAddress. ' + + 'Pass it via new SuiBetsExchange({ walletAddress: "0x..." }).', + 'SuiBets', + ); + } + const raw = await this.fetcher.fetchRawPositions(wallet); + return raw.map(r => this.normalizer.normalizePosition(r as SuibetsRawOffer)); + } +} diff --git a/core/src/exchanges/suibets/normalizer.ts b/core/src/exchanges/suibets/normalizer.ts new file mode 100644 index 00000000..f6d4748a --- /dev/null +++ b/core/src/exchanges/suibets/normalizer.ts @@ -0,0 +1,121 @@ +import { IExchangeNormalizer } from '../interfaces'; +import { UnifiedMarket, UnifiedEvent, Position } from '../../types'; +import { SuibetsRawOffer, SuibetsRawEvent } from './fetcher'; +import { + impliedProbability, + takerProbability, + mistToSui, + sideLabel, + toMarketId, + toOutcomeId, + mapStatus, +} from './utils'; + +function liquidity(offer: SuibetsRawOffer): number { + const remaining = offer.remainingStake ?? offer.creatorStake; + return mistToSui(remaining); +} + +export class SuibetsNormalizer implements IExchangeNormalizer { + normalizeMarket(raw: SuibetsRawOffer): UnifiedMarket | null { + if (!raw?.id) return null; + + const dateSource = raw.matchDate || raw.expiresAt; + if (!dateSource) { + throw new Error(`SuibetsNormalizer: offer ${raw.id} has neither matchDate nor expiresAt`); + } + + const homeTeam = raw.homeTeam || 'Unknown Team'; + const awayTeam = raw.awayTeam || 'Unknown Team'; + + const odds = Number(raw.creatorOdds) || 2; + const yesProb = impliedProbability(odds); + const noProb = takerProbability(odds); + const liq = liquidity(raw); + const volume24h = mistToSui(raw.totalMatched ?? 0); + + const marketId = toMarketId(raw.id); + const creatorOutcome = { + outcomeId: toOutcomeId(raw.id, 'creator'), + marketId, + label: sideLabel(raw, 'creator'), + price: yesProb, + }; + const takerOutcome = { + outcomeId: toOutcomeId(raw.id, 'taker'), + marketId, + label: sideLabel(raw, 'taker'), + price: noProb, + }; + + const market: UnifiedMarket = { + marketId, + eventId: raw.matchId ? toMarketId(raw.matchId) : undefined, + title: `${raw.matchName || `${homeTeam} vs ${awayTeam}`} \u2014 ${sideLabel(raw, 'creator')} @ ${odds}x`, + description: [ + `P2P offer on ${raw.sport || 'sports'} match.`, + `Creator bets ${sideLabel(raw, 'creator')} at ${odds}x odds.`, + `Taker backs ${sideLabel(raw, 'taker')} at ${(1 / noProb).toFixed(2)}x implied odds.`, + raw.leagueName ? `League: ${raw.leagueName}.` : '', + raw.isOnchain ? `On-chain escrow: ${raw.onchainOfferId ?? 'yes'}.` : 'Off-chain escrow.', + ].filter(Boolean).join(' '), + slug: raw.id, + outcomes: [creatorOutcome, takerOutcome], + resolutionDate: new Date(dateSource), + volume24h, + liquidity: liq, + url: 'https://suibets.replit.app/p2p', + status: mapStatus(raw.status), + category: 'Sports', + tags: ['Sports', 'P2P', raw.sport, raw.leagueName].filter((t): t is string => Boolean(t)), + contractAddress: raw.onchainOfferId, + yes: creatorOutcome, + no: takerOutcome, + }; + + return market; + } + + normalizeEvent(raw: SuibetsRawEvent): UnifiedEvent | null { + if (!raw?.id) return null; + + const homeTeam = raw.homeTeam || 'Unknown Team'; + const awayTeam = raw.awayTeam || 'Unknown Team'; + + const markets: UnifiedMarket[] = (raw.offers ?? []) + .map(o => this.normalizeMarket(o)) + .filter((m): m is UnifiedMarket => m !== null); + + const totalVolume = markets.reduce((s, m) => s + (m.volume24h ?? 0), 0); + + return { + id: toMarketId(raw.id), + title: raw.name || `${homeTeam} vs ${awayTeam}`, + description: [ + raw.leagueName ? `${raw.leagueName} \u2014` : '', + raw.sport, + 'P2P betting on SuiBets.', + ].filter(Boolean).join(' '), + slug: raw.id, + markets, + volume24h: totalVolume, + volume: totalVolume, + url: 'https://suibets.replit.app/p2p', + category: 'Sports', + tags: ['Sports', 'P2P', 'Sui', raw.sport, raw.leagueName].filter((t): t is string => Boolean(t)), + }; + } + + normalizePosition(raw: SuibetsRawOffer): Position { + const odds = Number(raw.creatorOdds) || 2; + return { + marketId: toMarketId(raw.matchId ?? raw.id), + outcomeId: toOutcomeId(raw.id, 'creator'), + outcomeLabel: sideLabel(raw, 'creator'), + size: mistToSui(raw.creatorStake ?? 0), + entryPrice: impliedProbability(odds), + currentPrice: impliedProbability(odds), + unrealizedPnL: 0, + }; + } +} diff --git a/core/src/exchanges/suibets/utils.ts b/core/src/exchanges/suibets/utils.ts new file mode 100644 index 00000000..27168d14 --- /dev/null +++ b/core/src/exchanges/suibets/utils.ts @@ -0,0 +1,122 @@ +const MARKET_PREFIX = 'suibets:'; + +/** + * Build a unique market ID from a SuiBets offer ID. + * Format: "suibets:{offerId}" + */ +export function toMarketId(offerId: string): string { + return `${MARKET_PREFIX}${offerId}`; +} + +/** + * Extract the offer ID from a SuiBets market ID. + * Throws if the ID does not carry the expected prefix. + */ +export function fromMarketId(marketId: string): string { + if (!marketId.startsWith(MARKET_PREFIX)) { + throw new Error(`Invalid SuiBets market ID: ${marketId}`); + } + return marketId.slice(MARKET_PREFIX.length); +} + +/** + * Build an outcome ID that encodes both the offer ID and the side. + * Format: "{offerId}:{side}" + */ +export function toOutcomeId(offerId: string, side: 'creator' | 'taker'): string { + return `${offerId}:${side}`; +} + +/** + * Decode an outcome ID back into offerId and side. + * Throws if the format is unrecognised or the side is invalid. + */ +export function fromOutcomeId(outcomeId: string): { offerId: string; side: 'creator' | 'taker' } { + const lastColon = outcomeId.lastIndexOf(':'); + if (lastColon === -1) { + throw new Error(`Invalid SuiBets outcome ID: ${outcomeId}`); + } + const offerId = outcomeId.slice(0, lastColon); + const side = outcomeId.slice(lastColon + 1); + if (side !== 'creator' && side !== 'taker') { + throw new Error(`Invalid side in SuiBets outcome ID: ${outcomeId}`); + } + if (!offerId) { + throw new Error(`Invalid SuiBets outcome ID (empty offerId): ${outcomeId}`); + } + return { offerId, side }; +} + +/** + * Map raw SuiBets offer statuses to the pmxt unified status vocabulary. + * + * OPEN -> active + * MATCHED -> matched + * SETTLED -> settled + * EXPIRED -> expired + * CANCELLED -> cancelled + * (other) -> inactive + */ +export function mapStatus(rawStatus: string): string { + switch (rawStatus) { + case 'OPEN': return 'active'; + case 'MATCHED': return 'matched'; + case 'SETTLED': return 'settled'; + case 'EXPIRED': return 'expired'; + case 'CANCELLED': return 'cancelled'; + default: return 'inactive'; + } +} + +/** + * Convert decimal odds to an implied probability clamped to [0.01, 0.99]. + * + * Throws if odds are zero or negative. + * Throws if odds are less than 1 (invalid — a payout below stake). + * When odds === 1 (evens), returns 0.99 (clamped maximum). + */ +export function impliedProbability(decimalOdds: number): number { + if (decimalOdds <= 0) { + throw new Error(`Decimal odds must be positive, got: ${decimalOdds}`); + } + if (decimalOdds < 1) { + throw new Error(`Decimal odds below 1 are invalid, got: ${decimalOdds}`); + } + const raw = 1 / decimalOdds; + return Math.min(0.99, Math.max(0.01, raw)); +} + +/** + * Implied probability for the taker side: 1 - impliedProbability(odds), + * clamped to [0.01, 0.99]. + */ +export function takerProbability(decimalOdds: number): number { + return Math.min(0.99, Math.max(0.01, 1 - impliedProbability(decimalOdds))); +} + +/** + * Convert an amount denominated in MIST (the smallest SUI unit, 1e-9 SUI) + * to SUI by dividing by 1e9. + */ +export function mistToSui(mist: number | string): number { + return Number(mist) / 1e9; +} + +/** + * Return the human-readable team name for the given side of a P2P offer. + * + * Creator side: the team the creator bet on (creatorTeam, falling back to homeTeam). + * Taker side: the opposite team. + */ +export function sideLabel( + offer: { creatorTeam?: string; homeTeam?: string; awayTeam?: string }, + side: 'creator' | 'taker', +): string { + const creator = offer.creatorTeam || offer.homeTeam || 'Home'; + const away = offer.awayTeam || 'Away'; + if (side === 'creator') return creator; + // Taker takes the opposite side + if (creator.toLowerCase() === offer.homeTeam?.toLowerCase()) return away; + if (creator.toLowerCase() === offer.awayTeam?.toLowerCase()) return offer.homeTeam || 'Home'; + return 'Opposite'; +} diff --git a/core/src/feeds/binance/binance-feed.ts b/core/src/feeds/binance/binance-feed.ts index 9606472d..e60b2184 100644 --- a/core/src/feeds/binance/binance-feed.ts +++ b/core/src/feeds/binance/binance-feed.ts @@ -1,5 +1,6 @@ import WebSocket from 'ws'; import { logger } from '../../utils/logger'; +import { ExchangeNotAvailable, NotSupported } from '../../errors'; import { BaseDataFeed, DataFeedOptions } from '../base-feed'; import { Ticker, Tickers, OHLCV, OrderBook, Market, Dictionary } from '../types'; import { BinanceFeedConfig, BinanceRelayMessage, BinanceRelayTradeEvent, BINANCE_RELAY_DEFAULTS } from './types'; @@ -17,6 +18,14 @@ interface Subscription { export class BinanceFeed extends BaseDataFeed { readonly name = 'binance'; readonly description = 'Binance spot trade firehose via obdata relay'; + readonly has = { + loadMarkets: true, + fetchTicker: true, + fetchTickers: true, + watchTicker: true, + fetchOHLCV: false, + fetchOrderBook: false, + } as const; private readonly wsUrl: string; private readonly apiKey: string; @@ -31,7 +40,7 @@ export class BinanceFeed extends BaseDataFeed { constructor(config: BinanceFeedConfig = {}, options?: DataFeedOptions) { super(options); - this.wsUrl = config.wsUrl ?? BINANCE_RELAY_DEFAULTS.wsUrl; + this.wsUrl = config.wsUrl ?? process.env.BINANCE_RELAY_WS_URL ?? BINANCE_RELAY_DEFAULTS.wsUrl; this.apiKey = config.apiKey ?? process.env.OBDATA_API_KEY ?? ''; this.reconnectIntervalMs = config.reconnectIntervalMs ?? BINANCE_RELAY_DEFAULTS.reconnectIntervalMs; } @@ -135,11 +144,11 @@ export class BinanceFeed extends BaseDataFeed { } protected async fetchOHLCVImpl(_symbol: string, _timeframe?: string, _since?: number, _limit?: number): Promise { - throw new Error('BinanceFeed: OHLCV not available via trade relay'); + throw new NotSupported('BinanceFeed does not support fetchOHLCV via the configured trade relay.', this.name); } protected async fetchOrderBookImpl(_symbol: string, _limit?: number): Promise { - throw new Error('BinanceFeed: Order book not available via trade relay'); + throw new NotSupported('BinanceFeed does not support fetchOrderBook via the configured trade relay.', this.name); } // -- Internal -- @@ -151,11 +160,12 @@ export class BinanceFeed extends BaseDataFeed { private establishConnection(): Promise { return new Promise((resolve, reject) => { - const url = this.apiKey - ? `${this.wsUrl}?key=${this.apiKey}` - : this.wsUrl; + const relayUrl = this.validateRelayWsUrl(); + if (this.apiKey) { + relayUrl.searchParams.set('key', this.apiKey); + } - const ws = new WebSocket(url); + const ws = new WebSocket(relayUrl.toString()); const connectionTimeout = setTimeout(() => { ws.close(); @@ -234,4 +244,33 @@ export class BinanceFeed extends BaseDataFeed { } }, this.reconnectIntervalMs); } + + private validateRelayWsUrl(): URL { + const rawUrl = this.wsUrl.trim(); + if (!rawUrl) { + throw new ExchangeNotAvailable( + 'BinanceFeed requires BINANCE_RELAY_WS_URL to fetch live ticker data.', + this.name, + ); + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new ExchangeNotAvailable( + 'BinanceFeed requires BINANCE_RELAY_WS_URL to be a valid WebSocket URL.', + this.name, + ); + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new ExchangeNotAvailable( + 'BinanceFeed requires BINANCE_RELAY_WS_URL to use ws:// or wss://.', + this.name, + ); + } + + return url; + } } diff --git a/core/src/feeds/chainlink/chainlink-feed.ts b/core/src/feeds/chainlink/chainlink-feed.ts index 406ece4f..c29ebe30 100644 --- a/core/src/feeds/chainlink/chainlink-feed.ts +++ b/core/src/feeds/chainlink/chainlink-feed.ts @@ -3,6 +3,7 @@ import axios, { AxiosInstance } from 'axios'; import { BaseDataFeed, DataFeedOptions } from '../base-feed'; import { Ticker, Tickers, OHLCV, OrderBook, Market, OracleRound, OracleParams, Dictionary } from '../types'; import { logger } from '../../utils/logger'; +import { ExchangeNotAvailable, NotSupported } from '../../errors'; import { ChainlinkFeedConfig, ChainlinkLatestPricesResponse, @@ -32,7 +33,19 @@ interface Subscription { export class ChainlinkFeed extends BaseDataFeed { readonly name = 'chainlink'; readonly description = 'Chainlink price feeds (ETH, BTC, XRP, SOL) on Polygon via pmxt-ohlc'; - + readonly has = { + loadMarkets: true, + fetchTicker: true, + fetchTickers: true, + watchTicker: true, + fetchOHLCV: false, + fetchOrderBook: false, + fetchOracleRound: true, + fetchOracleHistory: true, + fetchHistoricalPrices: true, + } as const; + + private readonly baseUrl: string; private readonly client: AxiosInstance; private readonly wsUrl: string; private readonly wsApiKey: string; @@ -47,13 +60,14 @@ export class ChainlinkFeed extends BaseDataFeed { constructor(config: ChainlinkFeedConfig, options?: DataFeedOptions) { super(options); - const baseURL = config.baseUrl ?? CHAINLINK_DEFAULTS.baseUrl; + const baseURL = config.baseUrl ?? process.env.CHAINLINK_API_URL ?? CHAINLINK_DEFAULTS.baseUrl; + this.baseUrl = baseURL; this.client = axios.create({ baseURL, headers: { 'X-API-Key': config.apiKey }, timeout: 10_000, }); - this.wsUrl = config.wsUrl ?? CHAINLINK_DEFAULTS.wsUrl; + this.wsUrl = config.wsUrl ?? process.env.CHAINLINK_WS_URL ?? CHAINLINK_DEFAULTS.wsUrl; this.wsApiKey = config.wsApiKey ?? config.apiKey; this.reconnectIntervalMs = config.reconnectIntervalMs ?? CHAINLINK_DEFAULTS.reconnectIntervalMs; } @@ -113,6 +127,7 @@ export class ChainlinkFeed extends BaseDataFeed { protected async fetchTickerImpl(symbol: string): Promise { const cached = this.latestTickers.get(symbol.toUpperCase()); if (cached) return cached; + this.ensureRestConfigured(); const token = TOKEN_BY_PAIR.get(symbol.toUpperCase()); if (!token) { @@ -137,6 +152,7 @@ export class ChainlinkFeed extends BaseDataFeed { // -- CCXT: fetchTickers -- protected async fetchTickersImpl(symbols?: string[]): Promise { + this.ensureRestConfigured(); const { data } = await this.client.get( '/v1/chainlink/latest-prices', ); @@ -176,21 +192,23 @@ export class ChainlinkFeed extends BaseDataFeed { // -- CCXT: fetchOHLCV (not supported) -- protected async fetchOHLCVImpl(_symbol: string, _timeframe?: string, _since?: number, _limit?: number): Promise { - throw new Error( + throw new NotSupported( 'Chainlink feed does not provide OHLCV candles. ' + 'Use fetchOracleHistory() for raw AnswerUpdated records.', + this.name, ); } // -- CCXT: fetchOrderBook (not applicable) -- protected async fetchOrderBookImpl(_symbol: string, _limit?: number): Promise { - throw new Error('Chainlink oracle feeds do not have order books.'); + throw new NotSupported('Chainlink oracle feeds do not have order books.', this.name); } // -- pmxt extensions: Oracle -- async fetchOracleRound(params: OracleParams): Promise { + this.ensureRestConfigured(); const token = TOKEN_BY_PAIR.get(params.feed.toUpperCase()); if (!token) { throw new Error(`Unsupported Chainlink feed: ${params.feed}. Supported: ${SUPPORTED_TOKENS.map((t) => t.pair).join(', ')}`); @@ -209,6 +227,7 @@ export class ChainlinkFeed extends BaseDataFeed { } async fetchOracleHistory(params: OracleParams): Promise { + this.ensureRestConfigured(); const token = TOKEN_BY_PAIR.get(params.feed.toUpperCase()); if (!token) { throw new Error(`Unsupported Chainlink feed: ${params.feed}. Supported: ${SUPPORTED_TOKENS.map((t) => t.pair).join(', ')}`); @@ -231,6 +250,7 @@ export class ChainlinkFeed extends BaseDataFeed { order?: 'asc' | 'desc'; }, ): Promise { + this.ensureRestConfigured(); const token = TOKEN_BY_PAIR.get(symbol.toUpperCase()); if (!token) { throw new Error(`Unsupported Chainlink symbol: ${symbol}. Supported: ${SUPPORTED_TOKENS.map((t) => t.pair).join(', ')}`); @@ -257,10 +277,41 @@ export class ChainlinkFeed extends BaseDataFeed { await this.connect(); } + private ensureRestConfigured(): void { + const rawUrl = this.baseUrl.trim(); + if (!rawUrl) { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_API_URL to fetch oracle data.', + this.name, + ); + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_API_URL to be a valid HTTP URL.', + this.name, + ); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_API_URL to use http:// or https://.', + this.name, + ); + } + } + private establishConnection(): Promise { return new Promise((resolve, reject) => { - const url = `${this.wsUrl}?key=${this.wsApiKey}`; - const ws = new WebSocket(url); + const wsUrl = this.validateWsUrl(); + if (this.wsApiKey) { + wsUrl.searchParams.set('key', this.wsApiKey); + } + + const ws = new WebSocket(wsUrl.toString()); const connectionTimeout = setTimeout(() => { ws.close(); @@ -298,6 +349,35 @@ export class ChainlinkFeed extends BaseDataFeed { }); } + private validateWsUrl(): URL { + const rawUrl = this.wsUrl.trim(); + if (!rawUrl) { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_WS_URL to stream live oracle data.', + this.name, + ); + } + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_WS_URL to be a valid WebSocket URL.', + this.name, + ); + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new ExchangeNotAvailable( + 'ChainlinkFeed requires CHAINLINK_WS_URL to use ws:// or wss://.', + this.name, + ); + } + + return url; + } + private handleMessage(data: WebSocket.Data): void { const text = typeof data === 'string' ? data : data.toString(); diff --git a/core/src/feeds/interfaces.ts b/core/src/feeds/interfaces.ts index b8d1438b..09374bb9 100644 --- a/core/src/feeds/interfaces.ts +++ b/core/src/feeds/interfaces.ts @@ -4,9 +4,27 @@ import { Ticker, Tickers, OHLCV, OrderBook, Market, FundingRate, FundingRates, O // Data Feed Interface — CCXT-compatible method signatures. // ---------------------------------------------------------------------------- +export type DataFeedCapability = + | 'loadMarkets' + | 'fetchTicker' + | 'fetchTickers' + | 'watchTicker' + | 'fetchOHLCV' + | 'fetchOrderBook' + | 'watchOrderBook' + | 'fetchFundingRate' + | 'fetchFundingRates' + | 'fetchOracleRound' + | 'fetchOracleHistory' + | 'fetchHistoricalPrices'; + +export type DataFeedCapabilityValue = true | false | 'emulated'; +export type DataFeedCapabilities = Readonly>>; + export interface IDataFeed { readonly name: string; readonly description: string; + readonly has?: DataFeedCapabilities; // -- CCXT unified methods -- @@ -24,6 +42,15 @@ export interface IDataFeed { fetchOracleRound?(params: OracleParams): Promise; fetchOracleHistory?(params: OracleParams): Promise; + fetchHistoricalPrices?( + symbol: string, + opts?: { + fromTimestamp?: number; + untilTimestamp?: number; + maxSize?: number; + order?: 'asc' | 'desc'; + }, + ): Promise; // -- Lifecycle -- diff --git a/core/src/index.ts b/core/src/index.ts index bd1ba46d..a274c5b8 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -17,6 +17,7 @@ export * from './exchanges/smarkets'; export * from './exchanges/polymarket_us'; export * from './exchanges/hyperliquid'; export * from './exchanges/gemini-titan'; +export * from './exchanges/suibets'; export * from './router'; export * from './feeds'; export * from './server/app'; @@ -37,6 +38,7 @@ import { SmarketsExchange } from './exchanges/smarkets'; import { PolymarketUSExchange } from './exchanges/polymarket_us'; import { HyperliquidExchange } from './exchanges/hyperliquid'; import { GeminiTitanExchange } from './exchanges/gemini-titan'; +import { SuiBetsExchange } from './exchanges/suibets'; import { Router } from './router'; const pmxt = { @@ -54,6 +56,7 @@ const pmxt = { PolymarketUS: PolymarketUSExchange, Hyperliquid: HyperliquidExchange, GeminiTitan: GeminiTitanExchange, + SuiBets: SuiBetsExchange, Router, }; @@ -71,5 +74,6 @@ export const Smarkets = SmarketsExchange; export const PolymarketUS = PolymarketUSExchange; export const Hyperliquid = HyperliquidExchange; export const GeminiTitan = GeminiTitanExchange; +export const SuiBets = SuiBetsExchange; export default pmxt; diff --git a/core/src/router/Router.ts b/core/src/router/Router.ts index 65eaca83..ab848833 100644 --- a/core/src/router/Router.ts +++ b/core/src/router/Router.ts @@ -4,6 +4,7 @@ import { type MarketFetchParams, type EventFetchParams, } from '../BaseExchange'; +import { BaseError, EventNotFound, MarketNotFound } from '../errors'; import type { UnifiedMarket, UnifiedEvent, OrderBook, OrderLevel, MarketOutcome } from '../types'; import { logger } from '../utils/logger'; import { PmxtApiClient } from './client'; @@ -53,14 +54,98 @@ function mergeOrderBooks(books: OrderBook[]): OrderBook { // --------------------------------------------------------------------------- +const MOCK_MARKET_OR_OUTCOME_ID_RE = /^(mock-m\d+)(?:-(?:yes|no|\d+))?$/; +const MOCK_EVENT_ID_RE = /^mock-event-\d+$/; + +class LocalRouterMatchLookupUnsupported extends BaseError { + constructor(kind: 'market' | 'event', identifier: string) { + super( + `LOCAL_MATCH_LOOKUP_UNSUPPORTED: Router match lookup for local mock ${kind} ` + + `"${identifier}" cannot be served by the hosted match catalog. ` + + `Configure RouterOptions.localExchanges.mock when using Router in-process; ` + + `the /api/router sidecar endpoint resolves bundled mock IDs locally and returns [] because mock fixtures have no hosted cross-venue matches.`, + 501, + 'LOCAL_MATCH_LOOKUP_UNSUPPORTED', + false, + 'Router', + ); + } +} + +function sourceExchangeIsMock(sourceExchange: unknown): boolean { + return typeof sourceExchange === 'string' && sourceExchange.toLowerCase() === 'mock'; +} + +function lookupString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function mockMarketIdFromLocalId(id: unknown): string | undefined { + const value = lookupString(id); + if (!value) return undefined; + return value.match(MOCK_MARKET_OR_OUTCOME_ID_RE)?.[1]; +} + +function isMockEventId(id: unknown): boolean { + const value = lookupString(id); + return value !== undefined && MOCK_EVENT_ID_RE.test(value); +} + +function isMockUrl(url: unknown, resource: 'market' | 'event'): boolean { + const value = lookupString(url); + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.hostname === 'mock.pmxt.dev' && parsed.pathname.startsWith(`/${resource}/`); + } catch { + return value.includes(`mock.pmxt.dev/${resource}/`); + } +} + +function describeMarketLookup(params: FetchMarketMatchesParams): string { + return params.market?.marketId + ?? lookupString(params.marketId) + ?? lookupString(params.slug) + ?? lookupString(params.url) + ?? 'unknown'; +} + +function describeEventLookup(params: FetchEventMatchesParams): string { + return params.event?.id + ?? lookupString(params.eventId) + ?? lookupString(params.slug) + ?? 'unknown'; +} + +function isLocalMockMarketLookup(params: FetchMarketMatchesParams): boolean { + return sourceExchangeIsMock(params.market?.sourceExchange) + || mockMarketIdFromLocalId(params.market?.marketId) !== undefined + || mockMarketIdFromLocalId(params.marketId) !== undefined + || mockMarketIdFromLocalId(params.slug) !== undefined + || isMockUrl(params.url, 'market'); +} + +function isLocalMockEventLookup(params: FetchEventMatchesParams): boolean { + return sourceExchangeIsMock(params.event?.sourceExchange) + || isMockEventId(params.event?.id) + || isMockEventId(params.eventId) + || isMockEventId(params.slug); +} + +function findByUrl(items: T[], url: string): T | undefined { + return items.find((item) => item.url === url); +} + export class Router extends PredictionMarketExchange { private readonly client: PmxtApiClient; private readonly exchanges: Record; + private readonly localExchanges: Record; constructor(options: RouterOptions) { super({ apiKey: options.apiKey } as ExchangeCredentials); this.client = new PmxtApiClient(options.apiKey, options.baseUrl); this.exchanges = options.exchanges ?? {}; + this.localExchanges = options.localExchanges ?? options.exchanges ?? {}; this.rateLimit = 100; } @@ -180,7 +265,9 @@ export class Router extends PredictionMarketExchange { async fetchMarketMatches(params: FetchMarketMatchesParams = {}): Promise { if (params.market && !params.marketId) { - if (params.market.slug && !params.slug) { + if (sourceExchangeIsMock(params.market.sourceExchange)) { + params = { ...params, marketId: params.market.marketId }; + } else if (params.market.slug && !params.slug) { params = { ...params, slug: params.market.slug }; } else { params = { ...params, marketId: params.market.marketId }; @@ -194,6 +281,10 @@ export class Router extends PredictionMarketExchange { return this.fetchMarketMatchesBrowse(params); } + if (await this.resolveLocalMockMarketLookup(params)) { + return []; + } + // Lookup mode: find matches for a specific market. const response = await this.client.getMarketMatches(params); const matches = response.matches ?? []; @@ -233,7 +324,9 @@ export class Router extends PredictionMarketExchange { async fetchEventMatches(params: FetchEventMatchesParams = {}): Promise { if (params.event && !params.eventId) { - if (params.event.slug && !params.slug) { + if (sourceExchangeIsMock(params.event.sourceExchange)) { + params = { ...params, eventId: params.event.id }; + } else if (params.event.slug && !params.slug) { params = { ...params, slug: params.event.slug }; } else { params = { ...params, eventId: params.event.id }; @@ -247,6 +340,10 @@ export class Router extends PredictionMarketExchange { return Array.isArray(results) ? results : []; } + if (await this.resolveLocalMockEventLookup(params)) { + return []; + } + // Lookup mode: find matches for a specific event. const response = await this.client.getEventMatches(params); return response.matches ?? []; @@ -354,6 +451,88 @@ export class Router extends PredictionMarketExchange { return this.fetchArbitrageInternal(params); } + private getLocalExchange(name: string): PredictionMarketExchange | undefined { + const target = name.toLowerCase(); + for (const [key, exchange] of Object.entries(this.localExchanges)) { + if (key.toLowerCase() === target || exchange.name.toLowerCase() === target) { + return exchange; + } + } + return undefined; + } + + /** + * Local mock IDs are sidecar-only fixtures. Hosted match endpoints do not + * know them, so resolve them locally when possible and avoid opaque hosted + * "not found" responses. + */ + private async resolveLocalMockMarketLookup(params: FetchMarketMatchesParams): Promise { + if (!isLocalMockMarketLookup(params)) return false; + + const identifier = describeMarketLookup(params); + const mock = this.getLocalExchange('mock'); + if (!mock) { + throw new LocalRouterMatchLookupUnsupported('market', identifier); + } + + if (params.market && sourceExchangeIsMock(params.market.sourceExchange)) { + return true; + } + + const localMarketId = + mockMarketIdFromLocalId(params.marketId) + ?? mockMarketIdFromLocalId(params.slug); + if (localMarketId) { + await mock.fetchMarket({ marketId: localMarketId }); + return true; + } + + const url = lookupString(params.url); + if (url && isMockUrl(url, 'market')) { + const markets = await mock.fetchMarkets(); + if (!findByUrl(markets, url)) { + throw new MarketNotFound(url, mock.name); + } + return true; + } + + return true; + } + + private async resolveLocalMockEventLookup(params: FetchEventMatchesParams): Promise { + if (!isLocalMockEventLookup(params)) return false; + + const identifier = describeEventLookup(params); + const mock = this.getLocalExchange('mock'); + if (!mock) { + throw new LocalRouterMatchLookupUnsupported('event', identifier); + } + + if (params.event && sourceExchangeIsMock(params.event.sourceExchange)) { + return true; + } + + const localEventId = + isMockEventId(params.eventId) ? params.eventId + : isMockEventId(params.slug) ? params.slug + : undefined; + if (localEventId) { + await mock.fetchEvent({ eventId: localEventId }); + return true; + } + + const url = lookupString((params as FetchEventMatchesParams & { url?: string }).url); + if (url && isMockUrl(url, 'event')) { + const events = await mock.fetchEvents(); + if (!findByUrl(events, url)) { + throw new EventNotFound(url, mock.name); + } + return true; + } + + return true; + } + private async fetchArbitrageInternal(params?: FetchArbitrageParams): Promise { // Try the dedicated bulk endpoint first (single DB query). try { diff --git a/core/src/router/types.ts b/core/src/router/types.ts index 59f8a071..c9f3032a 100644 --- a/core/src/router/types.ts +++ b/core/src/router/types.ts @@ -16,6 +16,11 @@ export interface RouterOptions { baseUrl?: string; /** Exchange instances for cross-venue orderbook aggregation. Keyed by exchange name (e.g. 'polymarket', 'kalshi'). */ exchanges?: Record; + /** + * Local exchange instances used only to resolve sidecar-only fixture IDs + * before hosted catalog match lookups. Does not affect orderbook routing. + */ + localExchanges?: Record; } // --------------------------------------------------------------------------- diff --git a/core/src/server/app.ts b/core/src/server/app.ts index d00dc07d..643b2abc 100644 --- a/core/src/server/app.ts +++ b/core/src/server/app.ts @@ -2,9 +2,13 @@ import express, { Express, Request, Response, NextFunction } from "express"; import cors from "cors"; import fs from "fs"; import path from "path"; +import { Server as HttpServer } from "http"; import { createWebSocketHandler, CreateWebSocketHandlerOptions } from "./ws-handler"; import { createExchange } from "./exchange-factory"; -import { ExchangeCredentials } from "../BaseExchange"; +import { createFeedRouter } from "./feed-routes"; +import { createSqlRouter } from "./sql-route"; +import { ExchangeCredentials, PredictionMarketExchange } from "../BaseExchange"; +import { Router } from "../router"; import { BaseError } from "../errors"; import { logger } from "../utils/logger"; @@ -154,8 +158,16 @@ const defaultExchanges: Record = { opinion: null, metaculus: null, smarkets: null, + mock: null, }; +function getDefaultExchange(exchangeName: string): any { + if (!defaultExchanges[exchangeName]) { + defaultExchanges[exchangeName] = createExchange(exchangeName); + } + return defaultExchanges[exchangeName]; +} + /** * Options accepted by {@link createApp}. */ @@ -191,12 +203,25 @@ export interface CreateAppOptions { * wrap the sidecar in their own auth / quota / usage middleware and serve * it as part of a larger Express application. * - * The returned app registers: + * The returned app registers HTTP routes only: * - `GET /health` * - (optional) the built-in `x-pmxt-access-token` auth check * - `POST /api/:exchange/:method` * - the error handler * + * WebSocket upgrades do not pass through Express routing. Local servers + * created from this app can expose `/ws` by attaching the WebSocket endpoint + * to the underlying HTTP server: + * + * ```ts + * import { createApp, attachWebSocketEndpoint } from 'pmxt-core'; + * + * const accessToken = process.env.PMXT_ACCESS_TOKEN; + * const app = createApp({ accessToken }); + * const server = app.listen(4000, "127.0.0.1"); + * attachWebSocketEndpoint(server, { accessToken }); + * ``` + * * Usage: * ```ts * import express from 'express'; @@ -239,6 +264,11 @@ export function createApp(options: CreateAppOptions = {}): Express { }); } + app.use("/v0/sql", createSqlRouter()); + // Mount before /api/:exchange/:method so "feeds" is not interpreted as + // an exchange name by the generic dispatcher. + app.use("/api/feeds", createFeedRouter()); + // Shared dispatch used by both GET and POST handlers below. Given the // method name, the positional args, and optional credentials, it // resolves the exchange instance (singleton or per-request) and @@ -261,7 +291,12 @@ export function createApp(options: CreateAppOptions = {}): Express { // different key, so Router is never cached as a singleton. const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, "") || ""; - exchange = createExchange(exchangeName, undefined, bearer); + exchange = new Router({ + apiKey: bearer, + localExchanges: { + mock: getDefaultExchange("mock") as PredictionMarketExchange, + }, + }); } else if ( credentials && (credentials.privateKey || @@ -270,10 +305,7 @@ export function createApp(options: CreateAppOptions = {}): Express { ) { exchange = createExchange(exchangeName, credentials); } else { - if (!defaultExchanges[exchangeName]) { - defaultExchanges[exchangeName] = createExchange(exchangeName); - } - exchange = defaultExchanges[exchangeName]; + exchange = getDefaultExchange(exchangeName); } if (req.headers["x-pmxt-verbose"] === "true") { @@ -408,6 +440,25 @@ export function createApp(options: CreateAppOptions = {}): Express { return app; } +export type WebSocketEndpoint = ReturnType; + +/** + * Attach the PMXT streaming WebSocket endpoint to an HTTP server. + * + * Use this with servers built from `createApp()` when you need `/ws` support + * for watchOrderBook, watchOrderBooks, or watchTrades. The access token should + * match the one passed to `createApp()` so HTTP and WebSocket requests share + * the same local auth policy. + */ +export function attachWebSocketEndpoint( + server: HttpServer, + options: CreateWebSocketHandlerOptions = {}, +): WebSocketEndpoint { + const wsHandler = createWebSocketHandler(options); + wsHandler.attach(server); + return wsHandler; +} + /** * Start the PMXT sidecar server on the given port with the built-in * access-token auth middleware enabled. Returns the underlying @@ -420,9 +471,7 @@ export async function startServer(port: number, accessToken: string) { const app = createApp({ accessToken }); const server = app.listen(port, "127.0.0.1"); - // Attach WebSocket handler for streaming subscriptions - const wsHandler = createWebSocketHandler({ accessToken }); - wsHandler.attach(server); + attachWebSocketEndpoint(server, { accessToken }); return server; } diff --git a/core/src/server/exchange-factory.ts b/core/src/server/exchange-factory.ts index ddd82b87..db679feb 100644 --- a/core/src/server/exchange-factory.ts +++ b/core/src/server/exchange-factory.ts @@ -12,6 +12,7 @@ import { SmarketsExchange } from "../exchanges/smarkets"; import { PolymarketUSExchange } from "../exchanges/polymarket_us"; import { HyperliquidExchange } from "../exchanges/hyperliquid"; import { GeminiTitanExchange } from "../exchanges/gemini-titan"; +import { SuiBetsExchange } from "../exchanges/suibets"; import { MockExchange } from "../exchanges/mock"; import { Router } from "../router"; @@ -127,6 +128,13 @@ export function createExchange( apiSecret: credentials?.apiSecret || process.env.GEMINI_API_SECRET, }); + case "suibets": + return new SuiBetsExchange({ + walletAddress: + (credentials as { walletAddress?: string })?.walletAddress || process.env.SUIBETS_WALLET_ADDRESS, + baseUrl: + credentials?.baseUrl || process.env.SUIBETS_BASE_URL, + }); case "mock": return new MockExchange(); case "router": diff --git a/core/src/server/feed-routes.ts b/core/src/server/feed-routes.ts index aa23cebc..67895f8a 100644 --- a/core/src/server/feed-routes.ts +++ b/core/src/server/feed-routes.ts @@ -1,5 +1,6 @@ import { Router, Request, Response, NextFunction } from 'express'; import { getFeed, AVAILABLE_FEEDS } from './feed-factory'; +import type { DataFeedCapability, IDataFeed } from '../feeds/interfaces'; /** * Express router for data feed endpoints — CCXT-compatible method names. @@ -60,6 +61,7 @@ export function createFeedRouter(): Router { // GET /api/feeds/:feed/fetchOHLCV?symbol=BTC/USDT&timeframe=1h&since=...&limit=... router.get('/:feed/fetchOHLCV', async (req: Request, res: Response, next: NextFunction) => { try { + if (sendUnsupportedIfNeeded(req, res, 'fetchOHLCV')) return; const symbol = req.query.symbol; if (typeof symbol !== 'string') { res.status(400).json({ success: false, error: 'Missing required query parameter: symbol' }); @@ -84,10 +86,8 @@ export function createFeedRouter(): Router { router.get('/:feed/fetchOrderBook', async (req: Request, res: Response, next: NextFunction) => { try { const feed = (req as any)._feed; - if (typeof feed.fetchOrderBook !== 'function') { - res.status(501).json({ success: false, error: `Feed '${req.params.feed}' does not support fetchOrderBook` }); - return; - } + if (sendUnsupportedIfNeeded(req, res, 'fetchOrderBook')) return; + if (typeof feed.fetchOrderBook !== 'function') return sendUnsupported(res, getFeedParam(req), 'fetchOrderBook'); const symbol = req.query.symbol; if (typeof symbol !== 'string') { res.status(400).json({ success: false, error: 'Missing required query parameter: symbol' }); @@ -102,10 +102,8 @@ export function createFeedRouter(): Router { router.get('/:feed/fetchOracleRound', async (req: Request, res: Response, next: NextFunction) => { try { const feed = (req as any)._feed; - if (typeof feed.fetchOracleRound !== 'function') { - res.status(501).json({ success: false, error: `Feed '${req.params.feed}' does not support fetchOracleRound` }); - return; - } + if (sendUnsupportedIfNeeded(req, res, 'fetchOracleRound')) return; + if (typeof feed.fetchOracleRound !== 'function') return sendUnsupported(res, getFeedParam(req), 'fetchOracleRound'); const feedName = req.query.feed; if (typeof feedName !== 'string') { res.status(400).json({ success: false, error: 'Missing required query parameter: feed' }); @@ -120,10 +118,8 @@ export function createFeedRouter(): Router { router.get('/:feed/fetchOracleHistory', async (req: Request, res: Response, next: NextFunction) => { try { const feed = (req as any)._feed; - if (typeof feed.fetchOracleHistory !== 'function') { - res.status(501).json({ success: false, error: `Feed '${req.params.feed}' does not support fetchOracleHistory` }); - return; - } + if (sendUnsupportedIfNeeded(req, res, 'fetchOracleHistory')) return; + if (typeof feed.fetchOracleHistory !== 'function') return sendUnsupported(res, getFeedParam(req), 'fetchOracleHistory'); const feedName = req.query.feed; if (typeof feedName !== 'string') { res.status(400).json({ success: false, error: 'Missing required query parameter: feed' }); @@ -141,10 +137,8 @@ export function createFeedRouter(): Router { router.get('/:feed/fetchHistoricalPrices', async (req: Request, res: Response, next: NextFunction) => { try { const feed = (req as any)._feed; - if (typeof feed.fetchHistoricalPrices !== 'function') { - res.status(501).json({ success: false, error: `Feed '${req.params.feed}' does not support fetchHistoricalPrices` }); - return; - } + if (sendUnsupportedIfNeeded(req, res, 'fetchHistoricalPrices')) return; + if (typeof feed.fetchHistoricalPrices !== 'function') return sendUnsupported(res, getFeedParam(req), 'fetchHistoricalPrices'); const symbol = req.query.symbol; if (typeof symbol !== 'string') { res.status(400).json({ success: false, error: 'Missing required query parameter: symbol' }); @@ -167,3 +161,26 @@ export function createFeedRouter(): Router { return router; } + +function getRequestFeed(req: Request): IDataFeed { + return (req as any)._feed as IDataFeed; +} + +function sendUnsupportedIfNeeded(req: Request, res: Response, method: DataFeedCapability): boolean { + const feed = getRequestFeed(req); + if (feed.has?.[method] !== false) return false; + sendUnsupported(res, feed.name || getFeedParam(req), method); + return true; +} + +function sendUnsupported(res: Response, feedName: string, method: string): void { + res.status(501).json({ + success: false, + error: `Feed '${feedName}' does not support ${method}`, + }); +} + +function getFeedParam(req: Request): string { + const value = req.params.feed; + return Array.isArray(value) ? value[0] ?? 'unknown' : value; +} diff --git a/core/src/server/openapi.yaml b/core/src/server/openapi.yaml index e00e81e6..e2b1913f 100644 --- a/core/src/server/openapi.yaml +++ b/core/src/server/openapi.yaml @@ -27,6 +27,149 @@ paths: timestamp: type: integer format: int64 + /v0/sql: + post: + summary: Execute a read-only SQL query against ClickHouse + operationId: postV0Sql + description: >- + Enterprise SQL endpoint. The local sidecar accepts the same `{ query }` + body shape as hosted PMXT. It returns `503` unless + `CLICKHOUSE_HTTP_URL` is configured. + tags: + - SQL + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + example: SELECT * FROM markets LIMIT 10 + responses: + '200': + description: Query executed successfully + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + meta: + type: object + properties: + columns: + type: array + items: + type: object + properties: + name: + type: string + type: + type: string + rows: + type: integer + statistics: + type: object + '400': + description: Invalid or disallowed query + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: query_error + message: + type: string + '403': + description: Enterprise plan required + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: sql_access_denied + message: + type: string + example: SQL query access requires an Enterprise plan + '408': + description: Query timed out + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: query_timeout + message: + type: string + example: Query exceeded the maximum execution time (5s) + '502': + description: ClickHouse connection failed + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: database_error + message: + type: string + example: Database connection failed + '503': + description: ClickHouse not configured + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: service_unavailable + message: + type: string + example: SQL query service is not available + get: + summary: Execute a read-only SQL query against ClickHouse (query param) + operationId: getV0Sql + description: >- + Curl-friendly variant of Enterprise SQL. The local sidecar returns + `503` unless `CLICKHOUSE_HTTP_URL` is configured. + tags: + - SQL + parameters: + - in: query + name: query + required: true + schema: + type: string + description: SQL query to execute + example: SELECT * FROM markets LIMIT 10 + responses: + '200': + $ref: '#/paths/~1v0~1sql/post/responses/200' + '400': + $ref: '#/paths/~1v0~1sql/post/responses/400' + '403': + $ref: '#/paths/~1v0~1sql/post/responses/403' + '408': + $ref: '#/paths/~1v0~1sql/post/responses/408' + '502': + $ref: '#/paths/~1v0~1sql/post/responses/502' + '503': + $ref: '#/paths/~1v0~1sql/post/responses/503' '/api/{exchange}/loadMarkets': post: summary: Load Markets @@ -2780,6 +2923,7 @@ components: - polymarket_us - gemini-titan - hyperliquid + - suibets - mock - router required: true diff --git a/core/src/server/sql-route.ts b/core/src/server/sql-route.ts new file mode 100644 index 00000000..caef62d2 --- /dev/null +++ b/core/src/server/sql-route.ts @@ -0,0 +1,341 @@ +import { NextFunction, Request, Response, Router } from "express"; + +interface SqlColumn { + name: string; + type: string; +} + +interface ClickHouseJsonResult { + meta?: SqlColumn[]; + data?: Record[]; + rows?: number; + statistics?: Record; + exception?: string; +} + +interface ClickHouseError extends Error { + chStatusCode?: number; +} + +type AccountLike = { + plan_name?: unknown; + plan?: unknown; +}; + +type SqlRequest = Request & { + account?: AccountLike; +}; + +const MAX_QUERY_LENGTH = 10_000; + +const ALLOWED_FIRST_KEYWORDS = new Set([ + "SELECT", + "WITH", + "SHOW", + "DESCRIBE", + "DESC", + "EXISTS", + "EXPLAIN", +]); + +const WRITE_KEYWORDS = [ + "INSERT", + "CREATE", + "DROP", + "ALTER", + "DELETE", + "TRUNCATE", + "ATTACH", + "DETACH", + "GRANT", + "REVOKE", + "KILL", + "RENAME", +]; + +const BLOCKED_FUNCTIONS = new Set([ + "hostname", + "fqdn", + "displayname", + "version", + "buildid", + "serveruuid", + "uptime", + "tcpport", + "currentuser", + "user", + "currentdatabase", + "currentprofiles", + "enabledprofiles", + "defaultprofiles", + "currentroles", + "enabledroles", + "defaultroles", + "filesystemavailable", + "filesystemcapacity", + "filesystemunreserved", + "getsetting", + "getmacro", +]); + +const BLOCKED_DB_PREFIXES = ["system.", "information_schema."]; + +function stripComments(sql: string): string { + return sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/--[^\n]*/g, " ") + .trim(); +} + +function stripStrings(sql: string): string { + return sql.replace(/'(?:[^'\\]|\\.)*'/g, "''"); +} + +function validateSqlQuery(sql: unknown): { valid: true; query: string } | { valid: false; error: string } { + if (typeof sql !== "string") { + return { valid: false, error: "query is required" }; + } + + const trimmed = sql.trim(); + if (trimmed.length === 0) { + return { valid: false, error: "query is required" }; + } + + if (trimmed.length > MAX_QUERY_LENGTH) { + return { + valid: false, + error: `query exceeds maximum length (${MAX_QUERY_LENGTH} chars)`, + }; + } + + const withoutStrings = stripStrings(trimmed); + if (withoutStrings.includes(";")) { + return { valid: false, error: "multi-statement queries are not allowed" }; + } + + const stripped = stripComments(trimmed); + const firstWord = (stripped.split(/\s+/)[0] || "").toUpperCase(); + if (!ALLOWED_FIRST_KEYWORDS.has(firstWord)) { + return { + valid: false, + error: + `query type "${firstWord}" is not allowed - only SELECT, WITH, ` + + "SHOW, DESCRIBE, and EXPLAIN are permitted", + }; + } + + if (firstWord === "SHOW") { + const upper = stripped.toUpperCase(); + if (/^SHOW\s+(GRANTS|CREATE|ACCESS|PROCESSLIST|SETTINGS)/.test(upper)) { + return { valid: false, error: "that SHOW command is not allowed" }; + } + } + + const safeText = stripStrings(stripped); + for (const keyword of WRITE_KEYWORDS) { + if (new RegExp(`\\b${keyword}\\b`, "i").test(safeText)) { + return { valid: false, error: `"${keyword}" is not allowed in queries` }; + } + } + + const lower = safeText.toLowerCase(); + for (const prefix of BLOCKED_DB_PREFIXES) { + if (lower.includes(prefix)) { + return { + valid: false, + error: "queries against system tables are not allowed", + }; + } + } + + const funcCalls = lower.matchAll(/\b([a-z_][a-z0-9_]*)\s*\(/g); + for (const match of funcCalls) { + if (BLOCKED_FUNCTIONS.has(match[1])) { + return { valid: false, error: `function "${match[1]}" is not allowed` }; + } + } + + return { valid: true, query: trimmed }; +} + +function allowedPlans(): Set { + return new Set( + (process.env.SQL_ALLOWED_PLANS || "Enterprise") + .split(",") + .map((plan) => plan.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function hasSqlAccess(req: SqlRequest): boolean { + if (!req.account) return true; + const plan = req.account.plan_name ?? req.account.plan ?? ""; + return typeof plan === "string" && allowedPlans().has(plan.toLowerCase()); +} + +function isConfigured(): boolean { + return Boolean(process.env.CLICKHOUSE_HTTP_URL); +} + +function scrubErrorMessage(message: string): string { + const user = process.env.CLICKHOUSE_SQL_USER || "readonly"; + const escapedUser = user.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return message + .replace(/\s*\(version\s+[\d.]+ \(official build\)\)/gi, "") + .replace(new RegExp(`${escapedUser}:\\s*`, "g"), ""); +} + +function extractErrorMessage(body: unknown): string { + if (typeof body !== "string") return String(body); + const trimmed = body.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed) as { exception?: string; error?: string }; + return scrubErrorMessage(parsed.exception || parsed.error || trimmed); + } catch { + // Fall through to plain-text handling. + } + } + return scrubErrorMessage(trimmed.split("\n")[0] || "ClickHouse query failed"); +} + +async function queryClickHouse(sql: string): Promise { + const baseUrl = process.env.CLICKHOUSE_HTTP_URL; + if (!baseUrl) { + throw Object.assign(new Error("SQL query service is not available"), { + chStatusCode: 503, + }); + } + + let url: URL; + try { + url = new URL("/", baseUrl); + } catch { + throw Object.assign(new Error("CLICKHOUSE_HTTP_URL is not a valid URL"), { + chStatusCode: 503, + }); + } + + url.searchParams.set("default_format", "JSON"); + url.searchParams.set("readonly", "1"); + url.searchParams.set("allow_ddl", "0"); + url.searchParams.set("allow_introspection_functions", "0"); + url.searchParams.set("max_execution_time", "5"); + url.searchParams.set("max_result_rows", "10000"); + + const user = process.env.CLICKHOUSE_SQL_USER || "readonly"; + const password = process.env.CLICKHOUSE_SQL_PASSWORD || ""; + const auth = Buffer.from(`${user}:${password}`).toString("base64"); + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "text/plain", + }, + body: sql, + signal: AbortSignal.timeout(6_000), + }); + + if (!response.ok) { + const body = await response.text(); + const error = new Error(extractErrorMessage(body)) as ClickHouseError; + error.chStatusCode = response.status; + throw error; + } + + const result = (await response.json()) as ClickHouseJsonResult; + if (result.exception) { + const error = new Error(extractErrorMessage(result.exception)) as ClickHouseError; + error.chStatusCode = 400; + throw error; + } + + return result; +} + +function extractQuery(req: Request): unknown { + return req.body?.query ?? req.query?.query; +} + +async function handleQuery(req: SqlRequest, res: Response, next: NextFunction) { + try { + const validation = validateSqlQuery(extractQuery(req)); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + + if (!hasSqlAccess(req)) { + res.status(403).json({ + error: "sql_access_denied", + message: "SQL query access requires an Enterprise plan", + }); + return; + } + + if (!isConfigured()) { + res.status(503).json({ + error: "service_unavailable", + message: + "SQL query service is not available. Configure CLICKHOUSE_HTTP_URL " + + "or use the hosted PMXT Enterprise SQL endpoint.", + }); + return; + } + + const result = await queryClickHouse(validation.query); + res.json({ + data: result.data || [], + meta: { + columns: result.meta || [], + rows: result.rows ?? result.data?.length ?? 0, + statistics: result.statistics || {}, + }, + }); + } catch (error: unknown) { + const err = error as ClickHouseError; + if (err.chStatusCode === 503) { + res.status(503).json({ + error: "service_unavailable", + message: err.message, + }); + return; + } + + if (err.chStatusCode === 401 || err.chStatusCode === 403) { + res.status(502).json({ + error: "database_error", + message: "Database connection failed", + }); + return; + } + + if (err.chStatusCode) { + res.status(400).json({ + error: "query_error", + message: err.message, + }); + return; + } + + if (err.name === "TimeoutError" || err.name === "AbortError") { + res.status(408).json({ + error: "query_timeout", + message: "Query exceeded the maximum execution time (5s)", + }); + return; + } + + next(error); + } +} + +export function createSqlRouter(): Router { + const router = Router(); + + router.post("/", handleQuery); + router.get("/", handleQuery); + + return router; +} diff --git a/core/test/normalizers/kalshi-event-title-normalization.test.ts b/core/test/normalizers/kalshi-event-title-normalization.test.ts new file mode 100644 index 00000000..67286962 --- /dev/null +++ b/core/test/normalizers/kalshi-event-title-normalization.test.ts @@ -0,0 +1,172 @@ +import { KalshiRawEvent, KalshiRawMarket } from '../../src/exchanges/kalshi/fetcher'; +import { KalshiNormalizer } from '../../src/exchanges/kalshi/normalizer'; + +const normalizer = new KalshiNormalizer(); + +function market(ticker: string, title: string, yesSubTitle: string): KalshiRawMarket { + return { + ticker, + title, + yes_sub_title: yesSubTitle, + expiration_time: '2026-06-01T00:00:00Z', + last_price_dollars: '0.5000', + volume_24h_fp: '0.00', + volume_fp: '0.00', + }; +} + +function event(raw: Omit & { markets?: KalshiRawMarket[] }): KalshiRawEvent { + return { + ...raw, + category: raw.category ?? 'Sports', + tags: raw.tags ?? ['Sports'], + markets: raw.markets ?? [ + market(`${raw.event_ticker}-YES`, `Will ${raw.title} happen?`, 'Yes'), + ], + }; +} + +describe('Kalshi event-title normalization', () => { + test.each([ + { + event_ticker: 'KXUCL-26', + title: 'Champions League Winner: PSG vs Arsenal', + series_title: 'UEFA Champions League', + sub_title: 'On May 30, 2026', + series_ticker: 'KXUCL', + mutually_exclusive: true, + product_metadata: { competition: 'Champions League', competition_scope: 'Game' }, + markets: [ + market('KXUCL-26-ARS', 'Will Arsenal win the Champions League Winner?', 'Arsenal'), + market('KXUCL-26-PSG', 'Will PSG win the Champions League Winner?', 'PSG'), + market('KXUCL-26-RMA', 'Will Real Madrid win the Champions League Winner?', 'Real Madrid'), + market('KXUCL-26-LFC', 'Will Liverpool win the Champions League Winner?', 'Liverpool'), + ], + requiredTerms: ['Champions League', 'Winner'], + forbiddenTerms: ['PSG vs Arsenal', 'PSG', 'Arsenal'], + }, + { + event_ticker: 'KXNBAEAST-26', + title: 'Series Winner: Cleveland (4) vs New York (3)', + series_title: 'Pro Basketball Eastern Conference Champion', + sub_title: '2026', + series_ticker: 'KXNBAEAST', + mutually_exclusive: true, + product_metadata: { competition: 'Pro Basketball (M)', competition_scope: 'Series Winner' }, + markets: [ + market('KXNBAEAST-26-DET', 'Will Detroit win the 2026 Pro Basketball Eastern Conference Championship?', 'Detroit'), + market('KXNBAEAST-26-BOS', 'Will Boston win the 2026 Pro Basketball Eastern Conference Championship?', 'Boston'), + market('KXNBAEAST-26-CLE', 'Will the Cleveland win the 2026 Pro Basketball Eastern Conference Championship?', 'Cleveland'), + market('KXNBAEAST-26-NYK', 'Will the New York win the 2026 Pro Basketball Eastern Conference Championship?', 'New York'), + ], + requiredTerms: ['2026', 'Pro Basketball', 'Eastern Conference'], + forbiddenTerms: ['Cleveland', 'New York', 'Series Winner:'], + }, + { + event_ticker: 'KXNBAWEST-26', + title: 'Series Winner: San Antonio (2) vs Oklahoma City (1)', + series_title: 'NBA Western Conference Championship', + sub_title: '2026', + series_ticker: 'KXNBAWEST', + mutually_exclusive: true, + product_metadata: { competition: 'Pro Basketball (M)', competition_scope: 'Series Winner' }, + markets: [ + market('KXNBAWEST-26-DAL', 'Will the Dallas win the 2026 Pro Basketball Western Conference Championship?', 'Dallas'), + market('KXNBAWEST-26-LAL', 'Will the Los Angeles Lakers win the 2026 Pro Basketball Western Conference Championship?', 'Los Angeles Lakers'), + market('KXNBAWEST-26-SAS', 'Will the San Antonio win the 2026 Pro Basketball Western Conference Championship?', 'San Antonio'), + market('KXNBAWEST-26-OKC', 'Will the Oklahoma City win the 2026 Pro Basketball Western Conference Championship?', 'Oklahoma City'), + ], + requiredTerms: ['2026', 'Western Conference'], + forbiddenTerms: ['San Antonio', 'Oklahoma City', 'Series Winner:'], + }, + { + event_ticker: 'KXNHLWEST-26', + title: 'Series Winner: Vegas Golden Knights vs Colorado Avalanche', + series_title: 'Western Conference Champion', + sub_title: '2025-2026 Western Conference Finals', + series_ticker: 'KXNHLWEST', + mutually_exclusive: true, + product_metadata: { competition: 'NHL', competition_scope: 'Series Winner' }, + markets: [ + market('KXNHLWEST-26-WPG', 'Western Conference Finals Winner?', 'Winnipeg Jets'), + market('KXNHLWEST-26-DAL', 'Western Conference Finals Winner?', 'Dallas Stars'), + market('KXNHLWEST-26-VGK', 'Western Conference Finals Winner?', 'Vegas Golden Knights'), + market('KXNHLWEST-26-COL', 'Western Conference Finals Winner?', 'Colorado Avalanche'), + ], + requiredTerms: ['Western Conference'], + forbiddenTerms: ['Vegas Golden Knights', 'Colorado Avalanche', 'Series Winner:'], + }, + ])('$event_ticker drops current-matchup contamination from broad futures', (sample) => { + const normalized = normalizer.normalizeEvent(event(sample)); + + expect(normalized).not.toBeNull(); + expect(normalized!.title).not.toBe(sample.title); + for (const term of sample.requiredTerms) { + expect(normalized!.title).toContain(term); + } + for (const term of sample.forbiddenTerms) { + expect(normalized!.title).not.toContain(term); + } + }); + + test.each([ + { + event_ticker: 'KXMENWORLDCUP-26', + title: "2026 Men's World Cup Winner", + series_title: "Men's World Cup", + sub_title: '2026', + series_ticker: 'KXMENWORLDCUP', + product_metadata: { competition: 'FIFA World Cup', competition_scope: 'Future' }, + markets: [ + market('KXMENWORLDCUP-26-FRA', "Will the France win the 2026 Men's World Cup?", 'France'), + ], + }, + { + event_ticker: 'KXNBA-26', + title: 'Pro Basketball Champion', + series_title: 'Pro Basketball Champion', + sub_title: '2026', + series_ticker: 'KXNBA', + product_metadata: { competition: 'Pro Basketball (M)', competition_scope: 'Future' }, + markets: [ + market('KXNBA-26-ATL', 'Will the Atlanta win the 2026 Pro Basketball Finals?', 'Atlanta'), + ], + }, + { + event_ticker: 'KXF1-26', + title: 'F1 Drivers Champion', + series_title: 'F1 Drivers Champion', + sub_title: '2026', + series_ticker: 'KXF1', + product_metadata: { competition: 'F1', competition_scope: 'Future' }, + markets: [ + market('KXF1-26-LNO', 'Will Lando Norris win the F1 Drivers Championship?', 'Lando Norris'), + ], + }, + ])('$event_ticker keeps already-sane broad future title', (sample) => { + const normalized = normalizer.normalizeEvent(event(sample)); + + expect(normalized).not.toBeNull(); + expect(normalized!.title).toBe(sample.title); + }); + + it('keeps true match events as matchup titles', () => { + const sample = event({ + event_ticker: 'KXUCLGAME-26MAY30PSGARS', + title: 'Reg Time: PSG vs Arsenal', + series_title: 'UEFA Champions League', + sub_title: 'PSG vs ARS (May 30)', + series_ticker: 'KXUCLGAME', + product_metadata: { competition: 'Champions League', competition_scope: 'Regulation Time Moneyline' }, + markets: [ + market('KXUCLGAME-26MAY30PSGARS-PSG', 'PSG vs Arsenal Winner?', 'PSG'), + market('KXUCLGAME-26MAY30PSGARS-ARS', 'PSG vs Arsenal Winner?', 'Arsenal'), + ], + }); + + const normalized = normalizer.normalizeEvent(sample); + + expect(normalized).not.toBeNull(); + expect(normalized!.title).toBe('Reg Time: PSG vs Arsenal'); + }); +}); diff --git a/core/test/normalizers/suibets-normalizer.test.ts b/core/test/normalizers/suibets-normalizer.test.ts new file mode 100644 index 00000000..3faa75de --- /dev/null +++ b/core/test/normalizers/suibets-normalizer.test.ts @@ -0,0 +1,439 @@ +/** + * Normalizer fixture tests for SuibetsNormalizer. + * + * Each test suite: + * 1. Declares a frozen raw fixture that mirrors what the real API returns. + * 2. Passes the fixture through the normalizer under test. + * 3. Asserts every field on the resulting UnifiedMarket / UnifiedEvent / Position. + * + * No network I/O occurs — all external dependencies are bypassed by + * constructing the raw types directly. + * + * MIST conversion: SuiBets stakes are expressed in MIST (1 SUI = 1e9 MIST). + * All monetary fields in the assertions are in SUI. + */ + +import { SuibetsNormalizer } from '../../src/exchanges/suibets/normalizer'; +import type { SuibetsRawOffer, SuibetsRawEvent } from '../../src/exchanges/suibets/fetcher'; + +// ============================================================================ +// SuibetsNormalizer +// ============================================================================ + +describe('SuibetsNormalizer', () => { + const normalizer = new SuibetsNormalizer(); + + // ------------------------------------------------------------------------- + // Fixtures + // ------------------------------------------------------------------------- + + /** + * A fully-populated P2P offer with all optional fields present. + * Staked amounts are in MIST (1 SUI = 1_000_000_000 MIST). + */ + const rawOffer: SuibetsRawOffer = Object.freeze({ + id: 'offer-123', + matchId: 'match-456', + matchName: 'Real Madrid vs Barcelona', + sport: 'Football', + homeTeam: 'Real Madrid', + awayTeam: 'Barcelona', + creatorWallet: '0xabc123', + creatorTeam: 'Real Madrid', + creatorOdds: 2.5, + creatorStake: 5_000_000_000, // 5 SUI in MIST + takerStake: 7_500_000_000, + remainingStake: 3_000_000_000, + matchDate: '2026-06-15T20:00:00Z', + expiresAt: '2026-06-15T19:00:00Z', + status: 'OPEN', + totalMatched: 2_000_000_000, + currency: 'SUI', + isOnchain: true, + onchainOfferId: '0xdef456', + leagueName: 'La Liga', + }); + + /** + * A raw event that groups the offer above under a match. + * The normalizer synthesises volume24h by summing market volumes. + */ + const rawEvent: SuibetsRawEvent = Object.freeze({ + id: 'match-456', + name: 'Real Madrid vs Barcelona', + homeTeam: 'Real Madrid', + awayTeam: 'Barcelona', + sport: 'Football', + leagueName: 'La Liga', + matchDate: '2026-06-15T20:00:00Z', + status: 'active', + offers: [rawOffer], + }); + + // Derived constants — keep in sync with the fixture so assertions are + // readable and the maths is self-documenting. + const CREATOR_ODDS = 2.5; + const CREATOR_PROB = 1 / CREATOR_ODDS; // 0.4 + const TAKER_PROB = 1 - CREATOR_PROB; // 0.6 + const LIQUIDITY_SUI = 3_000_000_000 / 1e9; // 3 SUI + const VOLUME24H_SUI = 2_000_000_000 / 1e9; // 2 SUI + const CREATOR_STAKE_SUI = 5_000_000_000 / 1e9; // 5 SUI + + // ------------------------------------------------------------------------- + // normalizeMarket — happy path + // ------------------------------------------------------------------------- + + describe('normalizeMarket', () => { + it('returns a non-null result for a valid offer', () => { + const market = normalizer.normalizeMarket(rawOffer); + expect(market).not.toBeNull(); + }); + + it('marketId is prefixed with suibets:', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.marketId).toBe('suibets:offer-123'); + }); + + it('eventId is prefixed with suibets:', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.eventId).toBe('suibets:match-456'); + }); + + it('title contains the match name', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.title).toContain('Real Madrid vs Barcelona'); + }); + + it('slug is the raw offer id without prefix', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.slug).toBe('offer-123'); + }); + + it('produces exactly 2 outcomes', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes).toHaveLength(2); + }); + + it('creator outcome has the correct outcomeId', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[0].outcomeId).toBe('offer-123:creator'); + }); + + it('creator outcome label is the creatorTeam', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[0].label).toBe('Real Madrid'); + }); + + it('creator outcome price is 1 / creatorOdds', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[0].price).toBeCloseTo(CREATOR_PROB, 5); + }); + + it('creator outcome marketId is suibets:offer-123', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[0].marketId).toBe('suibets:offer-123'); + }); + + it('taker outcome has the correct outcomeId', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[1].outcomeId).toBe('offer-123:taker'); + }); + + it('taker outcome label is the opposing team (awayTeam when creatorTeam is homeTeam)', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[1].label).toBe('Barcelona'); + }); + + it('taker outcome price is 1 - (1 / creatorOdds)', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[1].price).toBeCloseTo(TAKER_PROB, 5); + }); + + it('taker outcome marketId is suibets:offer-123', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.outcomes[1].marketId).toBe('suibets:offer-123'); + }); + + it('creator and taker prices are in the open interval (0, 1)', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + for (const o of market.outcomes) { + expect(o.price).toBeGreaterThan(0); + expect(o.price).toBeLessThan(1); + } + }); + + it('liquidity is remainingStake converted from MIST to SUI', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.liquidity).toBeCloseTo(LIQUIDITY_SUI, 5); + }); + + it('volume24h is totalMatched converted from MIST to SUI', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.volume24h).toBeCloseTo(VOLUME24H_SUI, 5); + }); + + it('status is "active" when raw status is "OPEN"', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.status).toBe('active'); + }); + + it('contractAddress is the onchainOfferId', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.contractAddress).toBe('0xdef456'); + }); + + it('resolutionDate is a valid Date instance', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.resolutionDate).toBeInstanceOf(Date); + expect(isNaN(market.resolutionDate.getTime())).toBe(false); + }); + + it('resolutionDate is derived from matchDate', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.resolutionDate.toISOString()).toBe('2026-06-15T20:00:00.000Z'); + }); + + it('category is "Sports"', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.category).toBe('Sports'); + }); + + it('tags include "Sports", "P2P", the sport, and the league', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(Array.isArray(market.tags)).toBe(true); + expect(market.tags).toContain('Sports'); + expect(market.tags).toContain('P2P'); + expect(market.tags).toContain('Football'); + expect(market.tags).toContain('La Liga'); + }); + + it('url points to the suibets P2P page', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.url).toContain('suibets'); + }); + + it('yes is set to outcomes[0] (creator side)', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect((market as any).yes).toBeDefined(); + expect((market as any).yes.outcomeId).toBe('offer-123:creator'); + }); + + it('no is set to outcomes[1] (taker side)', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect((market as any).no).toBeDefined(); + expect((market as any).no.outcomeId).toBe('offer-123:taker'); + }); + }); + + // ------------------------------------------------------------------------- + // normalizeMarket — null / degenerate guards + // ------------------------------------------------------------------------- + + describe('normalizeMarket null guards', () => { + it('returns null for null input', () => { + expect(normalizer.normalizeMarket(null as any)).toBeNull(); + }); + + it('returns null when id is undefined', () => { + expect(normalizer.normalizeMarket({ id: undefined } as any)).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // normalizeMarket — liquidity falls back to creatorStake when remainingStake + // is absent + // ------------------------------------------------------------------------- + + describe('normalizeMarket liquidity fallback', () => { + it('liquidity uses creatorStake when remainingStake is absent', () => { + const offerNoRemaining: SuibetsRawOffer = { + ...rawOffer, + remainingStake: undefined, + }; + const market = normalizer.normalizeMarket(offerNoRemaining)!; + expect(market.liquidity).toBeCloseTo(CREATOR_STAKE_SUI, 5); + }); + }); + + // ------------------------------------------------------------------------- + // normalizeMarket — status mapping + // ------------------------------------------------------------------------- + + describe('normalizeMarket status mapping', () => { + it('"OPEN" maps to "active"', () => { + const market = normalizer.normalizeMarket(rawOffer)!; + expect(market.status).toBe('active'); + }); + + it('unrecognised status maps to "inactive"', () => { + const closedOffer: SuibetsRawOffer = { ...rawOffer, status: 'CLOSED' }; + const market = normalizer.normalizeMarket(closedOffer)!; + expect(market.status).toBe('inactive'); + }); + + it('"MATCHED" maps to "matched"', () => { + const matchedOffer: SuibetsRawOffer = { ...rawOffer, status: 'MATCHED' }; + const market = normalizer.normalizeMarket(matchedOffer)!; + expect(market.status).toBe('matched'); + }); + + it('"SETTLED" maps to "settled"', () => { + const settledOffer: SuibetsRawOffer = { ...rawOffer, status: 'SETTLED' }; + const market = normalizer.normalizeMarket(settledOffer)!; + expect(market.status).toBe('settled'); + }); + + it('"EXPIRED" maps to "expired"', () => { + const expiredOffer: SuibetsRawOffer = { ...rawOffer, status: 'EXPIRED' }; + const market = normalizer.normalizeMarket(expiredOffer)!; + expect(market.status).toBe('expired'); + }); + }); + + // ------------------------------------------------------------------------- + // normalizeEvent + // ------------------------------------------------------------------------- + + describe('normalizeEvent', () => { + it('returns a non-null result for a valid event', () => { + const event = normalizer.normalizeEvent(rawEvent); + expect(event).not.toBeNull(); + }); + + it('id is prefixed with suibets:', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.id).toBe('suibets:match-456'); + }); + + it('title is taken from the event name', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.title).toBe('Real Madrid vs Barcelona'); + }); + + it('slug is the raw event id without prefix', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.slug).toBe('match-456'); + }); + + it('markets is populated from the offers array', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(Array.isArray(event.markets)).toBe(true); + expect(event.markets).toHaveLength(1); + }); + + it('each nested market is a valid UnifiedMarket', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + for (const m of event.markets) { + expect(typeof m.marketId).toBe('string'); + expect(m.marketId.length).toBeGreaterThan(0); + expect(Array.isArray(m.outcomes)).toBe(true); + expect(m.outcomes.length).toBeGreaterThan(0); + } + }); + + it('volume24h sums volume across all child markets', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + const expectedSum = event.markets.reduce( + (acc, m) => acc + (m.volume ?? m.volume24h ?? 0), + 0, + ); + expect(event.volume24h).toBeCloseTo(expectedSum, 5); + }); + + it('volume24h is non-zero when totalMatched is present on offers', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.volume24h).toBeGreaterThan(0); + }); + + it('category is "Sports"', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.category).toBe('Sports'); + }); + + it('tags include "Sports", "P2P", and "Sui"', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(Array.isArray(event.tags)).toBe(true); + expect(event.tags).toContain('Sports'); + expect(event.tags).toContain('P2P'); + expect(event.tags).toContain('Sui'); + }); + + it('url points to the suibets P2P page', () => { + const event = normalizer.normalizeEvent(rawEvent)!; + expect(event.url).toContain('suibets'); + }); + + it('returns null for null input', () => { + expect(normalizer.normalizeEvent(null as any)).toBeNull(); + }); + + it('returns null when event id is absent', () => { + expect(normalizer.normalizeEvent({ id: undefined } as any)).toBeNull(); + }); + + it('markets is empty when offers is an empty array', () => { + const emptyEvent: SuibetsRawEvent = { ...rawEvent, offers: [] }; + const event = normalizer.normalizeEvent(emptyEvent)!; + expect(event.markets).toHaveLength(0); + }); + + it('markets is empty when offers is absent', () => { + const noOffersEvent: SuibetsRawEvent = { ...rawEvent, offers: undefined }; + const event = normalizer.normalizeEvent(noOffersEvent)!; + expect(event.markets).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // normalizePosition + // ------------------------------------------------------------------------- + + describe('normalizePosition', () => { + it('marketId is suibets:matchId when matchId is present', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.marketId).toBe('suibets:match-456'); + }); + + it('outcomeId is offer-id:creator', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.outcomeId).toBe('offer-123:creator'); + }); + + it('outcomeLabel is the creator team name', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.outcomeLabel).toBe('Real Madrid'); + }); + + it('size is creatorStake converted from MIST to SUI', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.size).toBeCloseTo(CREATOR_STAKE_SUI, 5); + }); + + it('entryPrice is 1 / creatorOdds', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.entryPrice).toBeCloseTo(CREATOR_PROB, 5); + }); + + it('currentPrice equals entryPrice (no live price feed)', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.currentPrice).toBeCloseTo(position.entryPrice, 5); + }); + + it('unrealizedPnL is 0', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.unrealizedPnL).toBe(0); + }); + + it('entryPrice is in the open interval (0, 1)', () => { + const position = normalizer.normalizePosition(rawOffer); + expect(position.entryPrice).toBeGreaterThan(0); + expect(position.entryPrice).toBeLessThan(1); + }); + + it('marketId falls back to suibets:offer-id when matchId is absent', () => { + const offerNoMatch: SuibetsRawOffer = { ...rawOffer, matchId: undefined as any }; + const position = normalizer.normalizePosition(offerNoMatch); + expect(position.marketId).toBe('suibets:offer-123'); + }); + }); +}); diff --git a/core/test/pipeline/create-app-websocket.test.ts b/core/test/pipeline/create-app-websocket.test.ts new file mode 100644 index 00000000..6ab27e44 --- /dev/null +++ b/core/test/pipeline/create-app-websocket.test.ts @@ -0,0 +1,122 @@ +import http from 'http'; +import WebSocket from 'ws'; +import type { AddressInfo } from 'net'; +import { + attachWebSocketEndpoint, + createApp, +} from '../../src/server/app'; +import type { WebSocketEndpoint } from '../../src/server/app'; + +function waitForListening(server: http.Server): Promise { + if (server.listening) return Promise.resolve(); + return new Promise((resolve) => server.once('listening', () => resolve())); +} + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + ws.once('open', () => resolve()); + ws.once('error', reject); + ws.once('unexpected-response', (_req, res) => { + reject(new Error(`Unexpected server response: ${res.statusCode}`)); + }); + }); +} + +function waitForJsonMessage(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for WebSocket message')); + }, 1000); + + ws.once('message', (data) => { + clearTimeout(timeout); + try { + resolve(JSON.parse(data.toString())); + } catch (error) { + reject(error); + } + }); + ws.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +function closeServer(server: http.Server): Promise { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function closeWebSocketEndpoint(endpoint: WebSocketEndpoint): Promise { + for (const client of endpoint.wss.clients) { + client.terminate(); + } + return new Promise((resolve, reject) => { + endpoint.wss.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +describe('createApp WebSocket endpoint attachment', () => { + let server: http.Server | undefined; + let endpoint: WebSocketEndpoint | undefined; + let client: WebSocket | undefined; + + afterEach(async () => { + if (client && client.readyState !== WebSocket.CLOSED) { + client.terminate(); + } + if (endpoint) { + await closeWebSocketEndpoint(endpoint); + } + if (server) { + await closeServer(server); + } + client = undefined; + endpoint = undefined; + server = undefined; + }); + + it('exposes /ws for a local server returned by createApp().listen()', async () => { + const app = createApp({ accessToken: undefined }); + server = app.listen(0, '127.0.0.1'); + endpoint = attachWebSocketEndpoint(server); + await waitForListening(server); + + const { port } = server.address() as AddressInfo; + client = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await waitForOpen(client); + + client.send('not-json'); + await expect(waitForJsonMessage(client)).resolves.toMatchObject({ + event: 'error', + error: { message: 'Invalid JSON' }, + }); + }); + + it('uses the configured access token for /ws upgrades', async () => { + const accessToken = 'local-test-token'; + const app = createApp({ accessToken }); + server = app.listen(0, '127.0.0.1'); + endpoint = attachWebSocketEndpoint(server, { accessToken }); + await waitForListening(server); + + const { port } = server.address() as AddressInfo; + const unauthenticated = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await expect(waitForOpen(unauthenticated)).rejects.toThrow( + 'Unexpected server response: 401', + ); + unauthenticated.terminate(); + + client = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${accessToken}`); + await waitForOpen(client); + }); +}); diff --git a/core/test/pipeline/feed-routes.test.ts b/core/test/pipeline/feed-routes.test.ts new file mode 100644 index 00000000..9236eeca --- /dev/null +++ b/core/test/pipeline/feed-routes.test.ts @@ -0,0 +1,95 @@ +import express, { NextFunction, Request, Response } from 'express'; +import request from 'supertest'; +import { createFeedRouter } from '../../src/server/feed-routes'; + +const originalEnv = { + BINANCE_RELAY_WS_URL: process.env.BINANCE_RELAY_WS_URL, + CHAINLINK_API_URL: process.env.CHAINLINK_API_URL, + CHAINLINK_WS_URL: process.env.CHAINLINK_WS_URL, +}; + +function buildApp() { + const app = express(); + app.use('/api/feeds', createFeedRouter()); + app.use((error: any, _req: Request, res: Response, _next: NextFunction) => { + res.status(error.status || 500).json({ + success: false, + error: { + message: error.message, + code: error.code, + retryable: error.retryable, + exchange: error.exchange, + }, + }); + }); + return app; +} + +function restoreEnv(name: keyof typeof originalEnv): void { + const value = originalEnv[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +describe('feed routes backend errors', () => { + beforeEach(() => { + delete process.env.BINANCE_RELAY_WS_URL; + delete process.env.CHAINLINK_API_URL; + delete process.env.CHAINLINK_WS_URL; + }); + + afterAll(() => { + restoreEnv('BINANCE_RELAY_WS_URL'); + restoreEnv('CHAINLINK_API_URL'); + restoreEnv('CHAINLINK_WS_URL'); + }); + + test('binance fetchTicker names missing BINANCE_RELAY_WS_URL', async () => { + const res = await request(buildApp()) + .get('/api/feeds/binance/fetchTicker') + .query({ symbol: 'BTC/USDT' }); + + expect(res.status).toBe(503); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXCHANGE_NOT_AVAILABLE'); + expect(res.body.error.message).toContain('BINANCE_RELAY_WS_URL'); + }); + + test('chainlink oracle route names missing CHAINLINK_API_URL', async () => { + const res = await request(buildApp()) + .get('/api/feeds/chainlink/fetchOracleRound') + .query({ feed: 'BTC/USD' }); + + expect(res.status).toBe(503); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('EXCHANGE_NOT_AVAILABLE'); + expect(res.body.error.message).toContain('CHAINLINK_API_URL'); + }); + + test('binance OHLCV returns a route-level capability error', async () => { + const res = await request(buildApp()) + .get('/api/feeds/binance/fetchOHLCV') + .query({ symbol: 'BTC/USDT', timeframe: '1m', limit: '2' }); + + expect(res.status).toBe(501); + expect(res.body).toEqual({ + success: false, + error: "Feed 'binance' does not support fetchOHLCV", + }); + }); + + test('binance order book returns a route-level capability error', async () => { + const res = await request(buildApp()) + .get('/api/feeds/binance/fetchOrderBook') + .query({ symbol: 'BTC/USDT', limit: '5' }); + + expect(res.status).toBe(501); + expect(res.body).toEqual({ + success: false, + error: "Feed 'binance' does not support fetchOrderBook", + }); + }); +}); diff --git a/core/test/pipeline/router-local-mock-matches.test.ts b/core/test/pipeline/router-local-mock-matches.test.ts new file mode 100644 index 00000000..f0b4ce30 --- /dev/null +++ b/core/test/pipeline/router-local-mock-matches.test.ts @@ -0,0 +1,74 @@ +import http from 'http'; +import { Router } from '../../src/router'; +import { MockExchange } from '../../src/exchanges/mock'; +import { createApp } from '../../src/server/app'; + +interface RawResponse { + status: number; + body: any; +} + +async function startTestServer(): Promise<{ server: http.Server; baseUrl: string }> { + const app = createApp({ accessToken: undefined }); + const server = http.createServer(app); + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + const addr = server.address() as { port: number }; + return { server, baseUrl: `http://127.0.0.1:${addr.port}` }; +} + +async function stopTestServer(server: http.Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +async function get(baseUrl: string, path: string): Promise { + const res = await fetch(`${baseUrl}${path}`); + const body = await res.json(); + return { status: res.status, body }; +} + +describe('Router local mock match lookup', () => { + test('resolves local mock market and event IDs to no hosted matches when a mock exchange is configured', async () => { + const router = new Router({ + apiKey: 'test', + localExchanges: { mock: new MockExchange() }, + }); + + await expect(router.fetchMarketMatches({ marketId: 'mock-m0' })).resolves.toEqual([]); + await expect(router.fetchMarketMatches({ marketId: 'mock-m0-yes' })).resolves.toEqual([]); + await expect(router.fetchEventMatches({ eventId: 'mock-event-0' })).resolves.toEqual([]); + }); + + test('throws a clear local-unsupported error before hosted lookup without a mock exchange', async () => { + const router = new Router({ apiKey: 'test' }); + + await expect(router.fetchMarketMatches({ marketId: 'mock-m0' })).rejects.toMatchObject({ + code: 'LOCAL_MATCH_LOOKUP_UNSUPPORTED', + status: 501, + }); + await expect(router.fetchEventMatches({ eventId: 'mock-event-0' })).rejects.toMatchObject({ + code: 'LOCAL_MATCH_LOOKUP_UNSUPPORTED', + status: 501, + }); + }); + + test('sidecar router resolves bundled mock IDs locally instead of returning hosted not found', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const market = await get(baseUrl, '/api/router/fetchMarketMatches?marketId=mock-m0'); + expect(market.status).toBe(200); + expect(market.body.success).toBe(true); + expect(market.body.data).toEqual([]); + + const event = await get(baseUrl, '/api/router/fetchEventMatches?eventId=mock-event-0'); + expect(event.status).toBe(200); + expect(event.body.success).toBe(true); + expect(event.body.data).toEqual([]); + } finally { + await stopTestServer(server); + } + }); +}); diff --git a/core/test/pipeline/sql-route.test.ts b/core/test/pipeline/sql-route.test.ts new file mode 100644 index 00000000..db53764e --- /dev/null +++ b/core/test/pipeline/sql-route.test.ts @@ -0,0 +1,149 @@ +import express, { NextFunction, Request, Response } from "express"; +import http from "http"; +import request from "supertest"; +import { createApp } from "../../src/server/app"; + +const SQL_ENV_KEYS = [ + "CLICKHOUSE_HTTP_URL", + "CLICKHOUSE_SQL_USER", + "CLICKHOUSE_SQL_PASSWORD", + "SQL_ALLOWED_PLANS", +]; + +describe("Enterprise SQL route", () => { + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of SQL_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of SQL_ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + test("POST /v0/sql accepts the hosted query body shape and returns 503 when ClickHouse is not configured", async () => { + const app = createApp({ accessToken: undefined }); + + const res = await request(app) + .post("/v0/sql") + .send({ query: "select 1" }); + + expect(res.status).toBe(503); + expect(res.body.error).toBe("service_unavailable"); + expect(res.body.message).toMatch(/SQL query service is not available/i); + }); + + test("GET /v0/sql accepts query-string SQL and returns 503 when ClickHouse is not configured", async () => { + const app = createApp({ accessToken: undefined }); + + const res = await request(app) + .get("/v0/sql") + .query({ query: "select 1" }); + + expect(res.status).toBe(503); + expect(res.body.error).toBe("service_unavailable"); + }); + + test("POST /v0/sql rejects a missing query before configuration checks", async () => { + const app = createApp({ accessToken: undefined }); + + const res = await request(app) + .post("/v0/sql") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("query is required"); + }); + + test("POST /v0/sql rejects disallowed write queries before configuration checks", async () => { + const app = createApp({ accessToken: undefined }); + + const res = await request(app) + .post("/v0/sql") + .send({ query: "insert into markets values (1)" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/INSERT.*not allowed/i); + }); + + test("POST /v0/sql returns 403 when upstream auth marks the account as non-enterprise", async () => { + const app = express(); + app.use(express.json()); + app.use((req: Request, _res: Response, next: NextFunction) => { + (req as Request & { account?: { plan_name: string } }).account = { + plan_name: "Free", + }; + next(); + }); + app.use(createApp({ accessToken: undefined, skipBaseMiddleware: true })); + + const res = await request(app) + .post("/v0/sql") + .send({ query: "select 1" }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("sql_access_denied"); + }); + + test("POST /v0/sql proxies read-only SQL to configured ClickHouse", async () => { + let receivedBody = ""; + let receivedUrl = ""; + + const clickhouse = http.createServer((req, res) => { + receivedUrl = req.url || ""; + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + receivedBody = Buffer.concat(chunks).toString("utf8"); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ + meta: [{ name: "one", type: "UInt8" }], + data: [{ one: 1 }], + rows: 1, + statistics: { elapsed: 0.001 }, + })); + }); + }); + + await new Promise((resolve) => { + clickhouse.listen(0, "127.0.0.1", () => resolve()); + }); + + try { + const address = clickhouse.address(); + if (!address || typeof address === "string") { + throw new Error("Fake ClickHouse server did not bind to a TCP port"); + } + process.env.CLICKHOUSE_HTTP_URL = `http://127.0.0.1:${address.port}`; + + const app = createApp({ accessToken: undefined }); + const res = await request(app) + .post("/v0/sql") + .send({ query: "select 1" }); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([{ one: 1 }]); + expect(res.body.meta.columns).toEqual([{ name: "one", type: "UInt8" }]); + expect(res.body.meta.rows).toBe(1); + expect(receivedBody).toBe("select 1"); + expect(receivedUrl).toContain("readonly=1"); + expect(receivedUrl).toContain("max_execution_time=5"); + expect(receivedUrl).toContain("max_result_rows=10000"); + } finally { + await new Promise((resolve) => { + clickhouse.close(() => resolve()); + }); + } + }); +}); diff --git a/core/test/unit/dataFeeds.core.test.ts b/core/test/unit/dataFeeds.core.test.ts new file mode 100644 index 00000000..aa30892c --- /dev/null +++ b/core/test/unit/dataFeeds.core.test.ts @@ -0,0 +1,43 @@ +import { ExchangeNotAvailable, NotSupported } from '../../src/errors'; +import { BinanceFeed } from '../../src/feeds/binance'; +import { ChainlinkFeed } from '../../src/feeds/chainlink/chainlink-feed'; + +describe('Data feed backend errors', () => { + test('Binance fetchTicker names the missing relay URL setting', async () => { + const feed = new BinanceFeed({ wsUrl: '', apiKey: '' }); + + await expect(feed.fetchTicker('BTC/USDT')).rejects.toMatchObject({ + code: 'EXCHANGE_NOT_AVAILABLE', + message: expect.stringContaining('BINANCE_RELAY_WS_URL'), + status: 503, + } satisfies Partial); + }); + + test('Binance unsupported order book returns a capability error', async () => { + const feed = new BinanceFeed({ wsUrl: '', apiKey: '' }); + + await expect(feed.fetchOrderBook('BTC/USDT')).rejects.toMatchObject({ + code: 'NOT_SUPPORTED', + status: 501, + } satisfies Partial); + }); + + test('Chainlink oracle calls name the missing REST API URL setting', async () => { + const feed = new ChainlinkFeed({ baseUrl: '', apiKey: '', wsUrl: '' }); + + await expect(feed.fetchOracleRound({ feed: 'BTC/USD' })).rejects.toMatchObject({ + code: 'EXCHANGE_NOT_AVAILABLE', + message: expect.stringContaining('CHAINLINK_API_URL'), + status: 503, + } satisfies Partial); + }); + + test('Chainlink unsupported order book returns a capability error', async () => { + const feed = new ChainlinkFeed({ baseUrl: '', apiKey: '', wsUrl: '' }); + + await expect(feed.fetchOrderBook('BTC/USD')).rejects.toMatchObject({ + code: 'NOT_SUPPORTED', + status: 501, + } satisfies Partial); + }); +}); diff --git a/core/test/unit/mockExchange.core.test.ts b/core/test/unit/mockExchange.core.test.ts index c7cd38a3..34489071 100644 --- a/core/test/unit/mockExchange.core.test.ts +++ b/core/test/unit/mockExchange.core.test.ts @@ -57,6 +57,40 @@ describe('MockExchange', () => { expect(byParam.asks).toEqual(byToken.asks); }); + test('fetchOrderBook honors limit depth', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0 }); + const market = (await ex.fetchMarkets())[0]!; + const outcomeId = market.outcomes[0]!.outcomeId; + + const limited = await ex.fetchOrderBook(outcomeId, 5); + + expect(limited.bids).toHaveLength(5); + expect(limited.asks).toHaveLength(5); + }); + + test('fetchOrderBooks returns books keyed by requested outcome IDs', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0 }); + const market = (await ex.fetchMarkets())[0]!; + const outcomeIds = market.outcomes.slice(0, 2).map((outcome) => outcome.outcomeId); + + const books = await ex.fetchOrderBooks(outcomeIds); + + expect(Object.keys(books)).toEqual(outcomeIds); + expect(books[outcomeIds[0]!]!.bids.length).toBeGreaterThan(0); + expect(books[outcomeIds[1]!]!.asks.length).toBeGreaterThan(0); + expect(ex.has.fetchOrderBooks).toBe(true); + }); + + test('fetchTrades honors limit', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0 }); + const market = (await ex.fetchMarkets())[0]!; + const outcomeId = market.outcomes[0]!.outcomeId; + + const trades = await ex.fetchTrades(outcomeId, { limit: 3 }); + + expect(trades).toHaveLength(3); + }); + test('instant limit buy debits free cash and creates position', async () => { const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0, balance: 10_000 }); const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; diff --git a/sdks/python/pmxt/__init__.py b/sdks/python/pmxt/__init__.py index 6494cbe5..87923804 100644 --- a/sdks/python/pmxt/__init__.py +++ b/sdks/python/pmxt/__init__.py @@ -19,7 +19,7 @@ from typing import Any, Dict, List from .client import Exchange -from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion, Metaculus, Smarkets, PolymarketUS, Polymarket_us, Hyperliquid, GeminiTitan, Mock, Router +from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion, Metaculus, Smarkets, PolymarketUS, Polymarket_us, Hyperliquid, GeminiTitan, SuiBets, Mock, Router from .router import Router from .server_manager import ServerManager from .errors import ( @@ -157,6 +157,7 @@ def restart_server() -> None: "Polymarket_us", "Hyperliquid", "GeminiTitan", + "SuiBets", "Mock", "Router", "Exchange", diff --git a/sdks/python/pmxt/_exchanges.py b/sdks/python/pmxt/_exchanges.py index bc1bae3a..456f882d 100644 --- a/sdks/python/pmxt/_exchanges.py +++ b/sdks/python/pmxt/_exchanges.py @@ -470,6 +470,41 @@ def _get_credentials_dict(self) -> Optional[Dict[str, Any]]: return creds if creds else None +class SuiBets(Exchange): + """SuiBets exchange client.""" + + def __init__( + self, + wallet_address: Optional[str] = None, + base_url: Optional[str] = None, + auto_start_server: Optional[bool] = None, + pmxt_api_key: Optional[str] = None, + ): + """ + Initialize SuiBets client. + + Args: + wallet_address: Sui wallet address for fetching positions (optional) + base_url: Base URL of the PMXT sidecar server + auto_start_server: Automatically start server if not running (default: True) + pmxt_api_key: Hosted PMXT API key (optional; enables hosted mode) + """ + super().__init__( + exchange_name="suibets", + base_url=base_url, + auto_start_server=auto_start_server, + pmxt_api_key=pmxt_api_key, + ) + + self.wallet_address = wallet_address + + def _get_credentials_dict(self) -> Optional[Dict[str, Any]]: + creds = super()._get_credentials_dict() or {} + if self.wallet_address: + creds["walletAddress"] = self.wallet_address + return creds if creds else None + + class Mock(Exchange): """Mock exchange client.""" diff --git a/sdks/python/pmxt/client.py b/sdks/python/pmxt/client.py index 0a88bbf2..ef5df413 100644 --- a/sdks/python/pmxt/client.py +++ b/sdks/python/pmxt/client.py @@ -10,6 +10,7 @@ import sys import time import urllib.error +import uuid from abc import ABC from datetime import datetime from typing import Callable, List, Optional, Dict, Any, Literal, Union @@ -320,8 +321,8 @@ def __init__( self._ws_client = None self._ws_lock = __import__("threading").Lock() # Sticky flag: set to True if the sidecar's /ws endpoint is - # unavailable (older core). Once set, streaming methods skip - # the WS attempt and POST directly. + # unavailable (older core). Once set, streaming methods fail + # fast with a clear WebSocket transport error. self._ws_unsupported: bool = False # Resolve base_url / hosted mode using the shared rules. @@ -1088,24 +1089,13 @@ def fetch_balance(self, address: Optional[str] = None) -> List[Balance]: raise self._parse_api_exception(e) from None def unwatch_order_book(self, outcome_id: Union[str, "MarketOutcome"] = _UNSET, **_compat_kwargs) -> None: - try: - args = [] - outcome_id = _compat_id(outcome_id, _compat_kwargs) - args.append(_resolve_outcome_id(outcome_id)) - body: dict = {"args": args} - creds = self._get_credentials_dict() - if creds: - body["credentials"] = creds - url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/unwatchOrderBook" - headers = {"Content-Type": "application/json", "Accept": "application/json"} - headers.update(self._get_auth_headers()) - response = self._fetch_with_retry( - lambda: self._api_client.call_api(method="POST", url=url, body=body, header_params=headers) - ) - response.read() - self._handle_response(json.loads(response.data)) - except ApiException as e: - raise self._parse_api_exception(e) from None + outcome_id = _compat_id(outcome_id, _compat_kwargs) + outcome_id = _resolve_outcome_id(outcome_id) + self._unwatch_required_via_ws( + "unwatch_order_book", + "unwatchOrderBook", + [outcome_id], + ) def unwatch_address(self, address: str) -> None: try: @@ -1720,7 +1710,7 @@ def _get_or_create_ws(self): with client._lock: client._ensure_connected() except Exception: - # WS endpoint not available -- remember and fall back to HTTP + # WS endpoint not available -- remember and fail fast. self._ws_unsupported = True return None @@ -1731,11 +1721,10 @@ def _watch_via_ws( self, method: str, args: List[Any], - ) -> Optional[Dict[str, Any]]: + ) -> Optional[Any]: """Attempt to use the WS transport for a watch method. - Returns the raw data dict on success, or None if WS is unavailable - (caller should fall back to HTTP). + Returns the raw data payload on success, or None if WS is unavailable. """ ws = self._get_or_create_ws() if ws is None: @@ -1749,9 +1738,83 @@ def _watch_via_ws( credentials=self._get_credentials_dict(), ) except (ConnectionError, OSError): - # Transport-level failure -- fall back to HTTP + # Transport-level failure. return None + def _ws_required_error(self, method_name: str) -> PmxtError: + return PmxtError(f"{method_name}() requires WebSocket transport — connection failed") + + def _require_ws_transport(self, method_name: str): + ws = self._get_or_create_ws() + if ws is None: + raise self._ws_required_error(method_name) + return ws + + def _watch_required_via_ws( + self, + public_method_name: str, + ws_method_name: str, + args: List[Any], + ) -> Any: + data = self._watch_via_ws(ws_method_name, args) + if data is None: + raise self._ws_required_error(public_method_name) + return data + + def _watch_batch_required_via_ws( + self, + public_method_name: str, + ws_method_name: str, + args: List[Any], + ) -> Any: + ws = self._require_ws_transport(public_method_name) + try: + return ws.subscribe_batch( + exchange=self.exchange_name, + method=ws_method_name, + args=args, + credentials=self._get_credentials_dict(), + ) + except (ConnectionError, OSError) as e: + self._ws_unsupported = True + raise self._ws_required_error(public_method_name) from e + + def _unwatch_required_via_ws( + self, + public_method_name: str, + ws_method_name: str, + args: List[Any], + ) -> None: + ws = self._require_ws_transport(public_method_name) + try: + with ws._lock: + ws._ensure_connected() + sub_key = None + existing_id = None + if ws_method_name == "unwatchOrderBook" and args: + sub_key = f"watchOrderBook:{args[0]}" + existing_id = ws._active_subs.get(sub_key) + request_id = existing_id or f"req-{uuid.uuid4().hex[:12]}" + message = { + "id": request_id, + "action": "unsubscribe", + "exchange": self.exchange_name, + "method": ws_method_name, + "args": args, + } + ws._ws.send(json.dumps(message)) + + if sub_key: + existing_id = ws._active_subs.pop(sub_key, None) + if existing_id: + ws._subscriptions.pop(existing_id, None) + ws._data_store.pop(existing_id, None) + except PmxtError: + raise + except Exception as e: + self._ws_unsupported = True + raise self._ws_required_error(public_method_name) from e + def watch_order_book( self, outcome_id: Union[str, "MarketOutcome"] = _UNSET, @@ -1765,8 +1828,7 @@ def watch_order_book( Returns a promise that resolves with the next order book update. Call repeatedly in a loop to stream updates (CCXT Pro pattern). - Prefers the sidecar WebSocket transport when available, falling - back to HTTP long-polling for older sidecars. + Requires the sidecar WebSocket transport. Args: outcome_id: Outcome ID to watch @@ -1793,36 +1855,12 @@ def watch_order_book( args.append(None) args.append(params) - # Try WebSocket transport first - ws_data = self._watch_via_ws("watchOrderBook", args) - if ws_data is not None: - return _convert_order_book(ws_data) - - # HTTP fallback - try: - body: Dict[str, Any] = {"args": args} - - creds = self._get_credentials_dict() - if creds: - body["credentials"] = creds - - headers = {"Content-Type": "application/json", "Accept": "application/json"} - headers.update(self._get_auth_headers()) - - url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/watchOrderBook" - response = self._fetch_with_retry( - lambda: self._api_client.call_api( - method="POST", - url=url, - body=body, - header_params=headers, - ) - ) - response.read() - data = self._handle_response(json.loads(response.data)) - return _convert_order_book(data) - except ApiException as e: - raise self._parse_api_exception(e) from None + ws_data = self._watch_required_via_ws( + "watch_order_book", + "watchOrderBook", + args, + ) + return _convert_order_book(ws_data) def unwatch_order_book(self, outcome_id: Union[str, "MarketOutcome"]) -> None: """ @@ -1834,30 +1872,12 @@ def unwatch_order_book(self, outcome_id: Union[str, "MarketOutcome"]) -> None: Returns: None """ - try: - outcome_id = _resolve_outcome_id(outcome_id) - body: Dict[str, Any] = {"args": [outcome_id]} - - creds = self._get_credentials_dict() - if creds: - body["credentials"] = creds - - headers = {"Content-Type": "application/json", "Accept": "application/json"} - headers.update(self._get_auth_headers()) - - url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/unwatchOrderBook" - response = self._fetch_with_retry( - lambda: self._api_client.call_api( - method="POST", - url=url, - body=body, - header_params=headers, - ) - ) - response.read() - return self._handle_response(json.loads(response.data)) - except ApiException as e: - raise self._parse_api_exception(e) from None + outcome_id = _resolve_outcome_id(outcome_id) + self._unwatch_required_via_ws( + "unwatch_order_book", + "unwatchOrderBook", + [outcome_id], + ) def watch_order_books( self, @@ -1873,8 +1893,7 @@ def watch_order_books( order book snapshot. Call repeatedly in a loop to stream updates (CCXT Pro pattern). - Prefers the sidecar WebSocket transport when available, falling - back to HTTP POST for older sidecars. + Requires the sidecar WebSocket transport. Args: outcome_ids: List of outcome IDs (or MarketOutcome objects) @@ -1913,53 +1932,18 @@ def watch_order_books( args.append(None) args.append(params) - # Try WebSocket transport first - ws = self._get_or_create_ws() - if ws is not None: - try: - raw_result = ws.subscribe_batch( - exchange=self.exchange_name, - method="watchOrderBooks", - args=args, - credentials=self._get_credentials_dict(), - ) - if isinstance(raw_result, dict): - return { - k: _convert_order_book(v) - for k, v in raw_result.items() - if isinstance(v, dict) - } - except Exception: - pass # fall through to HTTP - - # HTTP fallback - try: - body: Dict[str, Any] = {"args": args} - creds = self._get_credentials_dict() - if creds: - body["credentials"] = creds - - headers = {"Content-Type": "application/json", "Accept": "application/json"} - headers.update(self._get_auth_headers()) - - url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/watchOrderBooks" - response = self._fetch_with_retry( - lambda: self._api_client.call_api( - method="POST", - url=url, - body=body, - header_params=headers, - ) - ) - response.read() - data = self._handle_response(json.loads(response.data)) - if isinstance(data, dict): - return { - k: _convert_order_book(v) for k, v in data.items() - } - return {} - except ApiException as e: - raise self._parse_api_exception(e) from None + raw_result = self._watch_batch_required_via_ws( + "watch_order_books", + "watchOrderBooks", + args, + ) + if isinstance(raw_result, dict): + return { + k: _convert_order_book(v) + for k, v in raw_result.items() + if isinstance(v, dict) + } + return {} def watch_all_order_books( self, @@ -2027,41 +2011,24 @@ def watch_trades( ... for trade in trades: ... print(f"Trade: {trade.price} @ {trade.amount}") """ - try: - outcome_id = _compat_id(outcome_id, _compat_kwargs) - outcome_id = _resolve_outcome_id(outcome_id) - args: List[Any] = [outcome_id] - if address is not None: - args.append(address) - if since is not None: - args.append(since) - if limit is not None: - args.append(limit) - - body: Dict[str, Any] = {"args": args} - - # Add credentials if available - creds = self._get_credentials_dict() - if creds: - body["credentials"] = creds - - headers = {"Content-Type": "application/json", "Accept": "application/json"} - headers.update(self._get_auth_headers()) + outcome_id = _compat_id(outcome_id, _compat_kwargs) + outcome_id = _resolve_outcome_id(outcome_id) + args: List[Any] = [outcome_id] + if address is not None: + args.append(address) + if since is not None: + args.append(since) + if limit is not None: + args.append(limit) - url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/watchTrades" - response = self._fetch_with_retry( - lambda: self._api_client.call_api( - method="POST", - url=url, - body=body, - header_params=headers, - ) - ) - response.read() - data = self._handle_response(json.loads(response.data)) - return [_convert_trade(t) for t in data] - except ApiException as e: - raise self._parse_api_exception(e) from None + data = self._watch_required_via_ws( + "watch_trades", + "watchTrades", + args, + ) + if not isinstance(data, list): + raise PmxtError("watch_trades() expected WebSocket trade list") + return [_convert_trade(t) for t in data] def watch_address( self, diff --git a/sdks/python/tests/test_streaming_ws_transport.py b/sdks/python/tests/test_streaming_ws_transport.py new file mode 100644 index 00000000..0db2a126 --- /dev/null +++ b/sdks/python/tests/test_streaming_ws_transport.py @@ -0,0 +1,163 @@ +import json +import threading + +import pytest + +from pmxt.client import Exchange +from pmxt.errors import PmxtError +from pmxt.models import OrderBook, Trade + + +def _exchange() -> Exchange: + return Exchange("mock", base_url="http://127.0.0.1:9", auto_start_server=False) + + +def _raw_order_book() -> dict: + return { + "bids": [{"price": 0.4, "size": 10}], + "asks": [{"price": 0.6, "size": 12}], + "timestamp": 123, + } + + +def _raw_trade() -> dict: + return { + "id": "trade-1", + "timestamp": 123, + "price": 0.55, + "amount": 4, + "side": "buy", + } + + +@pytest.mark.parametrize( + ("method_name", "args", "message"), + [ + ("watch_order_book", ("outcome-1",), "watch_order_book() requires WebSocket transport"), + ("watch_order_books", (["outcome-1"],), "watch_order_books() requires WebSocket transport"), + ("watch_trades", ("outcome-1",), "watch_trades() requires WebSocket transport"), + ("unwatch_order_book", ("outcome-1",), "unwatch_order_book() requires WebSocket transport"), + ], +) +def test_streaming_methods_require_websocket_transport(monkeypatch, method_name, args, message): + exchange = _exchange() + monkeypatch.setattr(exchange, "_get_or_create_ws", lambda: None) + monkeypatch.setattr( + exchange, + "_fetch_with_retry", + lambda _fn: pytest.fail("streaming method attempted HTTP fallback"), + ) + + with pytest.raises(PmxtError) as excinfo: + getattr(exchange, method_name)(*args) + assert message in str(excinfo.value) + + +def test_watch_order_book_uses_websocket_transport(monkeypatch): + exchange = _exchange() + calls = [] + monkeypatch.setattr( + exchange, + "_watch_via_ws", + lambda method, args: calls.append((method, args)) or _raw_order_book(), + ) + monkeypatch.setattr( + exchange, + "_fetch_with_retry", + lambda _fn: pytest.fail("watch_order_book attempted HTTP fallback"), + ) + + book = exchange.watch_order_book("outcome-1", limit=5, params={"depth": "full"}) + + assert calls == [("watchOrderBook", ["outcome-1", 5, {"depth": "full"}])] + assert isinstance(book, OrderBook) + assert book.bids[0].price == 0.4 + + +def test_watch_order_books_uses_websocket_batch_transport(monkeypatch): + exchange = _exchange() + + class FakeWs: + connected = True + + def subscribe_batch(self, **kwargs): + self.kwargs = kwargs + return {"outcome-1": _raw_order_book()} + + fake_ws = FakeWs() + monkeypatch.setattr(exchange, "_get_or_create_ws", lambda: fake_ws) + monkeypatch.setattr( + exchange, + "_fetch_with_retry", + lambda _fn: pytest.fail("watch_order_books attempted HTTP fallback"), + ) + + books = exchange.watch_order_books(["outcome-1"], limit=3) + + assert fake_ws.kwargs["method"] == "watchOrderBooks" + assert fake_ws.kwargs["args"] == [["outcome-1"], 3] + assert isinstance(books["outcome-1"], OrderBook) + + +def test_watch_trades_uses_websocket_transport(monkeypatch): + exchange = _exchange() + calls = [] + monkeypatch.setattr( + exchange, + "_watch_via_ws", + lambda method, args: calls.append((method, args)) or [_raw_trade()], + ) + monkeypatch.setattr( + exchange, + "_fetch_with_retry", + lambda _fn: pytest.fail("watch_trades attempted HTTP fallback"), + ) + + trades = exchange.watch_trades("outcome-1", address="0xabc", since=10, limit=2) + + assert calls == [("watchTrades", ["outcome-1", "0xabc", 10, 2])] + assert isinstance(trades[0], Trade) + assert trades[0].id == "trade-1" + + +def test_unwatch_order_book_sends_websocket_unsubscribe(monkeypatch): + exchange = _exchange() + + class FakeSocket: + def __init__(self): + self.sent = [] + + def send(self, raw): + self.sent.append(json.loads(raw)) + + class FakeWs: + connected = True + + def __init__(self): + self._lock = threading.Lock() + self._ws = FakeSocket() + self._active_subs = {"watchOrderBook:outcome-1": "req-existing"} + self._subscriptions = {"req-existing": object()} + self._data_store = {"req-existing": _raw_order_book()} + + def _ensure_connected(self): + return None + + fake_ws = FakeWs() + monkeypatch.setattr(exchange, "_get_or_create_ws", lambda: fake_ws) + monkeypatch.setattr( + exchange, + "_fetch_with_retry", + lambda _fn: pytest.fail("unwatch_order_book attempted HTTP fallback"), + ) + + exchange.unwatch_order_book("outcome-1") + + assert fake_ws._ws.sent[0]["action"] == "unsubscribe" + assert fake_ws._ws.sent[0]["id"] == "req-existing" + assert fake_ws._ws.sent[0]["exchange"] == "mock" + assert fake_ws._ws.sent[0]["method"] == "unwatchOrderBook" + assert fake_ws._ws.sent[0]["args"] == ["outcome-1"] + assert "watchOrderBook:outcome-1" not in fake_ws._active_subs + assert "req-existing" not in fake_ws._subscriptions + assert "req-existing" not in fake_ws._data_store diff --git a/sdks/typescript/index.ts b/sdks/typescript/index.ts index 37ae79bb..d1c843ea 100644 --- a/sdks/typescript/index.ts +++ b/sdks/typescript/index.ts @@ -25,7 +25,7 @@ import { ServerManager } from "./pmxt/server-manager.js"; import * as models from "./pmxt/models.js"; import * as errors from "./pmxt/errors.js"; -export { Exchange, Polymarket, Kalshi, KalshiDemo, Limitless, Myriad, Probable, Baozi, Opinion, Metaculus, Smarkets, PolymarketUS, GeminiTitan, Hyperliquid, Mock, PolymarketOptions } from "./pmxt/client.js"; +export { Exchange, Polymarket, Kalshi, KalshiDemo, Limitless, Myriad, Probable, Baozi, Opinion, Metaculus, Smarkets, PolymarketUS, GeminiTitan, Hyperliquid, SuiBets, Mock, PolymarketOptions } from "./pmxt/client.js"; export { Router } from "./pmxt/router.js"; export { ServerManager } from "./pmxt/server-manager.js"; export { MarketList } from "./pmxt/models.js"; diff --git a/sdks/typescript/pmxt/client.ts b/sdks/typescript/pmxt/client.ts index a5c5c0f5..b9ec7311 100644 --- a/sdks/typescript/pmxt/client.ts +++ b/sdks/typescript/pmxt/client.ts @@ -52,6 +52,18 @@ import { PmxtError, fromServerError } from "./errors.js"; import { LOCAL_URL, resolvePmxtBaseUrl } from "./constants.js"; import { SidecarWsClient } from "./ws-client.js"; +interface RawWebSocketLike { + send(data: string): void; +} + +interface SidecarWsClientInternals { + ensureConnected(): Promise; + ws: RawWebSocketLike | null; + activeSubs: Map; + subscriptions: Map void) | null }>; + dataStore: Map; +} + /** * Resolve a MarketOutcome shorthand to a plain outcome ID string. * Accepts either a raw string ID or a MarketOutcome object. @@ -418,7 +430,7 @@ export abstract class Exchange { * Return the shared WebSocket client, creating it on first use. * * Returns `null` if the sidecar /ws endpoint was previously found - * to be unavailable, letting callers fall back to HTTP. + * to be unavailable. */ private async getOrCreateWs(): Promise { if (this._wsUnsupported) return null; @@ -480,14 +492,79 @@ export abstract class Exchange { this.getCredentials() as Record | undefined, ); } catch (error) { - // Only fall back to HTTP for transport-level failures - if (error instanceof PmxtError && /connection failed|no websocket/i.test(error.message)) { + if (this.isWsTransportUnavailableError(error)) { return null; } throw error; } } + private wsTransportUnavailableError(method: string): PmxtError { + return new PmxtError(`${method}() requires WebSocket transport — connection failed`); + } + + private isWsTransportUnavailableError(error: unknown): boolean { + return error instanceof PmxtError + && /connection failed|no websocket|websocket.*not connected/i.test(error.message); + } + + private getWsInternals(ws: SidecarWsClient): SidecarWsClientInternals { + return ws as unknown as SidecarWsClientInternals; + } + + private wsSubscriptionKey(method: string, args: any[]): string { + const firstArg = args[0] ?? ""; + return Array.isArray(firstArg) + ? `${method}:${[...firstArg].sort().join(",")}` + : `${method}:${firstArg}`; + } + + private getWsSubscriptionId(ws: SidecarWsClient, method: string, args: any[]): string | undefined { + const internals = this.getWsInternals(ws); + const subKey = this.wsSubscriptionKey(method, args); + return internals.activeSubs.get(subKey); + } + + private clearWsSubscription(ws: SidecarWsClient, method: string, args: any[]): void { + const internals = this.getWsInternals(ws); + const subKey = this.wsSubscriptionKey(method, args); + const requestId = internals.activeSubs.get(subKey); + if (!requestId) return; + + const sub = internals.subscriptions.get(requestId); + if (sub?.reject) { + sub.reject(new PmxtError(`${method} subscription cancelled`)); + } + + internals.activeSubs.delete(subKey); + internals.subscriptions.delete(requestId); + internals.dataStore.delete(requestId); + + const firstArg = args[0] ?? ""; + const symbols = Array.isArray(firstArg) + ? firstArg.map(String) + : firstArg + ? [String(firstArg)] + : []; + for (const symbol of symbols) { + internals.dataStore.delete(`${requestId}:${symbol}`); + } + } + + private async sendWsMessage( + ws: SidecarWsClient, + message: Record, + ): Promise { + const internals = this.getWsInternals(ws); + await internals.ensureConnected(); + + const socket = internals.ws; + if (!socket) { + throw new PmxtError('[ws-client] Cannot send: WebSocket not connected'); + } + socket.send(JSON.stringify(message)); + } + // Low-Level API Access /** @@ -1098,24 +1175,32 @@ export abstract class Exchange { async unwatchOrderBook(outcomeId: string | MarketOutcome): Promise { await this.initPromise; + const resolvedOutcomeId = resolveOutcomeId(outcomeId); + const args: any[] = [resolvedOutcomeId]; try { - const args: any[] = []; - args.push(resolveOutcomeId(outcomeId)); - const response = await this.fetchWithRetry(`${this.resolveBaseUrl()}/api/${this.exchangeName}/unwatchOrderBook`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() }, - body: JSON.stringify({ args, credentials: this.getCredentials() }), - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - if (body.error && typeof body.error === "object") { - throw fromServerError(body.error); - } - throw new PmxtError(body.error?.message || response.statusText); + const ws = await this.getOrCreateWs(); + if (!ws) { + throw this.wsTransportUnavailableError("unwatchOrderBook"); } - const json = await response.json(); - this.handleResponse(json); + + const requestId = this.getWsSubscriptionId(ws, "watchOrderBook", args) + ?? `req-${Math.random().toString(36).slice(2, 14)}`; + + await this.sendWsMessage( + ws, + { + id: requestId, + action: "unsubscribe", + exchange: this.exchangeName, + method: "unwatchOrderBook", + args, + }, + ); + this.clearWsSubscription(ws, "watchOrderBook", args); } catch (error) { + if (this.isWsTransportUnavailableError(error)) { + throw this.wsTransportUnavailableError("unwatchOrderBook"); + } if (error instanceof PmxtError) throw error; throw new PmxtError(`Failed to unwatchOrderBook: ${error}`); } @@ -1521,33 +1606,12 @@ export abstract class Exchange { args.push(params); } - // Try WebSocket transport first const wsData = await this.watchViaWs("watchOrderBook", args); if (wsData !== null) { return convertOrderBook(wsData); } - // HTTP fallback - try { - const response = await this.fetchWithRetry(`${this.resolveBaseUrl()}/api/${this.exchangeName}/watchOrderBook`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() }, - body: JSON.stringify({ args, credentials: this.getCredentials() }), - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - if (body.error && typeof body.error === "object") { - throw fromServerError(body.error); - } - throw new PmxtError(body.error?.message || response.statusText); - } - const json = await response.json(); - const data = this.handleResponse(json); - return convertOrderBook(data); - } catch (error) { - if (error instanceof PmxtError) throw error; - throw new PmxtError(`Failed to watch order book: ${error}`); - } + throw this.wsTransportUnavailableError("watchOrderBook"); } /** @@ -1557,9 +1621,6 @@ export abstract class Exchange { * order book snapshot. Call repeatedly in a loop to stream updates * (CCXT Pro pattern). * - * Prefers the sidecar WebSocket transport when available, falling - * back to HTTP POST for older sidecars. - * * @param outcomeIds - Array of outcome IDs (or MarketOutcome objects) * @param limit - Optional depth limit for each order book * @param params - Optional exchange-specific parameters @@ -1594,61 +1655,33 @@ export abstract class Exchange { args.push(params); } - // Try WebSocket transport first - const ws = await this.getOrCreateWs(); - if (ws) { - try { - const rawResult = await ws.subscribeBatch( - this.exchangeName, - "watchOrderBooks", - args, - this.getCredentials() as Record | undefined, - ); - if (rawResult && typeof rawResult === "object") { - const result: Record = {}; - for (const [k, v] of Object.entries(rawResult)) { - if (v && typeof v === "object") { - result[k] = convertOrderBook(v); - } - } - return result; - } - } catch (error) { - // Only fall through to HTTP for transport-level WS failures - if (!(error instanceof PmxtError) || !/connection failed|no websocket/i.test(error.message)) { - throw error; - } + try { + const ws = await this.getOrCreateWs(); + if (!ws) { + throw this.wsTransportUnavailableError("watchOrderBooks"); } - } - // HTTP fallback - try { - const response = await this.fetchWithRetry( - `${this.resolveBaseUrl()}/api/${this.exchangeName}/watchOrderBooks`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() }, - body: JSON.stringify({ args, credentials: this.getCredentials() }), - }, + const rawResult = await ws.subscribeBatch( + this.exchangeName, + "watchOrderBooks", + args, + this.getCredentials() as Record | undefined, ); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - if (body.error && typeof body.error === "object") { - throw fromServerError(body.error); - } - throw new PmxtError(body.error?.message || response.statusText); - } - const json = await response.json(); - const data = this.handleResponse(json); - if (data && typeof data === "object") { + if (rawResult && typeof rawResult === "object") { const result: Record = {}; - for (const [k, v] of Object.entries(data as Record)) { - result[k] = convertOrderBook(v); + for (const [k, v] of Object.entries(rawResult)) { + if (v && typeof v === "object") { + result[k] = convertOrderBook(v); + } } return result; } + throw new PmxtError("watchOrderBooks: unexpected response shape from server"); } catch (error) { + if (this.isWsTransportUnavailableError(error)) { + throw this.wsTransportUnavailableError("watchOrderBooks"); + } if (error instanceof PmxtError) throw error; throw new PmxtError(`Failed to watch order books: ${error}`); } @@ -1690,7 +1723,7 @@ export abstract class Exchange { }; } - throw new PmxtError("watchAllOrderBooks() requires WebSocket transport — connection failed"); + throw this.wsTransportUnavailableError("watchAllOrderBooks"); } /** @deprecated Use {@link watchAllOrderBooks} instead. */ @@ -1729,37 +1762,23 @@ export abstract class Exchange { ): Promise { await this.initPromise; const resolvedOutcomeId = resolveOutcomeId(outcomeId); - try { - const args: any[] = [resolvedOutcomeId]; - if (address !== undefined) { - args.push(address); - } - if (since !== undefined) { - args.push(since); - } - if (limit !== undefined) { - args.push(limit); - } + const args: any[] = [resolvedOutcomeId]; + if (address !== undefined) { + args.push(address); + } + if (since !== undefined) { + args.push(since); + } + if (limit !== undefined) { + args.push(limit); + } - const response = await this.fetchWithRetry(`${this.resolveBaseUrl()}/api/${this.exchangeName}/watchTrades`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() }, - body: JSON.stringify({ args, credentials: this.getCredentials() }), - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - if (body.error && typeof body.error === "object") { - throw fromServerError(body.error); - } - throw new PmxtError(body.error?.message || response.statusText); - } - const json = await response.json(); - const data = this.handleResponse(json); - return data.map(convertTrade); - } catch (error) { - if (error instanceof PmxtError) throw error; - throw new PmxtError(`Failed to watch trades: ${error}`); + const wsData = await this.watchViaWs("watchTrades", args); + if (wsData !== null) { + return wsData.map(convertTrade); } + + throw this.wsTransportUnavailableError("watchTrades"); } /** @@ -2736,6 +2755,21 @@ export class Hyperliquid extends Exchange { } } +/** + * SuiBets exchange client. + * + * @example + * ```typescript + * const suibets = new SuiBets(); + * const markets = await suibets.fetchMarkets(); + * ``` + */ +export class SuiBets extends Exchange { + constructor(options: ExchangeOptions = {}) { + super("suibets", options); + } +} + /** * Mock exchange client. *