diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index b4f965f..83da8ad 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { sorobanService } from "@/lib/soroban"; export type SortKey = "credits" | "stake"; @@ -11,34 +12,49 @@ export type LeaderboardEntry = { export const PAGE_SIZE = 10; const REFRESH_MS = 30_000; +const SEARCH_DEBOUNCE_MS = 300; -export async function fetchLeaderboard(): Promise { - // Wire to Horizon event indexer or Soroban RPC when contract is deployed. - await new Promise((r) => setTimeout(r, 400)); - return Array.from({ length: 100 }, (_, i) => ({ - address: `G${"A".repeat(55 - String(i + 1).length)}${i + 1}`, - totalCredits: 50000 - i * 480, - totalStake: 100000 - i * 950, - boostUtilization: Math.max(5, 100 - i), - })); +export function fetchLeaderboard( + offset: number, + limit: number, + sortKey: SortKey +): Promise<{ entries: LeaderboardEntry[]; total: number }> { + return sorobanService.getLeaderboard(offset, limit, sortKey); } export function useLeaderboard(publicKey: string | null) { const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); const [isLoading, setIsLoading] = useState(true); const [sortKey, setSortKeyState] = useState("credits"); - const [searchQuery, setSearchQueryState] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const [page, setPageState] = useState(1); const [lastRefreshed, setLastRefreshed] = useState(null); + useEffect(() => { + const id = setTimeout(() => setSearchQuery(searchInput), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [searchInput]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const offset = (currentPage - 1) * PAGE_SIZE; + const refresh = useCallback(() => { setIsLoading(true); - fetchLeaderboard().then((data) => { - setEntries(data); - setIsLoading(false); - setLastRefreshed(new Date()); - }); - }, []); + fetchLeaderboard(offset, PAGE_SIZE, sortKey) + .then(({ entries, total }) => { + setEntries(entries); + setTotal(total); + setLastRefreshed(new Date()); + }) + .catch(() => { + setEntries([]); + setTotal(0); + }) + .finally(() => setIsLoading(false)); + }, [offset, sortKey]); useEffect(() => { refresh(); @@ -46,49 +62,35 @@ export function useLeaderboard(publicKey: string | null) { return () => clearInterval(id); }, [refresh]); - const sorted = [...entries].sort((a, b) => - sortKey === "credits" - ? b.totalCredits - a.totalCredits - : b.totalStake - a.totalStake - ); - - const filtered = sorted.filter((e) => - e.address.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const paged = searchQuery + ? entries.filter((e) => + e.address.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : entries; - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const currentPage = Math.min(page, totalPages); - const paged = filtered.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE - ); - - const connectedRank = publicKey - ? filtered.findIndex((e) => e.address === publicKey) + 1 - : 0; + const connectedRank = (() => { + if (!publicKey) return 0; + const idx = entries.findIndex((e) => e.address === publicKey); + return idx === -1 ? 0 : offset + idx + 1; + })(); const setSortKey = (key: SortKey) => { setSortKeyState(key); setPageState(1); }; - const setSearchQuery = (q: string) => { - setSearchQueryState(q); - setPageState(1); - }; - return { paged, isLoading, sortKey, setSortKey, - searchQuery, - setSearchQuery, + searchQuery: searchInput, + setSearchQuery: setSearchInput, currentPage, totalPages, setPage: setPageState, connectedRank, - filteredCount: filtered.length, + filteredCount: searchQuery ? paged.length : total, lastRefreshed, refresh, }; diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index cf348c1..e725113 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -32,6 +32,30 @@ import type { } from './soroban-parsers'; export type { AssetInfo, PoolInfo, UserPosition } from './soroban-parsers'; +// Soroban RPC Configuration +const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org:443'; +const NETWORK_PASSPHRASE = process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE || Networks.TESTNET; + +// Contract Addresses (will be set via environment variables in production) +const FACTORY_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ADDRESS || ''; + +const LEADERBOARD_API_URL = process.env.NEXT_PUBLIC_LEADERBOARD_API_URL || ''; +const LEADERBOARD_LOOKBACK_LEDGERS = 120960; // ~7 days at ~5s per ledger + +export type LeaderboardSortKey = 'credits' | 'stake'; + +export interface LeaderboardRow { + address: string; + totalCredits: number; + totalStake: number; + boostUtilization: number; +} + +export interface LeaderboardPage { + entries: LeaderboardRow[]; + total: number; +} + // Initialize Soroban RPC Server export const rpcServer = new rpc.Server(sorobanRpcUrl); @@ -1299,6 +1323,153 @@ export class SorobanService { } } + /** + * Fetch one page of the leaderboard. Prefers the backend indexer + * (NEXT_PUBLIC_LEADERBOARD_API_URL); otherwise derives it from on-chain events. + */ + async getLeaderboard( + offset: number, + limit: number, + sortKey: LeaderboardSortKey = 'credits', + ): Promise { + if (LEADERBOARD_API_URL) { + try { + return await this.fetchLeaderboardFromApi(offset, limit, sortKey); + } catch (err) { + console.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err); + } + } + return this.fetchLeaderboardFromEvents(offset, limit, sortKey); + } + + private async fetchLeaderboardFromApi( + offset: number, + limit: number, + sortKey: LeaderboardSortKey, + ): Promise { + const url = new URL(LEADERBOARD_API_URL); + url.searchParams.set('offset', String(offset)); + url.searchParams.set('limit', String(limit)); + url.searchParams.set('sort', sortKey); + + const res = await fetch(url.toString(), { headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`Leaderboard API responded ${res.status}`); + + const data = (await res.json()) as { + entries?: Array>; + total?: number; + }; + const entries: LeaderboardRow[] = (data.entries ?? []).map((e) => ({ + address: String(e.address ?? ''), + totalCredits: Number(e.totalCredits ?? 0), + totalStake: Number(e.totalStake ?? 0), + boostUtilization: Number(e.boostUtilization ?? 0), + })); + return { entries, total: Number(data.total ?? entries.length) }; + } + + private async fetchLeaderboardFromEvents( + offset: number, + limit: number, + sortKey: LeaderboardSortKey, + ): Promise { + const poolIds = await this.getLeaderboardPoolIds(); + if (poolIds.length === 0) return { entries: [], total: 0 }; + + try { + const latest = await this.rpcServer.getLatestLedger(); + const startLedger = Math.max(1, latest.sequence - LEADERBOARD_LOOKBACK_LEDGERS); + + const lockSym = xdr.ScVal.scvSymbol('lock_assets').toXDR('base64'); + const unlockSym = xdr.ScVal.scvSymbol('unlock_assets').toXDR('base64'); + const creditSym = xdr.ScVal.scvSymbol('update_credits').toXDR('base64'); + + const response = await this.rpcServer.getEvents({ + startLedger, + filters: [ + { + type: 'contract', + contractIds: poolIds, + topics: [[lockSym, '*'], [unlockSym, '*'], [creditSym, '*']], + }, + ], + pagination: { limit: 1000 }, + }); + + const agg = new Map(); + const rowFor = (addr: string) => { + let row = agg.get(addr); + if (!row) { + row = { stake: 0, credits: 0 }; + agg.set(addr, row); + } + return row; + }; + + for (const evt of response.events) { + if (!evt.inSuccessfulContractCall) continue; + const topics = (evt.topic as xdr.ScVal[]).map(scValToNative); + const action = topics[0] as string; + const address = String(topics[1] ?? ''); + if (!address) continue; + + const valueNative = scValToNative(evt.value as xdr.ScVal); + const amount = this.extractEventAmount(valueNative); + const row = rowFor(address); + + if (action === 'lock_assets') row.stake += amount / 10_000_000; + else if (action === 'unlock_assets') row.stake = Math.max(0, row.stake - amount / 10_000_000); + else if (action === 'update_credits') row.credits = amount; + } + + const all: LeaderboardRow[] = Array.from(agg.entries()) + .map(([address, { stake, credits }]) => ({ + address, + totalCredits: Math.round(credits), + totalStake: Math.round(stake), + boostUtilization: 0, + })) + .filter((e) => e.totalStake > 0 || e.totalCredits > 0); + + all.sort((a, b) => + sortKey === 'credits' + ? b.totalCredits - a.totalCredits + : b.totalStake - a.totalStake, + ); + + return { entries: all.slice(offset, offset + limit), total: all.length }; + } catch (err) { + console.warn('[SmartDrop] event-derived leaderboard failed:', err); + return { entries: [], total: 0 }; + } + } + + private async getLeaderboardPoolIds(): Promise { + const ids = new Set(); + const envPool = process.env.NEXT_PUBLIC_POOL_CONTRACT_ID; + if (envPool) ids.add(envPool); + try { + const pools = await this.getFactoryPools(); + pools.forEach((p) => { + if (p.contractAddress) ids.add(p.contractAddress); + }); + } catch { + // factory not configured; env pool is enough + } + return Array.from(ids); + } + + private extractEventAmount(valueNative: unknown): number { + if (typeof valueNative === 'bigint') return Number(valueNative); + if (typeof valueNative === 'number') return valueNative; + if (Array.isArray(valueNative)) return Number(valueNative[0] ?? 0); + if (valueNative && typeof valueNative === 'object') { + const v = valueNative as Record; + return Number(v['amount'] ?? v['credits'] ?? v['credits_earned'] ?? 0); + } + return Number(valueNative ?? 0); + } + // Helper methods for parsing XDR data private parsePoolsFromXdr(xdrResult: xdr.ScVal): PoolInfo[] { return parsePoolsFromXdrResult(xdrResult);