import { DEFAULT_BASE_URL } from './constants'; import { DubsApiError, parseSolanaError, SOLANA_PROGRAM_ERRORS } from './errors'; import type { UnifiedEvent, Pagination, EsportsMatchDetail, ValidateEventResult, CreateGameParams, CreateGameResult, CreateCustomGameParams, CreateCustomGameResult, JoinGameParams, JoinGameResult, ConfirmGameParams, ConfirmGameResult, BuildClaimParams, BuildClaimResult, ConfirmClaimParams, ConfirmClaimResult, GameDetail, GameListItem, GetGamesParams, GetNetworkGamesParams, GetUpcomingEventsParams, ParsedError, SolanaErrorCode, NonceResult, AuthenticateParams, AuthenticateResult, RegisterParams, RegisterResult, DubsUser, DubsPublicUser, DubsAppUser, CheckUsernameResult, LiveScore, UiConfig, WhatsNewPost, UFCEvent, UFCFighterDetail, ArcadePool, ArcadeEntry, ArcadeLeaderboardEntry, ArcadePoolStats, ArcadePoolResult, StartAttemptResult, SubmitScoreResult, BuildArcadeEntryResult, JackpotRound, JackpotLastWinner, JackpotEntry, JackpotRoundResult, JackpotConfig, BuildJackpotEnterResult, ConfirmJackpotEnterResult, PromoCredit, } from './types'; export interface DubsClientConfig { apiKey: string; baseUrl?: string; } export class DubsClient { private readonly apiKey: string; private readonly baseUrl: string; private _token: string | null = null; constructor(config: DubsClientConfig) { this.apiKey = config.apiKey; this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); } /** Set the user JWT token for authenticated requests */ setToken(token: string | null): void { this._token = token; } /** Get the current user JWT token */ getToken(): string | null { return this._token; } // ── Private HTTP helper ── private async request(method: string, path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json', }; if (this._token) { headers['Authorization'] = `Bearer ${this._token}`; } console.log(`[DubsClient] ${method} ${url}`, body ? JSON.stringify(body).slice(0, 200) : ''); const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); console.log(`[DubsClient] ${method} ${path} → ${res.status}`, text.slice(0, 300)); let json: any; try { json = JSON.parse(text); } catch { throw new DubsApiError('parse_error', `Invalid JSON response: ${text.slice(0, 100)}`, res.status); } if (!json.success) { const err = json.error; if (typeof err === 'object' && err !== null) { throw new DubsApiError(err.code || 'unknown', err.message || 'Unknown error', res.status); } throw new DubsApiError('unknown', typeof err === 'string' ? err : 'Request failed', res.status); } return json as T; } // ── Events ── async getUpcomingEvents(params?: GetUpcomingEventsParams): Promise<{ events: UnifiedEvent[]; pagination: Pagination }> { const qs = new URLSearchParams(); if (params?.type) qs.set('type', params.type); if (params?.game) qs.set('game', params.game); if (params?.page) qs.set('page', String(params.page)); if (params?.per_page) qs.set('per_page', String(params.per_page)); const query = qs.toString(); const res = await this.request<{ success: true; events: UnifiedEvent[]; pagination: Pagination }>( 'GET', `/events/upcoming${query ? `?${query}` : ''}`, ); return { events: res.events, pagination: res.pagination }; } async getSportsEvents(league: string): Promise { const res = await this.request<{ success: true; league: string; events: UnifiedEvent[] }>( 'GET', `/sports/events/${encodeURIComponent(league)}`, ); return res.events; } async getEsportsMatches(videogame?: string): Promise { const qs = videogame ? `?videogame=${encodeURIComponent(videogame)}` : ''; const res = await this.request<{ success: true; matches: UnifiedEvent[] }>( 'GET', `/esports/matches/upcoming${qs}`, ); return res.matches; } async getEsportsMatchDetail(matchId: string | number): Promise { const res = await this.request<{ success: true; match: EsportsMatchDetail }>( 'GET', `/esports/matches/${encodeURIComponent(String(matchId))}`, ); return res.match; } // ── UFC ── async getUFCFightCard(): Promise { const res = await this.request<{ success: true; events: UFCEvent[] }>( 'GET', '/ufc/fightcard', ); return res.events; } async getUFCFighterDetail(athleteId: string): Promise { const res = await this.request<{ success: true; fighter: UFCFighterDetail }>( 'GET', `/ufc/fighters/${encodeURIComponent(athleteId)}`, ); return res.fighter; } // ── Game Lifecycle ── async validateEvent(id: string): Promise { const res = await this.request<{ success: true } & ValidateEventResult>( 'POST', '/games/validate', { id }, ); return { bettable: res.bettable, gameMode: res.gameMode, lockTimestamp: res.lockTimestamp, startTime: res.startTime, status: res.status, }; } async createGame(params: CreateGameParams): Promise { // Sponsored path: spend an active streak credit instead of paying // rent + buy-in. Server picks oldest FIFO credit, returns the // partial-signed compound tx + promoCode so confirmGame can mark // the credit as used after the on-chain confirm. if (params.useCredit) { const res = await this.request<{ success: true; gameId: string; gameAddress: string; transaction: string; lockTimestamp: number; event: UnifiedEvent; promoCode: string; sponsorWallet?: string; wagerAmount: number; }>('POST', '/games/create-sponsored', { id: params.id, teamChoice: params.teamChoice, }); return { gameId: res.gameId, gameAddress: res.gameAddress, transaction: res.transaction, lockTimestamp: res.lockTimestamp, event: res.event, promoCode: res.promoCode, sponsorWallet: res.sponsorWallet, wagerAmount: res.wagerAmount, }; } const res = await this.request<{ success: true } & CreateGameResult>( 'POST', '/games/create', params, ); return { gameId: res.gameId, gameAddress: res.gameAddress, transaction: res.transaction, lockTimestamp: res.lockTimestamp, event: res.event, }; } async joinGame(params: JoinGameParams): Promise { // Sponsored path: spend an active streak credit instead of buy-in. // Server picks the user's oldest active credit (FIFO); we just flag // the intent. Result includes promoCode for the confirm step to // mark as used. if (params.useCredit) { const res = await this.request<{ success: true; gameId: string; transaction: string; gameAddress: string; promoCode: string; sponsorWallet?: string; }>('POST', '/games/join-sponsored', { gameId: params.gameId, teamChoice: params.teamChoice, }); return { gameId: res.gameId, transaction: res.transaction, gameAddress: res.gameAddress, promoCode: res.promoCode, sponsorWallet: res.sponsorWallet, }; } const res = await this.request<{ success: true } & JoinGameResult>( 'POST', '/games/join', params, ); return { gameId: res.gameId, transaction: res.transaction, gameAddress: res.gameAddress, }; } async confirmGame(params: ConfirmGameParams): Promise { const res = await this.request<{ success: true } & ConfirmGameResult>( 'POST', '/games/confirm', params, ); return { gameId: res.gameId, signature: res.signature, explorerUrl: res.explorerUrl, message: res.message, }; } // ── Custom Game Lifecycle (game_mode=6) ── async createCustomGame(params: CreateCustomGameParams): Promise { const res = await this.request<{ success: true } & CreateCustomGameResult>( 'POST', '/games/custom/create', params, ); return { gameId: res.gameId, gameAddress: res.gameAddress, transaction: res.transaction, lockTimestamp: res.lockTimestamp, }; } async confirmCustomGame(params: ConfirmGameParams): Promise { const res = await this.request<{ success: true } & ConfirmGameResult>( 'POST', '/games/custom/confirm', params, ); return { gameId: res.gameId, signature: res.signature, explorerUrl: res.explorerUrl, message: res.message, }; } async buildClaimTransaction(params: BuildClaimParams): Promise { const res = await this.request<{ success: true } & BuildClaimResult>( 'POST', '/transactions/build/claim', params, ); return { transaction: res.transaction, gameAddress: res.gameAddress, message: res.message, }; } async confirmClaim(gameId: string, params: ConfirmClaimParams): Promise { const res = await this.request<{ success: true } & ConfirmClaimResult>( 'POST', `/games/${encodeURIComponent(gameId)}/claim/confirm`, params, ); return { gameId: res.gameId, signature: res.signature, explorerUrl: res.explorerUrl, message: res.message, }; } // ── Game Queries ── async getGame(gameId: string): Promise { const res = await this.request<{ success: true; game: GameDetail }>( 'GET', `/games/${encodeURIComponent(gameId)}`, ); return res.game; } async getLiveScore(gameId: string): Promise { const res = await this.request<{ success: true; liveScore: LiveScore | null }>( 'GET', `/games/${encodeURIComponent(gameId)}/live-score`, ); return res.liveScore; } async getGames(params?: GetGamesParams): Promise { const qs = new URLSearchParams(); if (params?.wallet) qs.set('wallet', params.wallet); if (params?.status) qs.set('status', params.status); if (params?.limit != null) qs.set('limit', String(params.limit)); if (params?.offset != null) qs.set('offset', String(params.offset)); const query = qs.toString(); const res = await this.request<{ success: true; games: GameListItem[] }>( 'GET', `/games${query ? `?${query}` : ''}`, ); return res.games; } // ── Network ── async getNetworkGames(params?: GetNetworkGamesParams): Promise<{ games: GameListItem[]; pagination: Pagination }> { const qs = new URLSearchParams(); if (params?.league) qs.set('league', params.league); if (params?.exclude_wallet) qs.set('exclude_wallet', params.exclude_wallet); if (params?.limit != null) qs.set('limit', String(params.limit)); if (params?.offset != null) qs.set('offset', String(params.offset)); const query = qs.toString(); const res = await this.request<{ success: true; games: GameListItem[]; pagination: Pagination }>( 'GET', `/network/games${query ? `?${query}` : ''}`, ); return { games: res.games, pagination: res.pagination }; } // ── User Auth ── async getNonce(walletAddress: string): Promise { const res = await this.request<{ success: true; data: NonceResult }>( 'POST', '/auth/nonce', { walletAddress }, ); return res.data; } async authenticate(params: AuthenticateParams): Promise { const res = await this.request<{ success: true; data: AuthenticateResult }>( 'POST', '/auth/authenticate', params, ); // Auto-store token on successful auth if (res.data.token) { this._token = res.data.token; } return res.data; } async register(params: RegisterParams): Promise { const res = await this.request<{ success: true; data: RegisterResult }>( 'POST', '/auth/register', params, ); // Auto-store token on successful registration if (res.data.token) { this._token = res.data.token; } return res.data; } async getMe(): Promise { const res = await this.request<{ success: true; data: { user: DubsUser } }>( 'GET', '/auth/me', ); return res.data.user; } async getUser(walletAddress: string): Promise { const res = await this.request<{ success: true; data: { user: DubsPublicUser | null } }>( 'GET', `/users/${encodeURIComponent(walletAddress)}`, ); return res.data.user; } async getAppUsers(params?: { limit?: number; offset?: number }): Promise<{ users: DubsAppUser[]; pagination: { total: number; limit: number; offset: number } }> { const qs = new URLSearchParams(); if (params?.limit != null) qs.set('limit', String(params.limit)); if (params?.offset != null) qs.set('offset', String(params.offset)); const query = qs.toString(); const res = await this.request<{ success: true; data: { users: DubsAppUser[]; pagination: { total: number; limit: number; offset: number } } }>( 'GET', `/users${query ? `?${query}` : ''}`, ); return res.data; } async checkUsername(username: string): Promise { const res = await this.request<{ success: true; data: CheckUsernameResult }>( 'GET', `/auth/check-username/${encodeURIComponent(username)}`, ); return res.data; } async logout(): Promise { await this.request<{ success: true; data: { message: string } }>( 'POST', '/auth/logout', ); this._token = null; } // ── Push Notifications ── async registerPushToken(params: { token: string; platform: string; deviceName?: string }): Promise { await this.request<{ success: true; data: { tokenId: number; token: string } }>( 'POST', '/push/expo-token', params, ); } async unregisterPushToken(token: string): Promise { await this.request<{ success: true; data: { message: string } }>( 'DELETE', '/push/expo-token', { token }, ); } // ── Profile ── async updateProfile(params: { avatar?: string }): Promise { const res = await this.request<{ success: true; data: { user: DubsUser } }>( 'PATCH', '/auth/profile', params, ); return res.data.user; } // ── Error Utilities ── async parseError(error: unknown): Promise { const res = await this.request<{ success: true; error: ParsedError }>( 'POST', '/errors/parse', { error }, ); return res.error; } async getErrorCodes(): Promise> { const res = await this.request<{ success: true; errors: Record }>( 'GET', '/errors/codes', ); return res.errors; } /** * Parse a Solana error locally without an API call. * Useful for instant feedback in the UI. */ parseErrorLocal(error: unknown): ParsedError { return parseSolanaError(error); } /** Get the full local error code map */ getErrorCodesLocal(): Record { return { ...SOLANA_PROGRAM_ERRORS }; } // ── Arcade Pools ── async getArcadePools(params?: { gameSlug?: string; status?: string }): Promise { const qs = new URLSearchParams(); if (params?.gameSlug) qs.set('gameSlug', params.gameSlug); if (params?.status) qs.set('status', params.status); const query = qs.toString(); const res = await this.request<{ success: true; pools: ArcadePool[] }>( 'GET', `/arcade/pools${query ? `?${query}` : ''}`, ); return res.pools; } async getArcadePool(poolId: number): Promise<{ pool: ArcadePool; stats: ArcadePoolStats }> { const res = await this.request<{ success: true; pool: ArcadePool; stats: ArcadePoolStats }>( 'GET', `/arcade/pools/${poolId}`, ); return { pool: res.pool, stats: res.stats }; } /** Build unsigned entry transaction for an arcade pool */ async buildArcadeEntry(poolId: number, walletAddress: string): Promise { const res = await this.request<{ success: true } & BuildArcadeEntryResult>( 'POST', `/arcade/pools/${poolId}/build-enter`, { walletAddress }, ); return { transaction: res.transaction, poolId: res.poolId, amount: res.amount, destination: res.destination, gameId: res.gameId, gameAddress: res.gameAddress, }; } async enterArcadePool(poolId: number, params: { walletAddress: string; txSignature: string; gameId?: string; gameAddress?: string }): Promise { const res = await this.request<{ success: true; entry: ArcadeEntry }>( 'POST', `/arcade/pools/${poolId}/enter`, params, ); return res.entry; } async startArcadeAttempt(poolId: number, walletAddress: string): Promise { const res = await this.request<{ success: true } & StartAttemptResult>( 'POST', `/arcade/pools/${poolId}/start-attempt`, { walletAddress }, ); return { sessionToken: res.sessionToken, attemptNumber: res.attemptNumber, livesRemaining: res.livesRemaining }; } async submitArcadeScore(poolId: number, params: { walletAddress: string; sessionToken: string; score: number; durationMs?: number }): Promise { const res = await this.request<{ success: true } & SubmitScoreResult>( 'POST', `/arcade/pools/${poolId}/submit-score`, params, ); return { score: res.score, bestScore: res.bestScore, livesUsed: res.livesUsed, isNewBest: res.isNewBest }; } async getArcadeLeaderboard(poolId: number, params?: { limit?: number; offset?: number }): Promise { const qs = new URLSearchParams(); if (params?.limit != null) qs.set('limit', String(params.limit)); if (params?.offset != null) qs.set('offset', String(params.offset)); const query = qs.toString(); const res = await this.request<{ success: true; leaderboard: ArcadeLeaderboardEntry[] }>( 'GET', `/arcade/pools/${poolId}/leaderboard${query ? `?${query}` : ''}`, ); return res.leaderboard; } async getArcadeResults(poolId: number): Promise { const res = await this.request<{ success: true; results: ArcadePoolResult[] }>( 'GET', `/arcade/pools/${poolId}/results`, ); return res.results; } async getArcadeEntry(poolId: number, walletAddress: string): Promise { const res = await this.request<{ success: true; entry: ArcadeEntry }>( 'GET', `/arcade/pools/${poolId}/my-entry?walletAddress=${encodeURIComponent(walletAddress)}`, ); return res.entry; } // ── Jackpot ── /** Get current jackpot round + last winner */ async getJackpotCurrent(): Promise<{ round: JackpotRound | null; lastWinner: JackpotLastWinner | null }> { const res = await this.request<{ success: true; round: JackpotRound | null; lastWinner: JackpotLastWinner | null }>( 'GET', '/jackpot/current', ); return { round: res.round, lastWinner: res.lastWinner }; } /** * List the authenticated user's active promo credits — codes that * have been reserved to them but not yet used in a game. Includes * streak-milestone unlocks (auto-minted at each 1000-dub crossing) * and any manually-reserved Twitter giveaway codes. */ async getCredits(): Promise<{ credits: PromoCredit[]; totalLamports: number; totalSOL: number }> { const res = await this.request<{ success: true; credits: PromoCredit[]; totalLamports: number; totalSOL: number; }>('GET', '/me/credits'); return { credits: res.credits, totalLamports: res.totalLamports, totalSOL: res.totalSOL }; } /** Get current round entries with odds */ async getJackpotEntries(): Promise<{ roundId: string; entries: JackpotEntry[]; totalEntries: number }> { const res = await this.request<{ success: true; roundId: string; entries: JackpotEntry[]; totalEntries: number }>( 'GET', '/jackpot/entries', ); return { roundId: res.roundId, entries: res.entries, totalEntries: res.totalEntries }; } /** Get recent jackpot round results */ async getJackpotHistory(limit?: number): Promise { const qs = limit ? `?limit=${limit}` : ''; const res = await this.request<{ success: true; rounds: JackpotRoundResult[] }>( 'GET', `/jackpot/history${qs}`, ); return res.rounds; } /** Get jackpot protocol config */ async getJackpotConfig(): Promise { const res = await this.request<{ success: true; config: JackpotConfig }>( 'GET', '/jackpot/config', ); return res.config; } /** Build unsigned jackpot enter transaction */ async buildJackpotEnter(playerAddress: string, amount: number): Promise { const res = await this.request<{ success: true } & BuildJackpotEnterResult>( 'POST', '/jackpot/build-enter', { playerAddress, amount }, ); return { transaction: res.transaction, roundId: res.roundId, amount: res.amount, amountSol: res.amountSol }; } /** Confirm jackpot entry after wallet signs (creates developer attribution) */ async confirmJackpotEnter(params: { playerAddress: string; roundId?: string; amount?: number; signature: string }): Promise { const res = await this.request<{ success: true } & ConfirmJackpotEnterResult>( 'POST', '/jackpot/confirm-enter', params, ); return { attributed: res.attributed, appId: res.appId, signature: res.signature }; } // ── Chat ── /** Get recent global chat messages */ async getChatMessages(params?: { limit?: number; before?: number }): Promise<{ messages: any[]; hasMore: boolean }> { const qs = new URLSearchParams(); if (params?.limit) qs.set('limit', String(params.limit)); if (params?.before) qs.set('before', String(params.before)); const query = qs.toString(); return this.request('GET', `/chat/messages${query ? `?${query}` : ''}`); } /** * Send a message to global chat. Mirrors the same payload accepted by * the chat socket's `send_message` event so REST callers (e.g. the * useCreateGame auto-share) can attach a `gameInvite` chip, GIF, P&L * share, payment signature, or @mention IDs alongside the text. */ async sendChatMessage(params: { message: string; replyToId?: number; gameInvite?: Record; mentions?: number[]; gifUrl?: string; pnlShare?: Record; paymentSignature?: string; }): Promise<{ message: any }> { return this.request('POST', '/chat/message', params); } /** Edit your own message */ async editChatMessage(id: number, message: string): Promise<{ message: any }> { return this.request('PUT', `/chat/message/${id}`, { message }); } /** Delete your own message */ async deleteChatMessage(id: number): Promise<{ messageId: number }> { return this.request('DELETE', `/chat/message/${id}`); } /** Block a user */ async blockUser(targetUserId: number): Promise { await this.request('POST', `/chat/block/${targetUserId}`); } /** Unblock a user */ async unblockUser(targetUserId: number): Promise { await this.request('DELETE', `/chat/block/${targetUserId}`); } // ── DMs ── /** Get DM conversations inbox */ async getConversations(limit?: number): Promise<{ conversations: any[] }> { const qs = limit ? `?limit=${limit}` : ''; return this.request('GET', `/dm/conversations${qs}`); } /** Get conversation history with a specific user */ async getConversation(walletAddress: string, params?: { limit?: number; beforeId?: number }): Promise<{ messages: any[]; otherUser: any }> { const qs = new URLSearchParams(); if (params?.limit) qs.set('limit', String(params.limit)); if (params?.beforeId) qs.set('beforeId', String(params.beforeId)); const query = qs.toString(); return this.request('GET', `/dm/conversation/${encodeURIComponent(walletAddress)}${query ? `?${query}` : ''}`); } /** Send a direct message */ async sendDirectMessage(params: { recipientWallet: string; message: string }): Promise<{ message: any }> { return this.request('POST', '/dm/send', params); } /** Mark all DMs from a user as read */ async markDMRead(walletAddress: string): Promise<{ markedRead: number }> { return this.request('POST', `/dm/read/${encodeURIComponent(walletAddress)}`); } /** Get unread DM count */ async getDMUnreadCount(): Promise<{ unreadCount: number }> { return this.request('GET', '/dm/unread'); } /** Delete a DM conversation (soft delete) */ async deleteConversation(walletAddress: string): Promise { await this.request('DELETE', `/dm/conversation/${encodeURIComponent(walletAddress)}`); } // ── Social ── /** Search users by username */ async searchUsers(query: string): Promise<{ results: any[] }> { const qs = query ? `?q=${encodeURIComponent(query)}` : ''; return this.request('GET', `/social/search${qs}`); } /** Send a friend request */ async sendFriendRequest(targetUserId: number): Promise<{ requestId: number }> { return this.request('POST', `/social/friend-request/${targetUserId}`); } /** Get pending friend requests (received) */ async getPendingFriendRequests(): Promise<{ requests: any[] }> { return this.request('GET', '/social/friend-requests'); } /** Get pending friend requests this user has sent (still awaiting response) */ async getSentFriendRequests(): Promise<{ requests: any[] }> { return this.request('GET', '/social/friend-requests/sent'); } /** Cancel a pending friend request the user previously sent */ async cancelFriendRequest(requestId: number): Promise { await this.request('DELETE', `/social/friend-request/${requestId}`); } /** Accept a friend request */ async acceptFriendRequest(requestId: number): Promise { await this.request('POST', `/social/request/${requestId}/accept`); } /** Reject a friend request */ async rejectFriendRequest(requestId: number): Promise { await this.request('POST', `/social/request/${requestId}/reject`); } /** Get friends list */ async getFriends(): Promise<{ friends: any[] }> { return this.request('GET', '/social/friends'); } /** Remove a friend */ async removeFriend(targetUserId: number): Promise { await this.request('DELETE', `/social/friend/${targetUserId}`); } /** Get blocked users list */ async getBlockedUsers(): Promise<{ blocked: any[] }> { return this.request('GET', '/social/blocked'); } // ── What's New ── /** List published What's New posts for this app, with is_read for the current user. */ async getWhatsNewPosts(): Promise<{ posts: WhatsNewPost[] }> { return this.request('GET', '/whats-new/posts'); } /** Fetch a single What's New post by id. */ async getWhatsNewPost(postId: number): Promise<{ post: WhatsNewPost }> { return this.request('GET', `/whats-new/posts/${postId}`); } /** Number of unread What's New posts for the current user (this app). */ async getWhatsNewUnreadCount(): Promise<{ count: number }> { return this.request('GET', '/whats-new/unread-count'); } /** Mark specific posts as read. */ async markWhatsNewRead(postIds: number[]): Promise { await this.request('POST', '/whats-new/mark-read', { postIds }); } /** Mark every unread post as read. */ async markAllWhatsNewRead(): Promise<{ updated: number }> { return this.request('POST', '/whats-new/mark-all-read'); } // ── App Config ── /** Fetch the app's UI customization config (accent color, icon, tagline, environment) */ async getAppConfig(): Promise { const res = await this.request<{ data: { environment?: string; uiConfig: UiConfig } }>('GET', '/apps/config'); const config = res.data?.uiConfig || {}; if (res.data?.environment) { config.environment = res.data.environment as UiConfig['environment']; } return config; } }