Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions core/src/exchanges/suibets/api.ts
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions core/src/exchanges/suibets/config.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
105 changes: 105 additions & 0 deletions core/src/exchanges/suibets/errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<ErrorMapper['mapError']> {
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();
227 changes: 227 additions & 0 deletions core/src/exchanges/suibets/fetcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
return (
(typeof v['id'] === 'string' || typeof v['id'] === 'number') &&
typeof v['creatorOdds'] === 'number' &&
typeof v['creatorStake'] === 'number'
);
}

export class SuibetsFetcher implements IExchangeFetcher<SuibetsRawOffer, SuibetsRawEvent> {
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<T>(path: string, params?: Record<string, string>): Promise<T> {
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<SuibetsRawOffer[]> {
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<string, string> = {
status: params?.status === 'all' ? 'all' : 'OPEN',
limit: String(params?.limit ?? 50),
offset: String(params?.offset ?? 0),
};

const queryParams: Record<string, string> = 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<SuibetsRawEvent[]> {
const queryParams: Record<string, string> = {
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<string, SuibetsRawOffer[]>();
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<SuibetsRawPositions> {
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 ?? [],
};
}
}
Loading