diff --git a/core/src/exchanges/suibets/api.ts b/core/src/exchanges/suibets/api.ts new file mode 100644 index 00000000..2cfe455d --- /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://www.suibets.com + * + * 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..9c5ac180 --- /dev/null +++ b/core/src/exchanges/suibets/config.ts @@ -0,0 +1,42 @@ +export const SUIBETS_BASE_URL = 'https://www.suibets.com'; + +// 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[] = ['www.suibets.com']; + +/** + * 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..45e39ef2 --- /dev/null +++ b/core/src/exchanges/suibets/errors.ts @@ -0,0 +1,105 @@ +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; + + // HTML body = hosting/gateway outage — not a missing resource. + // Return ExchangeNotAvailable so callers can distinguish + // "offer not found" from "upstream server is down". + const responseData = error.response?.data; + const isHtml = + typeof responseData === 'string' && + responseData.trimStart().startsWith('<'); + if (isHtml) { + return new ExchangeNotAvailable( + 'SuiBets API is unavailable. Check https://www.suibets.com for service status.', + this.exchangeName, + ); + } + + 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..63a90a82 --- /dev/null +++ b/core/src/exchanges/suibets/fetcher.ts @@ -0,0 +1,227 @@ +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[]; +} + + +/** + * Structured return type for fetchRawPositions. + * Keeps the three position-array types separate so each is normalised + * with the correct shape instead of being cast from unknown[]. + */ +export interface SuibetsRawPositions { + createdOffers: SuibetsRawOffer[]; + matchedBets: unknown[]; + parlays: unknown[]; +} + +/** + * Type guard: true only when the value has the core fields that + * SuibetsNormalizer.normalizePosition() reads (id, creatorOdds, creatorStake). + * Guards against silent garbage output when matchedBets or parlays + * are accidentally passed in as SuibetsRawOffer. + */ +export function isSuibetsRawOffer(value: unknown): value is SuibetsRawOffer { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + (typeof v['id'] === 'string' || typeof v['id'] === 'number') && + typeof v['creatorOdds'] === 'number' && + typeof v['creatorStake'] === 'number' + ); +} + +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. + * + * Returns each array separately and typed so that normalisation uses + * the correct shape per position type rather than casting from unknown[]. + */ + async fetchRawPositions(walletAddress: string): Promise { + const data = await this.get<{ + createdOffers?: unknown[]; + matchedBets?: unknown[]; + parlays?: unknown[]; + }>('/api/p2p/my', { wallet: walletAddress }); + + return { + createdOffers: (data.createdOffers ?? []).filter(isSuibetsRawOffer), + matchedBets: data.matchedBets ?? [], + parlays: data.parlays ?? [], + }; + } +} diff --git a/core/src/exchanges/suibets/index.ts b/core/src/exchanges/suibets/index.ts new file mode 100644 index 00000000..6ee276fe --- /dev/null +++ b/core/src/exchanges/suibets/index.ts @@ -0,0 +1,143 @@ +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, isSuibetsRawOffer } from './fetcher'; +import { SuibetsNormalizer } from './normalizer'; +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://www.suibets.com) */ + 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.createdOffers.map(r => this.normalizer.normalizePosition(r)); + } +} diff --git a/core/src/exchanges/suibets/normalizer.ts b/core/src/exchanges/suibets/normalizer.ts new file mode 100644 index 00000000..3885091e --- /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://www.suibets.com/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://www.suibets.com/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/sdks/typescript/pmxt/client.ts b/sdks/typescript/pmxt/client.ts index a5c5c0f5..b1017b57 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 /** @@ -632,6 +709,30 @@ export abstract class Exchange { return response.json(); } + /** + * Read a hosted catalog endpoint directly. + * + * Hosted-only Router APIs such as matched clusters are not part of the + * core sidecar method namespace. They live under /v0 and return their own + * response envelopes, so callers intentionally receive the raw JSON body. + */ + protected async catalogReadRequest(path: string, query: Record = {}): Promise { + const qs = buildSidecarQueryString(query); + const url = `${this.resolveBaseUrl()}${path}${qs ? `?${qs}` : ''}`; + const response = await this.fetchWithRetry(url, { + method: 'GET', + headers: this.getAuthHeaders(), + }); + 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); + } + return response.json(); + } + // BEGIN GENERATED METHODS async loadMarkets(reload: boolean = false): Promise> { @@ -1098,24 +1199,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 +1630,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 +1645,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 +1679,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 +1747,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 +1786,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 +2779,59 @@ export class Hyperliquid extends Exchange { } } +/** + * Options for the SuiBets exchange client. + */ +export interface SuiBetsOptions extends ExchangeOptions { + /** + * Sui wallet address (0x + 64 hex chars). + * Required for fetchPositions(). Can also be set via the + * SUIBETS_WALLET_ADDRESS environment variable on the sidecar. + */ + walletAddress?: string; +} + +/** + * SuiBets exchange client. + * + * SuiBets is a decentralised P2P sports betting exchange on Sui mainnet. + * No house edge. 2% platform fee. + * Contract: 0xd51fe151bec66a15b086a67c1cfce9b05759ddac1d73fcd3e14324ad202b2e59 + * + * @example + * ```typescript + * const suibets = new SuiBets(); + * const markets = await suibets.fetchMarkets({ limit: 20 }); + * + * // With wallet for fetchPositions() + * const me = new SuiBets({ walletAddress: '0xabc...' }); + * const positions = await me.fetchPositions(); + * ``` + */ +export class SuiBets extends Exchange { + private readonly _walletAddress?: string; + + constructor(options: SuiBetsOptions = {}) { + super("suibets", options); + this._walletAddress = options.walletAddress; + } + + /** + * Includes walletAddress in the credentials sent to the sidecar so + * that fetchPositions() can reach the /api/p2p/my endpoint. + * Falls back to SUIBETS_WALLET_ADDRESS env var on the sidecar side + * when walletAddress is not set here. + */ + protected override getCredentials(): ExchangeCredentials | undefined { + const base = super.getCredentials(); + if (!this._walletAddress) return base; + return { + ...(base ?? {}), + walletAddress: this._walletAddress, + } as ExchangeCredentials & { walletAddress: string }; + } +} + /** * Mock exchange client. *