Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions frontend/app/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "@stellar/freighter-api";
import { Horizon } from "@stellar/stellar-sdk";
import { env } from "../lib/env";
import { usePrices, getAssetPrice } from "../hooks/usePrices";

interface Balance {
asset_code: string;
Expand Down Expand Up @@ -47,12 +48,6 @@ interface WalletContextValue extends WalletState {

const WalletContext = createContext<WalletContextValue | null>(null);

const COINGECKO_IDS: Record<string, string> = {
XLM: "stellar",
USDC: "usd-coin",
AQUA: "aqua",
};

export function WalletProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<WalletState>({
address: null,
Expand All @@ -70,6 +65,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const networkWatcher = useRef<WatchWalletChanges | null>(null);

// Use React Query for cached prices (updates every 5 minutes)
const { data: prices } = usePrices();

const getHorizonUrl = (network: string | null) => {
return network?.toLowerCase() === "public"
? env.horizonPublic
Expand All @@ -86,18 +84,10 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
const server = new Horizon.Server(horizonUrl);
const account = await server.loadAccount(state.address);

// Fetch prices
const assetIds = Object.values(COINGECKO_IDS).join(",");
const priceRes = await fetch(
`${env.coingeckoApi}/simple/price?ids=${assetIds}&vs_currencies=usd`
);
const prices = await priceRes.json();

let totalUsd = 0;
const balances: Balance[] = account.balances.map((b: any) => {
const code = b.asset_type === "native" ? "XLM" : b.asset_code;
const coingeckoId = COINGECKO_IDS[code];
const price = prices[coingeckoId]?.usd || (code === "USDC" ? 1 : 0);
const price = getAssetPrice(prices, code);
const usdValue = parseFloat(b.balance) * price;
totalUsd += usdValue;

Expand Down Expand Up @@ -127,7 +117,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
err instanceof Error ? err.message : "Unable to refresh wallet balances.",
}));
}
}, [state.address, state.network]);
}, [state.address, state.network, prices]);

// Restore session on mount
useEffect(() => {
Expand Down Expand Up @@ -156,12 +146,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
})();
}, []);

// Fetch balances when address changes
// Fetch balances when address changes (prices come from React Query cache)
useEffect(() => {
if (state.address) {
fetchBalances();

// Real-time updates every 30 seconds
// Poll balances every 30 seconds (prices are cached separately)
if (refreshInterval.current) clearInterval(refreshInterval.current);
refreshInterval.current = setInterval(fetchBalances, 30000);
} else {
Expand All @@ -187,7 +177,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
// Watch for network changes when wallet is connected
useEffect(() => {
if (!state.isConnected) {
// Clean up watcher when wallet is disconnected
if (networkWatcher.current) {
try {
networkWatcher.current.stop();
Expand All @@ -200,7 +189,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
}

try {
// Initialize watcher to poll every 3 seconds
networkWatcher.current = new WatchWalletChanges(3000);

networkWatcher.current.watch((changes) => {
Expand All @@ -215,7 +203,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
console.error("Failed to initialize network watcher:", error);
}

// Cleanup function
return () => {
if (networkWatcher.current) {
try {
Expand Down
54 changes: 54 additions & 0 deletions frontend/app/hooks/usePrices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { env } from "../lib/env";

const COINGECKO_IDS: Record<string, string> = {
XLM: "stellar",
USDC: "usd-coin",
AQUA: "aqua",
};

const PRICE_CACHE_KEY = ["coingecko-prices"];

interface PriceData {
[coingeckoId: string]: { usd: number };
}

async function fetchPrices(): Promise<PriceData> {
const assetIds = Object.values(COINGECKO_IDS).join(",");
const res = await fetch(
`${env.coingeckoApi}/simple/price?ids=${assetIds}&vs_currencies=usd`
);
if (!res.ok) throw new Error("Failed to fetch prices");
return res.json();
}

/**
* Hook to fetch and cache CoinGecko prices.
* Prices are cached for 5 minutes to reduce unnecessary API calls.
*/
export function usePrices() {
return useQuery({
queryKey: PRICE_CACHE_KEY,
queryFn: fetchPrices,
// Prices update every 5 minutes
staleTime: 5 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
});
}

/**
* Get USD price for a given asset code from cached price data.
*/
export function getAssetPrice(
prices: PriceData | undefined,
assetCode: string
): number {
if (!prices) return assetCode === "USDC" ? 1 : 0;
const coingeckoId = COINGECKO_IDS[assetCode];
return prices[coingeckoId]?.usd ?? (assetCode === "USDC" ? 1 : 0);
}

export { COINGECKO_IDS };
export type { PriceData };
17 changes: 10 additions & 7 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from "next";
import { ThemeProvider } from "./context/ThemeContext";
import { WalletProvider } from "./context/WalletContext";
import { ToastProvider } from "./context/ToastContext";
import QueryProvider from "./providers/QueryProvider";

const BASE_URL = "https://nestera.app";

Expand Down Expand Up @@ -53,13 +54,15 @@ export default function RootLayout({
<a href="#main-content" className="skip-link">
Skip to content
</a>
<ThemeProvider>
<WalletProvider>
<ToastProvider>
<main id="main-content">{children}</main>
</ToastProvider>
</WalletProvider>
</ThemeProvider>
<QueryProvider>
<ThemeProvider>
<WalletProvider>
<ToastProvider>
<main id="main-content">{children}</main>
</ToastProvider>
</WalletProvider>
</ThemeProvider>
</QueryProvider>
</body>
</html>
);
Expand Down
32 changes: 32 additions & 0 deletions frontend/app/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import React, { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Cache data for 5 minutes by default
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 10 minutes
gcTime: 10 * 60 * 1000,
// Retry failed requests twice
retry: 2,
// Don't refetch on window focus to reduce API calls
refetchOnWindowFocus: false,
},
},
})
);

return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
Loading