@dubsdotapp/expo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { ParsedError, SolanaErrorCode } from './types';
2
+
3
+ export class DubsApiError extends Error {
4
+ public readonly code: string;
5
+ public readonly httpStatus: number;
6
+
7
+ constructor(code: string, message: string, httpStatus: number) {
8
+ super(message);
9
+ this.name = 'DubsApiError';
10
+ this.code = code;
11
+ this.httpStatus = httpStatus;
12
+ }
13
+ }
14
+
15
+ /** Custom error codes from dubs_solana_program (6000-6049) */
16
+ export const SOLANA_PROGRAM_ERRORS: Record<number, SolanaErrorCode> = {
17
+ 6000: { code: 'invalid_amount', message: 'Amount must be greater than 0' },
18
+ 6001: { code: 'invalid_max_players', message: 'Max players must be between 1 and 20' },
19
+ 6002: { code: 'game_not_active', message: 'Game is not active' },
20
+ 6003: { code: 'game_full', message: 'Game is full' },
21
+ 6004: { code: 'player_already_joined', message: 'Player has already joined this game' },
22
+ 6005: { code: 'insufficient_funds', message: 'Insufficient funds in pot' },
23
+ 6006: { code: 'unauthorized', message: 'Only the game creator can perform this action' },
24
+ 6007: { code: 'invalid_winner_index', message: 'Invalid winner index' },
25
+ 6008: { code: 'game_still_active', message: 'Game is still active — must close before resetting' },
26
+ 6009: { code: 'pot_not_empty', message: 'Pot must be empty before resetting — distribute winnings first' },
27
+ 6010: { code: 'no_winners_specified', message: 'At least one winner must be specified' },
28
+ 6011: { code: 'mismatched_winners_percentages', message: 'Number of winners must match number of percentages' },
29
+ 6012: { code: 'percentages_invalid', message: 'Percentages must sum to exactly 100' },
30
+ 6013: { code: 'winner_account_mismatch', message: 'Winner account does not match expected player' },
31
+ 6014: { code: 'invalid_operator_fee', message: 'Operator fee must be between 0 and 100' },
32
+ 6015: { code: 'operator_wallet_mismatch', message: 'Operator wallet does not match game settings' },
33
+ 6016: { code: 'winner_not_in_game', message: 'Winner address is not a player in this game' },
34
+ 6017: { code: 'voting_not_enabled', message: 'Voting is not enabled for this game' },
35
+ 6018: { code: 'voter_not_in_game', message: 'Voter is not a player in this game' },
36
+ 6019: { code: 'voted_for_not_in_game', message: 'Cannot vote for someone who is not in the game' },
37
+ 6020: { code: 'voting_incomplete', message: 'Voting incomplete — need majority of players to vote' },
38
+ 6021: { code: 'invalid_referee_commission', message: 'Referee commission must be between 0 and 100' },
39
+ 6022: { code: 'total_fees_exceed_100', message: 'Total fees (operator + referee) cannot exceed 100%' },
40
+ 6023: { code: 'referee_mode_requires_referee', message: 'Referee mode requires a referee address' },
41
+ 6024: { code: 'referee_must_earn_commission', message: 'Referee must earn commission' },
42
+ 6025: { code: 'referee_cannot_be_player', message: 'Referee cannot join as a player (conflict of interest)' },
43
+ 6026: { code: 'only_referee_can_vote', message: 'Only the referee can vote in Referee mode' },
44
+ 6027: { code: 'referee_account_mismatch', message: 'Referee account does not match game settings' },
45
+ 6028: { code: 'invalid_lock_time', message: 'Lock time must be in the future' },
46
+ 6029: { code: 'game_locked', message: 'Game is locked — no more players can join' },
47
+ 6030: { code: 'lock_time_passed', message: 'Lock time has passed — game is now locked' },
48
+ 6031: { code: 'unauthorized_oracle', message: 'Only authorized oracle can resolve this game' },
49
+ 6032: { code: 'game_not_locked', message: 'Game must be locked before it can be resolved' },
50
+ 6033: { code: 'already_resolved', message: 'Game has already been resolved' },
51
+ 6034: { code: 'game_not_resolved', message: 'Game has not been resolved yet' },
52
+ 6035: { code: 'player_not_in_game', message: 'Player is not in this game' },
53
+ 6036: { code: 'not_a_winner', message: 'Player did not win this game' },
54
+ 6037: { code: 'invalid_game_mode', message: 'Invalid game mode for this operation' },
55
+ 6038: { code: 'already_claimed', message: 'Player has already claimed their winnings' },
56
+ 6039: { code: 'lock_time_too_soon', message: 'Lock time must be at least 2 minutes in the future' },
57
+ 6040: { code: 'operator_account_missing', message: 'Operator account must be provided' },
58
+ 6041: { code: 'no_winners_to_distribute', message: 'No winners to distribute funds to' },
59
+ 6042: { code: 'insufficient_funds_for_rent', message: 'Insufficient funds to maintain rent exemption' },
60
+ 6043: { code: 'cannot_resolve_before_lock', message: 'Cannot resolve game before lock time has passed' },
61
+ 6044: { code: 'emergency_refund_not_available', message: 'Emergency refund not available yet — must wait 7 days after lock time' },
62
+ 6045: { code: 'sponsor_account_missing', message: 'Sponsor account must be provided for tie refund' },
63
+ 6046: { code: 'sponsor_wallet_mismatch', message: 'Sponsor wallet does not match stored sponsor' },
64
+ 6047: { code: 'invalid_bet_amount', message: 'Bet amount must be greater than zero' },
65
+ 6048: { code: 'no_survivors_to_distribute', message: 'No survivors to distribute winnings to' },
66
+ 6049: { code: 'too_many_survivors', message: 'Too many survivors (max 50 per batch)' },
67
+ };
68
+
69
+ /** Known Solana built-in instruction errors */
70
+ const SOLANA_BUILTIN_ERRORS: Record<number, SolanaErrorCode> = {
71
+ 0: { code: 'generic_error', message: 'Generic instruction error' },
72
+ 1: { code: 'invalid_argument', message: 'Invalid argument passed to program' },
73
+ 2: { code: 'invalid_instruction_data', message: 'Invalid instruction data' },
74
+ 3: { code: 'invalid_account_data', message: 'Invalid account data' },
75
+ 4: { code: 'account_data_too_small', message: 'Account data too small' },
76
+ 5: { code: 'insufficient_funds', message: 'Insufficient funds for transaction' },
77
+ 6: { code: 'incorrect_program_id', message: 'Incorrect program ID' },
78
+ 7: { code: 'missing_required_signature', message: 'Missing required signature' },
79
+ 8: { code: 'account_already_initialized', message: 'Account already initialized' },
80
+ 9: { code: 'uninitialized_account', message: 'Attempt to operate on uninitialized account' },
81
+ };
82
+
83
+ /**
84
+ * Parse a raw Solana transaction error into a human-readable { code, message }.
85
+ * Handles: { InstructionError: [idx, { Custom: N }] }, string errors, etc.
86
+ */
87
+ export function parseSolanaError(err: unknown): ParsedError {
88
+ if (!err) return { code: 'unknown_error', message: 'Unknown transaction error' };
89
+
90
+ let parsed = err;
91
+
92
+ if (typeof parsed === 'string') {
93
+ try {
94
+ const jsonMatch = parsed.match(/\{.*\}/s);
95
+ if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
96
+ else return { code: 'transaction_failed', message: parsed };
97
+ } catch {
98
+ return { code: 'transaction_failed', message: parsed as string };
99
+ }
100
+ }
101
+
102
+ if (typeof parsed === 'object' && parsed !== null && 'InstructionError' in parsed) {
103
+ const [ixIndex, details] = (parsed as { InstructionError: [number, unknown] }).InstructionError;
104
+
105
+ if (details && typeof details === 'object' && 'Custom' in details) {
106
+ const customCode = (details as { Custom: number }).Custom;
107
+ const known = SOLANA_PROGRAM_ERRORS[customCode];
108
+ if (known) return known;
109
+ return { code: `program_error_${customCode}`, message: `Program error code ${customCode} (instruction ${ixIndex})` };
110
+ }
111
+
112
+ if (typeof details === 'string') {
113
+ const snake = details.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
114
+ return { code: snake, message: details.replace(/([a-z])([A-Z])/g, '$1 $2') };
115
+ }
116
+
117
+ if (typeof details === 'number') {
118
+ const known = SOLANA_BUILTIN_ERRORS[details];
119
+ if (known) return known;
120
+ return { code: `instruction_error_${details}`, message: `Instruction error ${details}` };
121
+ }
122
+
123
+ return { code: 'instruction_error', message: `Instruction ${ixIndex} failed: ${JSON.stringify(details)}` };
124
+ }
125
+
126
+ return { code: 'transaction_failed', message: typeof parsed === 'object' ? JSON.stringify(parsed) : String(parsed) };
127
+ }
@@ -0,0 +1,12 @@
1
+ export { useEvents } from './useEvents';
2
+ export { useGame } from './useGame';
3
+ export { useGames } from './useGames';
4
+ export { useNetworkGames } from './useNetworkGames';
5
+ export { useCreateGame } from './useCreateGame';
6
+ export type { CreateGameMutationResult } from './useCreateGame';
7
+ export { useJoinGame } from './useJoinGame';
8
+ export type { JoinGameMutationResult } from './useJoinGame';
9
+ export { useClaim } from './useClaim';
10
+ export type { ClaimMutationResult } from './useClaim';
11
+ export { useAuth } from './useAuth';
12
+ export type { UseAuthResult } from './useAuth';
@@ -0,0 +1,180 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import bs58 from 'bs58';
3
+ import { useDubs } from '../provider';
4
+ import type { AuthStatus, DubsUser } from '../types';
5
+
6
+ export interface UseAuthResult {
7
+ /** Current auth status */
8
+ status: AuthStatus;
9
+ /** Authenticated user profile, or null */
10
+ user: DubsUser | null;
11
+ /** JWT token, or null */
12
+ token: string | null;
13
+ /** Convenience boolean */
14
+ isAuthenticated: boolean;
15
+ /** Error from the last operation, or null */
16
+ error: Error | null;
17
+
18
+ /**
19
+ * Full nonce → sign → verify flow.
20
+ * If the wallet is already registered, resolves to authenticated state.
21
+ * If new wallet, resolves to needsRegistration state — call register() next.
22
+ */
23
+ authenticate: () => Promise<void>;
24
+
25
+ /**
26
+ * Register a new user after authenticate() returned needsRegistration.
27
+ * Reuses the nonce+signature from authenticate() — no second signing prompt.
28
+ */
29
+ register: (username: string, referralCode?: string) => Promise<void>;
30
+
31
+ /** Log out and clear state */
32
+ logout: () => Promise<void>;
33
+
34
+ /**
35
+ * Restore a session from a saved token (e.g. AsyncStorage on app restart).
36
+ * Calls GET /auth/me to validate. Returns true if valid, false otherwise.
37
+ */
38
+ restoreSession: (token: string) => Promise<boolean>;
39
+
40
+ /** Reset to idle state, clearing errors and user */
41
+ reset: () => void;
42
+ }
43
+
44
+ export function useAuth(): UseAuthResult {
45
+ const { client, wallet } = useDubs();
46
+ const [status, setStatus] = useState<AuthStatus>('idle');
47
+ const [user, setUser] = useState<DubsUser | null>(null);
48
+ const [token, setToken] = useState<string | null>(null);
49
+ const [error, setError] = useState<Error | null>(null);
50
+
51
+ // Stash nonce+signature between authenticate → register (single-sign flow)
52
+ const pendingAuth = useRef<{
53
+ walletAddress: string;
54
+ nonce: string;
55
+ signature: string;
56
+ } | null>(null);
57
+
58
+ const reset = useCallback(() => {
59
+ setStatus('idle');
60
+ setUser(null);
61
+ setToken(null);
62
+ setError(null);
63
+ pendingAuth.current = null;
64
+ client.setToken(null);
65
+ }, [client]);
66
+
67
+ const authenticate = useCallback(async () => {
68
+ try {
69
+ if (!wallet.publicKey) {
70
+ throw new Error('Wallet not connected');
71
+ }
72
+ if (!wallet.signMessage) {
73
+ throw new Error('Wallet does not support signMessage');
74
+ }
75
+
76
+ const walletAddress = wallet.publicKey.toBase58();
77
+ setStatus('authenticating');
78
+ setError(null);
79
+
80
+ // 1. Get nonce
81
+ const { nonce, message } = await client.getNonce(walletAddress);
82
+
83
+ // 2. Sign message
84
+ setStatus('signing');
85
+ const messageBytes = new TextEncoder().encode(message);
86
+ const signatureBytes = await wallet.signMessage(messageBytes);
87
+ const signature = bs58.encode(signatureBytes);
88
+
89
+ // 3. Verify with server
90
+ setStatus('verifying');
91
+ const result = await client.authenticate({ walletAddress, signature, nonce });
92
+
93
+ if (result.needsRegistration) {
94
+ // Stash credentials for register() — nonce is NOT consumed
95
+ pendingAuth.current = { walletAddress, nonce, signature };
96
+ setStatus('needsRegistration');
97
+ return;
98
+ }
99
+
100
+ // Existing user — fully authenticated
101
+ setUser(result.user!);
102
+ setToken(result.token!);
103
+ setStatus('authenticated');
104
+ } catch (err) {
105
+ setError(err instanceof Error ? err : new Error(String(err)));
106
+ setStatus('error');
107
+ }
108
+ }, [client, wallet]);
109
+
110
+ const register = useCallback(async (username: string, referralCode?: string) => {
111
+ try {
112
+ const pending = pendingAuth.current;
113
+ if (!pending) {
114
+ throw new Error('No pending authentication — call authenticate() first');
115
+ }
116
+
117
+ setStatus('registering');
118
+ setError(null);
119
+
120
+ const result = await client.register({
121
+ walletAddress: pending.walletAddress,
122
+ signature: pending.signature,
123
+ nonce: pending.nonce,
124
+ username,
125
+ referralCode,
126
+ });
127
+
128
+ pendingAuth.current = null;
129
+ setUser(result.user);
130
+ setToken(result.token);
131
+ setStatus('authenticated');
132
+ } catch (err) {
133
+ setError(err instanceof Error ? err : new Error(String(err)));
134
+ setStatus('error');
135
+ }
136
+ }, [client]);
137
+
138
+ const logout = useCallback(async () => {
139
+ try {
140
+ await client.logout();
141
+ } catch {
142
+ // Ignore logout errors — clear state regardless
143
+ }
144
+ setUser(null);
145
+ setToken(null);
146
+ setStatus('idle');
147
+ setError(null);
148
+ pendingAuth.current = null;
149
+ }, [client]);
150
+
151
+ const restoreSession = useCallback(async (savedToken: string): Promise<boolean> => {
152
+ try {
153
+ client.setToken(savedToken);
154
+ const me = await client.getMe();
155
+ setUser(me);
156
+ setToken(savedToken);
157
+ setStatus('authenticated');
158
+ return true;
159
+ } catch {
160
+ client.setToken(null);
161
+ setUser(null);
162
+ setToken(null);
163
+ setStatus('idle');
164
+ return false;
165
+ }
166
+ }, [client]);
167
+
168
+ return {
169
+ status,
170
+ user,
171
+ token,
172
+ isAuthenticated: status === 'authenticated',
173
+ error,
174
+ authenticate,
175
+ register,
176
+ logout,
177
+ restoreSession,
178
+ reset,
179
+ };
180
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import { signAndSendBase64Transaction, pollTransactionConfirmation } from '../utils/transaction';
4
+ import type { BuildClaimParams, MutationStatus } from '../types';
5
+
6
+ export interface ClaimMutationResult {
7
+ gameId: string;
8
+ signature: string;
9
+ explorerUrl: string;
10
+ }
11
+
12
+ export function useClaim() {
13
+ const { client, wallet, connection } = useDubs();
14
+ const [status, setStatus] = useState<MutationStatus>('idle');
15
+ const [error, setError] = useState<Error | null>(null);
16
+ const [data, setData] = useState<ClaimMutationResult | null>(null);
17
+
18
+ const reset = useCallback(() => {
19
+ setStatus('idle');
20
+ setError(null);
21
+ setData(null);
22
+ }, []);
23
+
24
+ const execute = useCallback(async (params: BuildClaimParams): Promise<ClaimMutationResult> => {
25
+ setStatus('building');
26
+ setError(null);
27
+ setData(null);
28
+
29
+ try {
30
+ // 1. Build unsigned claim transaction
31
+ const claimResult = await client.buildClaimTransaction(params);
32
+
33
+ // 2. Sign transaction
34
+ setStatus('signing');
35
+ const signature = await signAndSendBase64Transaction(
36
+ claimResult.transaction,
37
+ wallet,
38
+ connection,
39
+ );
40
+
41
+ // 3. Poll for confirmation (no backend confirm step for claims)
42
+ setStatus('confirming');
43
+ await pollTransactionConfirmation(signature, connection);
44
+
45
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
46
+ const result: ClaimMutationResult = {
47
+ gameId: params.gameId,
48
+ signature,
49
+ explorerUrl,
50
+ };
51
+
52
+ setData(result);
53
+ setStatus('success');
54
+ return result;
55
+ } catch (err) {
56
+ const error = err instanceof Error ? err : new Error(String(err));
57
+ setError(error);
58
+ setStatus('error');
59
+ throw error;
60
+ }
61
+ }, [client, wallet, connection]);
62
+
63
+ return { execute, status, error, data, reset };
64
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import { signAndSendBase64Transaction, pollTransactionConfirmation } from '../utils/transaction';
4
+ import type { CreateGameParams, ConfirmGameResult, MutationStatus } from '../types';
5
+
6
+ export interface CreateGameMutationResult {
7
+ gameId: string;
8
+ gameAddress: string;
9
+ signature: string;
10
+ explorerUrl: string;
11
+ }
12
+
13
+ export function useCreateGame() {
14
+ const { client, wallet, connection } = useDubs();
15
+ const [status, setStatus] = useState<MutationStatus>('idle');
16
+ const [error, setError] = useState<Error | null>(null);
17
+ const [data, setData] = useState<CreateGameMutationResult | null>(null);
18
+
19
+ const reset = useCallback(() => {
20
+ setStatus('idle');
21
+ setError(null);
22
+ setData(null);
23
+ }, []);
24
+
25
+ const execute = useCallback(async (params: CreateGameParams): Promise<CreateGameMutationResult> => {
26
+ setStatus('building');
27
+ setError(null);
28
+ setData(null);
29
+
30
+ try {
31
+ // 1. Build unsigned transaction
32
+ const createResult = await client.createGame(params);
33
+
34
+ // 2. Sign transaction
35
+ setStatus('signing');
36
+ const signature = await signAndSendBase64Transaction(
37
+ createResult.transaction,
38
+ wallet,
39
+ connection,
40
+ );
41
+
42
+ // 3. Poll for confirmation
43
+ setStatus('confirming');
44
+ await pollTransactionConfirmation(signature, connection);
45
+
46
+ // 4. Confirm with backend
47
+ setStatus('saving');
48
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
49
+ await client.confirmGame({
50
+ gameId: createResult.gameId,
51
+ playerWallet: params.playerWallet,
52
+ signature,
53
+ teamChoice: params.teamChoice,
54
+ wagerAmount: params.wagerAmount,
55
+ role: 'creator',
56
+ gameAddress: createResult.gameAddress,
57
+ });
58
+
59
+ const result: CreateGameMutationResult = {
60
+ gameId: createResult.gameId,
61
+ gameAddress: createResult.gameAddress,
62
+ signature,
63
+ explorerUrl,
64
+ };
65
+
66
+ setData(result);
67
+ setStatus('success');
68
+ return result;
69
+ } catch (err) {
70
+ const error = err instanceof Error ? err : new Error(String(err));
71
+ setError(error);
72
+ setStatus('error');
73
+ throw error;
74
+ }
75
+ }, [client, wallet, connection]);
76
+
77
+ return { execute, status, error, data, reset };
78
+ }
@@ -0,0 +1,34 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import type { UnifiedEvent, Pagination, GetUpcomingEventsParams, QueryResult } from '../types';
4
+
5
+ export function useEvents(
6
+ params?: GetUpcomingEventsParams,
7
+ ): QueryResult<{ events: UnifiedEvent[]; pagination: Pagination }> {
8
+ const { client } = useDubs();
9
+ const [data, setData] = useState<{ events: UnifiedEvent[]; pagination: Pagination } | null>(null);
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState<Error | null>(null);
12
+
13
+ // Serialize params for dependency comparison
14
+ const paramKey = JSON.stringify(params ?? {});
15
+
16
+ const fetchData = useCallback(async () => {
17
+ setLoading(true);
18
+ setError(null);
19
+ try {
20
+ const result = await client.getUpcomingEvents(params);
21
+ setData(result);
22
+ } catch (err) {
23
+ setError(err instanceof Error ? err : new Error(String(err)));
24
+ } finally {
25
+ setLoading(false);
26
+ }
27
+ }, [client, paramKey]); // eslint-disable-line react-hooks/exhaustive-deps
28
+
29
+ useEffect(() => {
30
+ fetchData();
31
+ }, [fetchData]);
32
+
33
+ return { data, loading, error, refetch: fetchData };
34
+ }
@@ -0,0 +1,30 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import type { GameDetail, QueryResult } from '../types';
4
+
5
+ export function useGame(gameId: string | null): QueryResult<GameDetail> {
6
+ const { client } = useDubs();
7
+ const [data, setData] = useState<GameDetail | null>(null);
8
+ const [loading, setLoading] = useState(!!gameId);
9
+ const [error, setError] = useState<Error | null>(null);
10
+
11
+ const fetchData = useCallback(async () => {
12
+ if (!gameId) return;
13
+ setLoading(true);
14
+ setError(null);
15
+ try {
16
+ const game = await client.getGame(gameId);
17
+ setData(game);
18
+ } catch (err) {
19
+ setError(err instanceof Error ? err : new Error(String(err)));
20
+ } finally {
21
+ setLoading(false);
22
+ }
23
+ }, [client, gameId]);
24
+
25
+ useEffect(() => {
26
+ fetchData();
27
+ }, [fetchData]);
28
+
29
+ return { data, loading, error, refetch: fetchData };
30
+ }
@@ -0,0 +1,31 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import type { GameListItem, GetGamesParams, QueryResult } from '../types';
4
+
5
+ export function useGames(params?: GetGamesParams): QueryResult<GameListItem[]> {
6
+ const { client } = useDubs();
7
+ const [data, setData] = useState<GameListItem[] | null>(null);
8
+ const [loading, setLoading] = useState(true);
9
+ const [error, setError] = useState<Error | null>(null);
10
+
11
+ const paramKey = JSON.stringify(params ?? {});
12
+
13
+ const fetchData = useCallback(async () => {
14
+ setLoading(true);
15
+ setError(null);
16
+ try {
17
+ const games = await client.getGames(params);
18
+ setData(games);
19
+ } catch (err) {
20
+ setError(err instanceof Error ? err : new Error(String(err)));
21
+ } finally {
22
+ setLoading(false);
23
+ }
24
+ }, [client, paramKey]); // eslint-disable-line react-hooks/exhaustive-deps
25
+
26
+ useEffect(() => {
27
+ fetchData();
28
+ }, [fetchData]);
29
+
30
+ return { data, loading, error, refetch: fetchData };
31
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import { signAndSendBase64Transaction, pollTransactionConfirmation } from '../utils/transaction';
4
+ import type { JoinGameParams, MutationStatus } from '../types';
5
+
6
+ export interface JoinGameMutationResult {
7
+ gameId: string;
8
+ gameAddress: string;
9
+ signature: string;
10
+ explorerUrl: string;
11
+ }
12
+
13
+ export function useJoinGame() {
14
+ const { client, wallet, connection } = useDubs();
15
+ const [status, setStatus] = useState<MutationStatus>('idle');
16
+ const [error, setError] = useState<Error | null>(null);
17
+ const [data, setData] = useState<JoinGameMutationResult | null>(null);
18
+
19
+ const reset = useCallback(() => {
20
+ setStatus('idle');
21
+ setError(null);
22
+ setData(null);
23
+ }, []);
24
+
25
+ const execute = useCallback(async (params: JoinGameParams): Promise<JoinGameMutationResult> => {
26
+ setStatus('building');
27
+ setError(null);
28
+ setData(null);
29
+
30
+ try {
31
+ // 1. Build unsigned transaction
32
+ const joinResult = await client.joinGame(params);
33
+
34
+ // 2. Sign transaction
35
+ setStatus('signing');
36
+ const signature = await signAndSendBase64Transaction(
37
+ joinResult.transaction,
38
+ wallet,
39
+ connection,
40
+ );
41
+
42
+ // 3. Poll for confirmation
43
+ setStatus('confirming');
44
+ await pollTransactionConfirmation(signature, connection);
45
+
46
+ // 4. Confirm with backend
47
+ setStatus('saving');
48
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
49
+ await client.confirmGame({
50
+ gameId: params.gameId,
51
+ playerWallet: params.playerWallet,
52
+ signature,
53
+ teamChoice: params.teamChoice,
54
+ wagerAmount: params.amount,
55
+ role: 'joiner',
56
+ gameAddress: joinResult.gameAddress,
57
+ });
58
+
59
+ const result: JoinGameMutationResult = {
60
+ gameId: params.gameId,
61
+ gameAddress: joinResult.gameAddress,
62
+ signature,
63
+ explorerUrl,
64
+ };
65
+
66
+ setData(result);
67
+ setStatus('success');
68
+ return result;
69
+ } catch (err) {
70
+ const error = err instanceof Error ? err : new Error(String(err));
71
+ setError(error);
72
+ setStatus('error');
73
+ throw error;
74
+ }
75
+ }, [client, wallet, connection]);
76
+
77
+ return { execute, status, error, data, reset };
78
+ }
@@ -0,0 +1,31 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { useDubs } from '../provider';
3
+ import type { GameListItem, Pagination, GetNetworkGamesParams, QueryResult } from '../types';
4
+
5
+ export function useNetworkGames(params?: GetNetworkGamesParams): QueryResult<{ games: GameListItem[]; pagination: Pagination }> {
6
+ const { client } = useDubs();
7
+ const [data, setData] = useState<{ games: GameListItem[]; pagination: Pagination } | null>(null);
8
+ const [loading, setLoading] = useState(true);
9
+ const [error, setError] = useState<Error | null>(null);
10
+
11
+ const paramKey = JSON.stringify(params ?? {});
12
+
13
+ const fetchData = useCallback(async () => {
14
+ setLoading(true);
15
+ setError(null);
16
+ try {
17
+ const result = await client.getNetworkGames(params);
18
+ setData(result);
19
+ } catch (err) {
20
+ setError(err instanceof Error ? err : new Error(String(err)));
21
+ } finally {
22
+ setLoading(false);
23
+ }
24
+ }, [client, paramKey]); // eslint-disable-line react-hooks/exhaustive-deps
25
+
26
+ useEffect(() => {
27
+ fetchData();
28
+ }, [fetchData]);
29
+
30
+ return { data, loading, error, refetch: fetchData };
31
+ }