diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx
index 3ccc454d..bfe0899f 100644
--- a/src/app/leaderboard/page.tsx
+++ b/src/app/leaderboard/page.tsx
@@ -1,6 +1,7 @@
"use client";
+
import React, { useState, useMemo } from "react";
-import { Loader2, Search, Trophy, User } from "lucide-react";
+import { Loader2, Search, User } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
@@ -13,14 +14,15 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
-import { useLeaderboardData } from "@/hooks/useLeaderBoardData";
import { useUserData } from "@/hooks/useUserData";
+import { useLeaderboardData } from "@/hooks/useLeaderBoardData";
+import { ProfileWithBalance } from "@/types/supabase";
-function BalanceCell({
- balance,
+function PortfolioValueCell({
+ value,
isLoading,
}: {
- balance: number | null;
+ value: number;
isLoading: boolean;
}) {
if (isLoading) {
@@ -30,23 +32,7 @@ function BalanceCell({
);
}
- if (balance === null) {
- return -;
- }
- return {balance.toFixed(2)} STX;
-}
-
-function RankDisplay({
- rank,
- isTopRank,
-}: {
- rank: number;
- isTopRank: boolean;
-}) {
- if (isTopRank) {
- return ;
- }
- return {rank};
+ return ${value.toFixed(4)};
}
export default function LeaderBoard() {
@@ -70,12 +56,6 @@ export default function LeaderBoard() {
);
}, [leaderboard, userData]);
- // Find the highest balance to determine the top rank
- const highestBalance = useMemo(() => {
- if (!leaderboard?.length) return 0;
- return Math.max(...leaderboard.map((p) => p.balance ?? 0));
- }, [leaderboard]);
-
if (isLoading) {
return (
@@ -129,7 +109,9 @@ export default function LeaderBoard() {
Participant
Agent Address
- Agent Balance
+
+ Portfolio Value
+
@@ -145,8 +127,8 @@ export default function LeaderBoard() {
)}
-
@@ -162,11 +144,11 @@ export default function LeaderBoard() {
Rank
Participant
Agent Address
- Agent Balance
+ Portfolio Value
- {filteredLeaderboard.map((profile) => (
+ {filteredLeaderboard.map((profile: ProfileWithBalance) => (
- 0
- }
- />
+ {profile.rank}
@@ -194,8 +171,8 @@ export default function LeaderBoard() {
)}
-
diff --git a/src/hooks/useLeaderBoardData.ts b/src/hooks/useLeaderBoardData.ts
index 4b0c74dd..9d906a86 100644
--- a/src/hooks/useLeaderBoardData.ts
+++ b/src/hooks/useLeaderBoardData.ts
@@ -1,131 +1,154 @@
import { useQuery, useQueries } from "@tanstack/react-query";
import { supabase } from "@/utils/supabase/client";
+import { useMemo } from "react";
+import { BalanceResponse, TokenPrice, Profile, ProfileWithBalance } from "@/types/supabase";
-interface Profile {
- email: string;
- assigned_agent_address: string | null;
+function normalizeContractId(contractId: string): string {
+ return contractId.split("::")[0];
}
-interface ProfileWithBalance extends Profile {
- balance: number | null;
- rank: number;
- isLoadingBalance: boolean;
+// Fetch balance for a given address
+async function fetchAgentBalance(address: string): Promise {
+ try {
+ const balanceResponse = await fetch(`/fetch?address=${address}`);
+ if (!balanceResponse.ok) throw new Error(`Failed to fetch balance data: ${balanceResponse.statusText}`);
+ return await balanceResponse.json();
+ } catch (error) {
+ console.error(`Error fetching balance for ${address}:`, error);
+ throw error;
+ }
+}
+
+// Fetch token prices
+async function fetchTokenPrices(): Promise {
+ try {
+ const response = await fetch('https://cache.aibtc.dev/stx-city/tokens/tradable-full-details-tokens');
+ if (!response.ok) throw new Error(`Failed to fetch token prices: ${response.statusText}`);
+ return await response.json();
+ } catch (error) {
+ console.error("Error fetching token prices:", error);
+ throw error;
+ }
+}
+
+// Calculate the total portfolio value
+function calculatePortfolioValue(balances: BalanceResponse, tokenPrices: TokenPrice[]): number {
+ let totalValue = 0;
+
+ // STX token calculation
+ const stxBalance = parseInt(balances.stx.balance) / 1_000_000;
+ const stxPrice = tokenPrices.find((token) => token.symbol === 'STX')?.metrics.price_usd || 0;
+ totalValue += stxBalance * stxPrice;
+
+ // Calculate value for each fungible token
+ for (const [contractId, tokenData] of Object.entries(balances.fungible_tokens)) {
+ const normalizedContractId = normalizeContractId(contractId);
+ const balance = parseInt(tokenData.balance);
+
+ const tokenInfo = tokenPrices.find(
+ (token) => token.contract_id && normalizeContractId(token.contract_id) === normalizedContractId
+ );
+
+ if (tokenInfo && tokenInfo.metrics.price_usd) {
+ // Adjust balance based on token decimals
+ const adjustedBalance = balance / Math.pow(10, tokenInfo.decimals);
+ const tokenValue = adjustedBalance * tokenInfo.metrics.price_usd;
+ totalValue += tokenValue;
+ } else {
+ console.warn(`No price found for token ${contractId}`);
+ }
+ }
+
+ return totalValue;
}
async function fetchLeaderboardData(): Promise {
try {
const [participantResponse, adminResponse] = await Promise.all([
- supabase
- .from("profiles")
- .select("email, assigned_agent_address")
- .eq("role", "Participant"),
- supabase
- .from("profiles")
- .select("email, assigned_agent_address")
- .eq("role", "Admin")
+ supabase.from("profiles").select("email, assigned_agent_address").eq("role", "Participant"),
+ supabase.from("profiles").select("email, assigned_agent_address").eq("role", "Admin")
]);
if (participantResponse.error) throw participantResponse.error;
if (adminResponse.error) throw adminResponse.error;
- const combinedData: Profile[] = [
+ return [
...(participantResponse.data ?? []),
...(adminResponse.data ?? [])
].map((profile) => ({
email: profile.email,
assigned_agent_address: profile.assigned_agent_address?.toUpperCase() ?? null,
}));
-
- return combinedData;
} catch (error) {
console.error('Error fetching leaderboard data:', error);
throw error;
}
}
-async function fetchAgentBalance(address: string): Promise {
- try {
- const response = await fetch(`/fetch?address=${address}`);
- if (!response.ok) {
- throw new Error(`Failed to fetch balance: ${response.statusText}`);
- }
- const balanceData = await response.json();
- return balanceData.stx?.balance
- ? parseInt(balanceData.stx.balance) / 1000000
- : 0;
- } catch (error) {
- console.error(`Error fetching balance for ${address}:`, error);
- return null;
- }
-}
-
export function useLeaderboardData() {
- // Fetch profiles
- const {
- data: profiles,
- error: profilesError,
- isLoading: isLoadingProfiles,
- ...rest
- } = useQuery({
- queryKey: ["leaderboardData"],
+ const tokenPricesQuery = useQuery({
+ queryKey: ["tokenPrices"],
+ queryFn: fetchTokenPrices,
+ staleTime: 300000,
+ });
+
+ const profilesQuery = useQuery({
+ queryKey: ["profiles"],
queryFn: fetchLeaderboardData,
- refetchOnWindowFocus: false,
+ staleTime: 30000,
});
- // Fetch balances for each profile with an assigned agent
const balanceQueries = useQueries({
- queries: (profiles ?? []).map((profile) => ({
- queryKey: ["agentBalance", profile.assigned_agent_address],
- queryFn: () =>
- profile.assigned_agent_address
- ? fetchAgentBalance(profile.assigned_agent_address)
- : Promise.resolve(null),
- enabled: !!profile.assigned_agent_address,
+ queries: (profilesQuery.data ?? []).map((profile) => ({
+ queryKey: ["balance", profile.assigned_agent_address],
+ queryFn: async () => {
+ if (!profile.assigned_agent_address) return { portfolioValue: 0 };
+
+ const balances = await fetchAgentBalance(profile.assigned_agent_address);
+ const portfolioValue = calculatePortfolioValue(balances, tokenPricesQuery.data || []);
+ return { portfolioValue };
+ },
+ enabled: !!profile.assigned_agent_address && tokenPricesQuery.isSuccess,
staleTime: 30000,
retry: 2,
})),
});
- // Combine profiles with their balances and loading states
- const leaderboardWithBalances: ProfileWithBalance[] = (profiles ?? []).map((profile, index) => ({
- ...profile,
- balance: balanceQueries[index].data ?? null,
- isLoadingBalance: balanceQueries[index].isLoading && !!profile.assigned_agent_address,
- rank: 0, // Initial rank, will be updated in sorting
- }));
-
- // Sort profiles by balance (null balances at the end)
- const sortedLeaderboard = [...leaderboardWithBalances].sort((a, b) => {
- // If both balances are null, maintain original order
- if (a.balance === null && b.balance === null) return 0;
- // Push null balances to the end
- if (a.balance === null) return 1;
- if (b.balance === null) return -1;
- // Sort by balance in descending order
- return b.balance - a.balance;
- });
+ const combinedData: ProfileWithBalance[] = useMemo(() => {
+ if (!profilesQuery.data) return [];
- // Assign ranks (tied balances get the same rank)
- const rankedLeaderboard = sortedLeaderboard.map((profile, index, array) => {
- if (index === 0) {
- return { ...profile, rank: 1 };
- }
+ const profiles = profilesQuery.data.map((profile, index) => ({
+ ...profile,
+ portfolioValue: balanceQueries[index]?.data?.portfolioValue ?? 0,
+ isLoadingBalance: balanceQueries[index]?.isLoading ?? false,
+ rank: 0,
+ }));
- const prevProfile = array[index - 1];
- // If current balance equals previous balance, assign same rank
- // Otherwise, assign current position + 1 as rank
- const rank = profile.balance === prevProfile.balance
- ? prevProfile.rank
- : index + 1;
+ const sortedProfiles = [...profiles].sort((a, b) => b.portfolioValue - a.portfolioValue);
- return { ...profile, rank };
- });
+ let currentRank = 0;
+ let currentValue = Infinity;
+ let increment = 0;
+
+ return sortedProfiles.map((profile) => {
+ if (profile.portfolioValue < currentValue) {
+ currentRank += 1 + increment;
+ increment = 0;
+ currentValue = profile.portfolioValue;
+ } else {
+ increment++;
+ }
+
+ return {
+ ...profile,
+ rank: currentRank,
+ };
+ });
+ }, [profilesQuery.data, balanceQueries]);
return {
- data: rankedLeaderboard,
- error: profilesError,
- isLoading: isLoadingProfiles,
- isLoadingBalances: balanceQueries.some((query) => query.isLoading),
- ...rest,
+ data: combinedData,
+ isLoading: profilesQuery.isLoading || tokenPricesQuery.isLoading,
+ error: profilesQuery.error || tokenPricesQuery.error,
};
-}
\ No newline at end of file
+}
diff --git a/src/types/supabase.ts b/src/types/supabase.ts
index 323807c9..ad2d22cf 100644
--- a/src/types/supabase.ts
+++ b/src/types/supabase.ts
@@ -113,4 +113,38 @@ export interface PublicCrew {
created_at: string;
creator_email: string;
agents: PublicAgent[];
+ }
+
+ export interface Profile {
+ email: string;
+ assigned_agent_address: string | null;
+ }
+
+ export interface ProfileWithBalance extends Profile {
+ portfolioValue: number;
+ rank: number;
+ isLoadingBalance: boolean;
+ balances?: BalanceResponse; // Optional because it may be undefined until loaded
+ tokenPrices?: Record; // Map contract ID to token price
+ }
+
+
+ export interface BalanceResponse {
+ stx: {
+ balance: string;
+ };
+ fungible_tokens: {
+ [key: string]: {
+ balance: string;
+ };
+ };
+ }
+
+ export interface TokenPrice {
+ symbol?: string;
+ contract_id?: string;
+ metrics: {
+ price_usd: number;
+ };
+ decimals:number;
}
\ No newline at end of file