diff --git a/apps/multiplayer_demo/src/App.tsx b/apps/multiplayer_demo/src/App.tsx index 55d6bd9..312fe03 100644 --- a/apps/multiplayer_demo/src/App.tsx +++ b/apps/multiplayer_demo/src/App.tsx @@ -1,3 +1,4 @@ +// App.tsx import React, { useState, useEffect, useMemo, useCallback } from 'react' import { WalletModalButton } from '@solana/wallet-adapter-react-ui' import { PublicKey, Connection } from '@solana/web3.js' @@ -25,6 +26,7 @@ const App = () => { const [maxPlayers, setMaxPlayers] = useState('') const [tokenMint, setTokenMint] = useState('So11111111111111111111111111111111111111112') const [currentBlockchainTime, setCurrentBlockchainTime] = useState(null) + const [fetchTime, setFetchTime] = useState(null) // Record the local time when fetching data const [gameDuration, setGameDuration] = useState('') const [wagerAmount, setWagerAmount] = useState('') const [winners, setWinners] = useState('') @@ -38,13 +40,15 @@ const App = () => { return new MultiplayerProvider(connection, wallet) }, [connection, wallet]) - // Define fetchGames using useCallback to prevent unnecessary re-renders + // Fetch games and blockchain time const fetchGames = useCallback(async () => { if (!provider) return try { const gameAccounts = await provider.fetchGames() const currentTimestamp = await provider.getCurrentBlockchainTime() + const fetchTime = Date.now() // Local time when data was fetched setCurrentBlockchainTime(currentTimestamp) + setFetchTime(fetchTime) setGames(gameAccounts) } catch (error) { console.error('Error fetching games:', error) @@ -54,27 +58,27 @@ const App = () => { useEffect(() => { if (!wallet.connected || !provider) return - // Fetch games initially + // Initial fetch fetchGames() // Subscribe to program account changes const subscriptionId = connection.onProgramAccountChange( new PublicKey(MULTIPLAYER_PROGRAM_ID), - (info) => { - // Whenever an account owned by the program changes, fetch updated games + () => { + // Fetch updated games when any program account changes fetchGames() }, 'confirmed', ) - // Cleanup subscription on unmount or dependency change + // Cleanup subscription on unmount return () => { connection.removeProgramAccountChangeListener(subscriptionId) - }; + } }, [wallet.connected, provider, connection, fetchGames]) const gambaConfig = async () => { - if (!provider) return; + if (!provider) return try { const gambaFeeAddress = new PublicKey('BoDeHdqeVd2ds6keWYp2r63hwpL4UfjvNEPCyvVz38mQ') const gambaFeeBps = new BN(100) // 1% @@ -93,7 +97,7 @@ const App = () => { } catch (error) { console.error('Error configuring gamba:', error) } - }; + } const createGame = async () => { if (!provider) return @@ -105,15 +109,15 @@ const App = () => { gameType === WagerType.SameWager ? 0 : 1, parseInt(wagerAmount), parseInt(gameDuration), - 600, // optional hard settle, if not defined settles in 24 hours + 600, // Optional hard settle duration (in seconds) ) - const txId = await sendTransaction(provider.anchorProvider, instruction, undefined, 5000); + const txId = await sendTransaction(provider.anchorProvider, instruction, undefined, 5000) console.log('Transaction ID:', txId) } catch (error) { console.error('Error creating game:', error) } - }; + } const joinGame = async (game, creatorAddressPubKey, creatorFee) => { if (!provider) return @@ -147,7 +151,7 @@ const App = () => { } catch (error) { console.error('Error settling game:', error) } - }; + } return (
@@ -218,15 +222,13 @@ const App = () => {
-
- {/* Removed the Refresh Games button since updates are handled via subscriptions */} -
{games.map((game, index) => ( { /> ))}
- {wallet.connected && wallet.publicKey && ( - - )} - + {wallet.connected && wallet.publicKey && } - ); -}; + ) +} -export default App; +export default App diff --git a/apps/multiplayer_demo/src/components/GameCard.tsx b/apps/multiplayer_demo/src/components/GameCard.tsx index 8cb2a40..6f204cc 100644 --- a/apps/multiplayer_demo/src/components/GameCard.tsx +++ b/apps/multiplayer_demo/src/components/GameCard.tsx @@ -1,29 +1,54 @@ -import React from 'react' +// GameCard.tsx +import React, { useState, useEffect } from 'react' import { PublicKey } from '@solana/web3.js' import { BN } from '@coral-xyz/anchor' import { formatPublicKey, parseGameState, parseWagerType } from '../utils' interface GameCardProps { - game: any; - currentBlockchainTime: number | null; - joinGame: (game: any, creatorAddressPubKey: PublicKey, creatorFee: number) => void; - leaveGame: (game: any) => void; - settleGame: (game: any) => void; - customWager: string; - setCustomWager: React.Dispatch>; + game: any + currentBlockchainTime: number | null + fetchTime: number | null + joinGame: (game: any, creatorAddressPubKey: PublicKey, creatorFee: number) => void + leaveGame: (game: any) => void + settleGame: (game: any) => void + customWager: string + setCustomWager: React.Dispatch> } const GameCard: React.FC = ({ game, currentBlockchainTime, + fetchTime, joinGame, leaveGame, settleGame, customWager, setCustomWager, }) => { - const expirationTimestamp = new BN(game.account.softExpirationTimestamp).toNumber() - const timeUntilExpiration = currentBlockchainTime ? Math.max(0, expirationTimestamp - currentBlockchainTime) : null + const [timeUntilExpiration, setTimeUntilExpiration] = useState(null) + + useEffect(() => { + if (currentBlockchainTime !== null && fetchTime !== null) { + const expirationTimestamp = new BN(game.account.softExpirationTimestamp).toNumber() + const initialElapsedTime = (Date.now() - fetchTime) / 1000 // Time elapsed since data fetch in seconds + const initialTimeUntilExpiration = Math.max( + 0, + expirationTimestamp - (currentBlockchainTime + initialElapsedTime), + ) + setTimeUntilExpiration(initialTimeUntilExpiration) + + const interval = setInterval(() => { + setTimeUntilExpiration((prev) => { + if (prev !== null) { + return Math.max(0, prev - 1) + } + return null + }) + }, 1000) + + return () => clearInterval(interval) + } + }, [currentBlockchainTime, fetchTime, game.account.softExpirationTimestamp]) return (
@@ -35,7 +60,10 @@ const GameCard: React.FC = ({
Winners: {game.account.winners.toString()}
Game ID: {game.account.gameId.toString()}
Game Expiration Timestamp: {game.account.softExpirationTimestamp.toString()}
-
Time Until Expiration: {timeUntilExpiration !== null ? `${timeUntilExpiration} seconds` : 'Loading...'}
+
+ Time Until Expiration:{' '} + {timeUntilExpiration !== null ? `${Math.floor(timeUntilExpiration)} seconds` : 'Loading...'} +
Wager Type: {parseWagerType(game.account.wagerType)}
Wager: {game.account.wager.toString()}
@@ -52,18 +80,30 @@ const GameCard: React.FC = ({
- + setCustomWager(e.target.value)} + onChange={(e) => setCustomWager(e.target.value)} className="input-field" />
{(parseGameState(game.account.state) === 'Playing' || timeUntilExpiration === 0) && ( - + )}
diff --git a/apps/multiplayer_demo/src/components/RecentEvents.tsx b/apps/multiplayer_demo/src/components/RecentEvents.tsx deleted file mode 100644 index 35c1c0a..0000000 --- a/apps/multiplayer_demo/src/components/RecentEvents.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { fetchMultiplayerTransactions } from 'gamba-multiplayer-core/src/events' -import { PublicKey, Connection } from '@solana/web3.js' - -const RecentEvents = ({ connection, address }) => { - const [recentEvents, setRecentEvents] = useState([]) - - const fetchRecentEvents = async () => { - try { - console.log('Address:', address) // Debugging log - - if (!address) { - console.error('Address is null or undefined') - return - } - - const options = { - limit: 10, // Adjust as needed - } - - const events = await fetchMultiplayerTransactions(connection, address, options) - console.log('Recent Events:', events) - setRecentEvents(events) - } catch (error) { - console.error('Error fetching recent events:', error) - } - } - - useEffect(() => { - const fetchInterval = 10000 // Fetch every 10 seconds - - const intervalId = setInterval(() => { - fetchRecentEvents() - }, fetchInterval) - - return () => clearInterval(intervalId) - }, [address]) - - return ( -
-

Recent Events

- {recentEvents.map((event, index) => ( -
-
Event: {event.name}
-
Time: {new Date(event.time).toLocaleString()}
-
Signature: {event.signature}
-
Data:
-
- {Object.entries(event.data).map(([key, value]) => ( -
- {key}: {typeof value === 'object' ? JSON.stringify(value) : value} -
- ))} -
-
- ))} -
- ) -} - -export default RecentEvents diff --git a/apps/multiplayer_demo/src/components/RecentMultiplayerEvents.tsx b/apps/multiplayer_demo/src/components/RecentMultiplayerEvents.tsx index f221afe..e63b627 100644 --- a/apps/multiplayer_demo/src/components/RecentMultiplayerEvents.tsx +++ b/apps/multiplayer_demo/src/components/RecentMultiplayerEvents.tsx @@ -8,7 +8,7 @@ const RecentMultiplayerEvents = () => { const [recentEvents, setRecentEvents] = useState([]); useEffect(() => { - const setupEventListener = async () => { + const setupEventListener = () => { const programId = MULTIPLAYER_PROGRAM_ID; // Subscribe to program logs @@ -16,16 +16,16 @@ const RecentMultiplayerEvents = () => { programId, (logs, context) => { const events = parseTransactionEvents(logs.logs); - const newEvents = events.map(event => ({ + const newEvents = events.map((event) => ({ ...event, signature: context.signature, slot: context.slot, time: Date.now(), })); - setRecentEvents(prevEvents => [...newEvents, ...prevEvents]); + setRecentEvents((prevEvents) => [...newEvents, ...prevEvents]); }, - 'confirmed' + 'confirmed', ); return () => { @@ -33,7 +33,8 @@ const RecentMultiplayerEvents = () => { }; }; - setupEventListener(); + const cleanup = setupEventListener(); + return cleanup; }, [connection]); return ( @@ -61,7 +62,8 @@ const RecentMultiplayerEvents = () => {
{Object.entries(event.data).map(([key, value]) => (
- {key}: {typeof value === 'object' ? JSON.stringify(value) : value} + {key}:{' '} + {typeof value === 'object' ? JSON.stringify(value) : value}
))}
diff --git a/apps/multiplayer_demo/src/hooks/useGambaEvents.ts b/apps/multiplayer_demo/src/hooks/useGambaEvents.ts index 65bc79e..727da0b 100644 --- a/apps/multiplayer_demo/src/hooks/useGambaEvents.ts +++ b/apps/multiplayer_demo/src/hooks/useGambaEvents.ts @@ -1,7 +1,8 @@ +// usegambeevents.ts import { useEffect, useState } from 'react' import { useConnection } from '@solana/wallet-adapter-react' +import { parseTransactionEvents, convertEventData } from 'gamba-multiplayer-core' import { PublicKey } from '@solana/web3.js' -import { MultiplayerEventType, fetchMultiplayerTransactions } from 'gamba-multiplayer-core' export function useMultiplayerEventListener(eventType, { address, signatureLimit = 10 }) { const { connection } = useConnection() @@ -9,33 +10,33 @@ export function useMultiplayerEventListener(eventType, { address, signatureLimit useEffect(() => { // Function to listen to new events - const subscribeToEvents = async () => { - const subscription = connection.onLogs(address, (logs, context) => { - const parsedEvents = parseTransactionEvents(logs.logs) - const relevantEvents = parsedEvents.filter(event => event.name === eventType) - - // Mapping to more friendly data structure - const newEvents = relevantEvents.map(event => ({ - signature: context.signature, - slot: context.slot, - time: new Date().getTime(), // Approximate time, better to use block time for accuracy - data: event.data, - })) - - setEvents(prevEvents => [...newEvents, ...prevEvents]) - }, 'confirmed') + const subscribeToEvents = () => { + const subscription = connection.onLogs( + address, + (logs, context) => { + const parsedEvents = parseTransactionEvents(logs.logs) + const relevantEvents = parsedEvents.filter((event) => event.name === eventType) + + // Mapping to more friendly data structure + const newEvents = relevantEvents.map((event) => ({ + signature: context.signature, + slot: context.slot, + time: Date.now(), // Approximate time + data: event.data, + })) + + setEvents((prevEvents) => [...newEvents, ...prevEvents]) + }, + 'confirmed', + ) return () => { connection.removeOnLogsListener(subscription) } } - subscribeToEvents() - - return () => { - // Clean up subscription - connection.removeOnLogsListener(subscribeToEvents) - } + const cleanup = subscribeToEvents() + return cleanup }, [connection, address, eventType]) return events diff --git a/packages/multiplayer-core/src/MultiplayerProvider.ts b/packages/multiplayer-core/src/MultiplayerProvider.ts index 82ae096..fb7381f 100644 --- a/packages/multiplayer-core/src/MultiplayerProvider.ts +++ b/packages/multiplayer-core/src/MultiplayerProvider.ts @@ -112,7 +112,7 @@ export class MultiplayerProvider { async joinGame( - game: any, + game: anchor.ProgramAccount, creatorAddressPubKey: PublicKey, creatorFee: number, wager: number, @@ -157,7 +157,7 @@ export class MultiplayerProvider { } - async leaveGame(game: any) { + async leaveGame(game: anchor.ProgramAccount) { const playerPubKey = this.wallet.publicKey // If mint is wrapped SOL, set playerAta to null @@ -186,7 +186,7 @@ export class MultiplayerProvider { .instruction() } - async settleGame(game: any) { + async settleGame(game: anchor.ProgramAccount) { if (game.account.mint.equals(WRAPPED_SOL_MINT)) { return this.settleGameNative(game) } else { @@ -194,7 +194,7 @@ export class MultiplayerProvider { } } - async settleGameSpl(game: any) { + async settleGameSpl(game: anchor.ProgramAccount) { const gambaState = await getGambaStateAddress() // Fetch gambaConfig to get the gambaFeeAddress @@ -256,7 +256,7 @@ export class MultiplayerProvider { // New function: settleWithUninitializedAccounts - async settleWithUninitializedAccounts(game: any) { + async settleWithUninitializedAccounts(game: anchor.ProgramAccount) { const gambaState = await getGambaStateAddress() // Fetch gambaConfig to get addresses @@ -336,7 +336,7 @@ export class MultiplayerProvider { .instruction() } - async settleGameNative(game: any) { + async settleGameNative(game: anchor.ProgramAccount) { const gambaState = await getGambaStateAddress() // Fetch gambaConfig to get the gambaFeeAddress diff --git a/packages/multiplayer-core/src/events.ts b/packages/multiplayer-core/src/events.ts index 2a5e757..21e247a 100644 --- a/packages/multiplayer-core/src/events.ts +++ b/packages/multiplayer-core/src/events.ts @@ -1,49 +1,71 @@ -import { BorshCoder, EventParser, BN } from '@coral-xyz/anchor' -import { Connection, ParsedTransactionWithMeta, PublicKey, SignaturesForAddressOptions } from '@solana/web3.js' -import { AnyMultiplayerEvent, MultiplayerEvent, MultiplayerEventType, IDL, MULTIPLAYER_PROGRAM_ID } from '.' +// events.ts +import { BorshCoder, EventParser, BN } from '@coral-xyz/anchor'; +import { Connection, ParsedTransactionWithMeta, PublicKey, SignaturesForAddressOptions } from '@solana/web3.js'; +import { AnyMultiplayerEvent, MultiplayerEvent, MultiplayerEventType, IDL, MULTIPLAYER_PROGRAM_ID } from '.'; export type MultiplayerTransaction = { - signature: string - time: number - name: Event - data: MultiplayerEvent['data'] -} + signature: string; + time: number; + name: Event; + data: MultiplayerEvent['data']; +}; -const eventParser = new EventParser(MULTIPLAYER_PROGRAM_ID, new BorshCoder(IDL)) +const eventParser = new EventParser(MULTIPLAYER_PROGRAM_ID, new BorshCoder(IDL)); /** - * Converts event data fields from hex to decimal if they are hex strings + * Converts event data fields from BN and PublicKey to strings for display */ const convertEventData = (data: any) => { - const keysToConvert = ['expirationTimestamp', 'wager', 'totalWager', 'totalGambaFee', 'durationSeconds', 'gameId'] - const convertedData = { ...data } + const convertedData = { ...data }; - keysToConvert.forEach((key) => { - if (convertedData[key] && typeof convertedData[key] === 'string' && convertedData[key].startsWith('0x')) { - convertedData[key] = parseInt(convertedData[key], 16) + for (const key in convertedData) { + const value = convertedData[key]; + if (value instanceof BN) { + // Convert BN to string + convertedData[key] = value.toString(); + } else if (value instanceof PublicKey) { + // Convert PublicKey to base58 string + convertedData[key] = value.toBase58(); + } else if (Array.isArray(value)) { + // Process arrays recursively + convertedData[key] = value.map((item: any) => { + if (item instanceof BN) { + return item.toString(); + } else if (item instanceof PublicKey) { + return item.toBase58(); + } else { + return item; + } + }); } - }) + // else leave the value as is + } - return convertedData -} + return convertedData; +}; /** - * Extracts events from transaction logs + * Extracts events from transaction logs and converts event data */ export const parseTransactionEvents = (logs: string[]) => { try { - const parsedEvents: AnyMultiplayerEvent[] = [] - const events = eventParser.parseLogs(logs) as any as AnyMultiplayerEvent[] + const parsedEvents: AnyMultiplayerEvent[] = []; + const events = eventParser.parseLogs(logs); for (const event of events) { - console.log('Parsed Event:', event) // Log each parsed event for debugging - parsedEvents.push(event) + const convertedData = convertEventData(event.data); + const convertedEvent = { + ...event, + data: convertedData, + }; + console.log('Parsed Event:', convertedEvent); // Log each parsed event for debugging + parsedEvents.push(convertedEvent); } - return parsedEvents + return parsedEvents; } catch (error) { - console.error('Failed to parse transaction logs:', error) - return [] + console.error('Failed to parse transaction logs:', error); + return []; } -} +}; /** * Extracts events from a transaction @@ -51,20 +73,18 @@ export const parseTransactionEvents = (logs: string[]) => { export const parseMultiplayerTransaction = ( transaction: ParsedTransactionWithMeta, ) => { - const logs = transaction.meta?.logMessages ?? [] - const events = parseTransactionEvents(logs) + const logs = transaction.meta?.logMessages ?? []; + const events = parseTransactionEvents(logs); - return events.map((event): MultiplayerTransaction<'GameCreated'> | MultiplayerTransaction<'PlayerJoined'> | MultiplayerTransaction<'PlayerLeft'> | MultiplayerTransaction<'GameSettled'> => { - const convertedData = convertEventData(event.data) // Convert event data - console.log('Converted Event Data:', convertedData) // Log converted event data for debugging + return events.map((event): MultiplayerTransaction => { return { signature: transaction.transaction.signatures[0], time: (transaction.blockTime ?? 0) * 1000, - name: event.name as any, - data: convertedData, - } - }) -} + name: event.name, + data: event.data, + }; + }); +}; export async function fetchMultiplayerTransactionsFromSignatures( connection: Connection, @@ -76,9 +96,9 @@ export async function fetchMultiplayerTransactionsFromSignatures( maxSupportedTransactionVersion: 0, commitment: 'confirmed', }, - )).flatMap((x) => x ? [x] : []) + )).flatMap((x) => (x ? [x] : [])); - return transactions.flatMap(parseMultiplayerTransaction) + return transactions.flatMap(parseMultiplayerTransaction); } /** @@ -93,8 +113,11 @@ export async function fetchMultiplayerTransactions( address, options, 'confirmed', - ) - const events = await fetchMultiplayerTransactionsFromSignatures(connection, signatureInfo.map((x) => x.signature)) + ); + const events = await fetchMultiplayerTransactionsFromSignatures( + connection, + signatureInfo.map((x) => x.signature), + ); - return events + return events; }