diff --git a/src/app/airdrops/error.tsx b/src/app/airdrops/error.tsx
new file mode 100644
index 0000000..c72dcf3
--- /dev/null
+++ b/src/app/airdrops/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function AirdropsError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/alerts/error.tsx b/src/app/alerts/error.tsx
new file mode 100644
index 0000000..de4f6ae
--- /dev/null
+++ b/src/app/alerts/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function AlertsError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/contributors/error.tsx b/src/app/contributors/error.tsx
new file mode 100644
index 0000000..6a6dea0
--- /dev/null
+++ b/src/app/contributors/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function ContributorsError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/farm/error.tsx b/src/app/farm/error.tsx
new file mode 100644
index 0000000..440cf6d
--- /dev/null
+++ b/src/app/farm/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function FarmError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/history/error.tsx b/src/app/history/error.tsx
new file mode 100644
index 0000000..d0db251
--- /dev/null
+++ b/src/app/history/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function HistoryError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/leaderboard/error.tsx b/src/app/leaderboard/error.tsx
new file mode 100644
index 0000000..09815c1
--- /dev/null
+++ b/src/app/leaderboard/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function LeaderboardError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/prices/error.tsx b/src/app/prices/error.tsx
new file mode 100644
index 0000000..eb0e3c1
--- /dev/null
+++ b/src/app/prices/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function PricesError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/app/webhooks/error.tsx b/src/app/webhooks/error.tsx
new file mode 100644
index 0000000..1b1077d
--- /dev/null
+++ b/src/app/webhooks/error.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import RouteError from "@/components/RouteError/RouteError";
+
+export default function WebhooksError(props: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return ;
+}
diff --git a/src/components/AppShell/AppShell.tsx b/src/components/AppShell/AppShell.tsx
index d7bdbdc..9d6ba1a 100644
--- a/src/components/AppShell/AppShell.tsx
+++ b/src/components/AppShell/AppShell.tsx
@@ -4,6 +4,7 @@ import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletB
import Footer from "@/components/Footer/Footer";
import Navbar from "@/components/Navbar/Navbar";
import NetworkMismatchBanner from "@/components/NetworkMismatchBanner/NetworkMismatchBanner";
+import RefetchIndicator from "@/components/RefetchIndicator/RefetchIndicator";
import RpcUnreachableBanner from "@/components/RpcUnreachableBanner/RpcUnreachableBanner";
import ContextProvider from "@/context";
import {
@@ -46,6 +47,7 @@ function LayoutWrapper({ children }: { children: React.ReactNode }) {
md: 0,
}}
>
+
diff --git a/src/components/RefetchIndicator/RefetchIndicator.tsx b/src/components/RefetchIndicator/RefetchIndicator.tsx
new file mode 100644
index 0000000..2454fdd
--- /dev/null
+++ b/src/components/RefetchIndicator/RefetchIndicator.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { keyframes } from "@emotion/react";
+import { Box } from "@chakra-ui/react";
+import { useIsFetching } from "@tanstack/react-query";
+
+const slide = keyframes`
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(250%); }
+`;
+
+/**
+ * A thin bar at the top of the viewport while cached data is being refreshed
+ * (#499). Only queries that already hold data count, so first loads (which
+ * have their own spinners) don't trigger it; the old data stays on screen and
+ * this shows it is being updated. The animation is dropped for users who
+ * prefer reduced motion, leaving a static bar.
+ */
+export default function RefetchIndicator() {
+ const refetching =
+ useIsFetching({ predicate: (query) => query.state.data !== undefined }) > 0;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/RouteError/RouteError.tsx b/src/components/RouteError/RouteError.tsx
new file mode 100644
index 0000000..0dba041
--- /dev/null
+++ b/src/components/RouteError/RouteError.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useEffect } from "react";
+import { Box, Button, Heading, Text, VStack } from "@chakra-ui/react";
+import { UnknownError, errorLogger } from "@/lib/error-handler";
+
+interface RouteErrorProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+ routeName: string;
+}
+
+/**
+ * Fallback rendered by a route's `error.tsx` (#497). Because it replaces only
+ * that route's content, a crash in one page leaves the navbar, footer and the
+ * rest of the app usable instead of showing an app-wide error page.
+ */
+export default function RouteError({ error, reset, routeName }: RouteErrorProps) {
+ useEffect(() => {
+ errorLogger.log(
+ new UnknownError(`The ${routeName} page encountered an error`, error),
+ `Route error boundary: ${routeName}`,
+ );
+ }, [error, routeName]);
+
+ return (
+
+
+ Something went wrong
+
+ The {routeName} page ran into an unexpected error. The rest of the app
+ is still available.
+
+
+
+
+ );
+}
diff --git a/src/components/TvlChart/TvlChart.tsx b/src/components/TvlChart/TvlChart.tsx
index ffe32b0..2153e84 100644
--- a/src/components/TvlChart/TvlChart.tsx
+++ b/src/components/TvlChart/TvlChart.tsx
@@ -10,7 +10,6 @@ import {
Tooltip,
ResponsiveContainer,
} from "recharts";
-import { sorobanService } from "@/lib/soroban";
const ACCENT = "#4ae292";
@@ -34,8 +33,8 @@ export default function TvlChart({ poolId }: TvlChartProps) {
useEffect(() => {
let cancelled = false;
setLoading(true);
- sorobanService
- .getPoolHistory(poolId, 7)
+ import("@/lib/soroban")
+ .then(({ sorobanService }) => sorobanService.getPoolHistory(poolId, 7))
.then((history) => {
if (!cancelled) {
setData(history);
diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts
index f2c10e7..e641ef7 100644
--- a/src/hooks/useLeaderboard.ts
+++ b/src/hooks/useLeaderboard.ts
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react";
-import { sorobanService } from "@/lib/soroban";
export type SortKey = "credits" | "stake";
@@ -21,7 +20,10 @@ export function fetchLeaderboard(
sortKey: SortKey,
search?: string
): Promise<{ entries: LeaderboardEntry[]; total: number }> {
- return sorobanService.getLeaderboard(offset, limit, sortKey, search);
+ // #498 — loaded on first use rather than at import time.
+ return import("@/lib/soroban").then(({ sorobanService }) =>
+ sorobanService.getLeaderboard(offset, limit, sortKey, search),
+ );
}
export function useLeaderboard(publicKey: string | null) {
diff --git a/src/hooks/useSorobanQuery.ts b/src/hooks/useSorobanQuery.ts
index beaba1d..ce6dffb 100644
--- a/src/hooks/useSorobanQuery.ts
+++ b/src/hooks/useSorobanQuery.ts
@@ -5,18 +5,15 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import {
- getStellarBalance,
- rpcServer,
- simulateLockAssets,
- simulateUnlockAssets,
- sorobanService,
- type UserPosition,
- type TransactionResult,
-} from '@/lib/soroban';
+import type { UserPosition, TransactionResult } from '@/lib/soroban';
import { useStellarWallet } from '@/context/StellarWalletContext';
import { useToast } from '@chakra-ui/react';
+// #498 — the Soroban service is loaded on first use instead of when this module
+// is imported, so the RPC client is not created during app boot before any
+// query needs it.
+const loadSoroban = () => import('@/lib/soroban');
+
// Query Keys
export const QUERY_KEYS = {
POOLS: 'pools',
@@ -32,7 +29,7 @@ export const QUERY_KEYS = {
export const usePools = () => {
return useQuery({
queryKey: [QUERY_KEYS.POOLS],
- queryFn: () => sorobanService.getFactoryPools(),
+ queryFn: async () => (await loadSoroban()).sorobanService.getFactoryPools(),
staleTime: 30000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 60000, // 1 minute
@@ -50,7 +47,7 @@ export const usePools = () => {
export const usePoolDepositors = (poolId: string, limit: number = 20) => {
return useQuery({
queryKey: ['poolDepositors', poolId, limit],
- queryFn: () => sorobanService.getPoolDepositors(poolId, limit),
+ queryFn: async () => (await loadSoroban()).sorobanService.getPoolDepositors(poolId, limit),
enabled: !!poolId,
staleTime: 30000,
gcTime: 5 * 60 * 1000,
@@ -67,7 +64,7 @@ export const useUserPosition = (poolId: string, enabled: boolean = true) => {
return useQuery({
queryKey: [QUERY_KEYS.USER_POSITION, poolId, publicKey],
- queryFn: () => sorobanService.getUserPosition(poolId, publicKey!),
+ queryFn: async () => (await loadSoroban()).sorobanService.getUserPosition(poolId, publicKey!),
enabled: enabled && !!publicKey && !!poolId,
staleTime: 15000, // 15 seconds
gcTime: 5 * 60 * 1000,
@@ -84,7 +81,7 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => {
return useQuery({
queryKey: [QUERY_KEYS.USER_CREDITS, poolId, publicKey],
- queryFn: () => sorobanService.calculateUserCredits(poolId, publicKey!),
+ queryFn: async () => (await loadSoroban()).sorobanService.calculateUserCredits(poolId, publicKey!),
enabled: enabled && !!publicKey && !!poolId,
staleTime: 5000, // 5 seconds (credits change frequently)
gcTime: 5 * 60 * 1000,
@@ -96,7 +93,7 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => {
export const useStellarBalance = (publicKey?: string) => {
return useQuery({
queryKey: ['stellarBalance', publicKey],
- queryFn: ({ signal }) => getStellarBalance(publicKey!, signal),
+ queryFn: async ({ signal }) => (await loadSoroban()).getStellarBalance(publicKey!, signal),
enabled: !!publicKey,
staleTime: 15000,
gcTime: 5 * 60 * 1000,
@@ -152,8 +149,8 @@ export const useLockAssetsFeePreview = (args: {
args.poolContractId,
debouncedAmount,
],
- queryFn: () =>
- simulateLockAssets({
+ queryFn: async () =>
+ (await loadSoroban()).simulateLockAssets({
publicKey: args.publicKey!,
poolContractId: args.poolContractId!,
amount: debouncedAmount,
@@ -203,8 +200,8 @@ export const useUnlockAssetsFeePreview = (args: {
args.poolContractId,
debouncedAmount,
],
- queryFn: () =>
- simulateUnlockAssets({
+ queryFn: async () =>
+ (await loadSoroban()).simulateUnlockAssets({
publicKey: args.publicKey!,
poolContractId: args.poolContractId!,
amount: debouncedAmount,
@@ -250,6 +247,7 @@ export const useLockAssets = (options?: {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected. Please connect Freighter before depositing.');
}
+ const { sorobanService } = await loadSoroban();
const result = await sorobanService.lockAssets(
poolId,
publicKey,
@@ -324,6 +322,7 @@ export const useUnlockAssets = () => {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected');
}
+ const { sorobanService } = await loadSoroban();
return sorobanService.unlockAssets(
poolId,
publicKey,
@@ -404,6 +403,7 @@ export const useSetBoost = () => {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected');
}
+ const { sorobanService } = await loadSoroban();
return sorobanService.setBoost(poolId, publicKey, allocationPercentage, walletApi);
},
onSuccess: (result: TransactionResult, variables) => {
@@ -460,6 +460,7 @@ export const useAllUserPositions = () => {
queryFn: async () => {
if (!publicKey || !pools) return [];
+ const { sorobanService } = await loadSoroban();
const positions = await Promise.allSettled(
pools.map(pool => sorobanService.getUserPosition(pool.id, publicKey))
);
@@ -490,6 +491,7 @@ export const useTotalUserCredits = () => {
queryFn: async () => {
if (!publicKey || !pools) return '0';
+ const { sorobanService } = await loadSoroban();
const credits = await Promise.allSettled(
pools.map(pool => sorobanService.calculateUserCredits(pool.id, publicKey))
);
@@ -566,6 +568,7 @@ export function usePlatformStats(initialData?: UIPlatformStats) {
return useQuery({
queryKey: [QUERY_KEYS.PLATFORM_STATS],
queryFn: async () => {
+ const { sorobanService } = await loadSoroban();
const [stats, velocity] = await Promise.all([
sorobanService.getPlatformStats(),
sorobanService.getCreditVelocity(24)
@@ -599,7 +602,7 @@ export function usePlatformStats(initialData?: UIPlatformStats) {
export function useRpcHealth() {
const query = useQuery({
queryKey: ['rpcHealth'],
- queryFn: () => rpcServer.getHealth(),
+ queryFn: async () => (await loadSoroban()).rpcServer.getHealth(),
staleTime: 15000,
gcTime: 5 * 60 * 1000,
refetchInterval: 30000,
diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts
index b152d26..bab3729 100644
--- a/src/lib/soroban.ts
+++ b/src/lib/soroban.ts
@@ -2113,6 +2113,11 @@ export const formatCredits = (credits: string): string => {
return Number(formattedCredits) === 0 ? normalizedCredits : formattedCredits;
};
+// #496 — one number formatter for the lock-time and asset-amount helpers, with
+// a fixed locale so both format numbers the same way for every user.
+const NUMBER_FORMAT = new Intl.NumberFormat('en-US', { maximumFractionDigits: 7 });
+const formatNumber = (value: number): string => NUMBER_FORMAT.format(value);
+
export const formatLockTime = (timestamp: number): string => {
const now = Date.now();
const diff = timestamp - now;
@@ -2125,9 +2130,9 @@ export const formatLockTime = (timestamp: number): string => {
const days = Math.floor(hours / 24);
if (days > 0) {
- return `${days} day${days > 1 ? 's' : ''} remaining`;
+ return `${formatNumber(days)} day${days > 1 ? 's' : ''} remaining`;
} else if (hours > 0) {
- return `${hours} hour${hours > 1 ? 's' : ''} remaining`;
+ return `${formatNumber(hours)} hour${hours > 1 ? 's' : ''} remaining`;
} else {
return 'Less than 1 hour';
}
@@ -2135,7 +2140,7 @@ export const formatLockTime = (timestamp: number): string => {
export const formatAssetAmount = (amount: string, asset: AssetInfo): string => {
const num = parseFloat(amount);
- return `${num.toLocaleString()} ${asset.code}`;
+ return `${formatNumber(num)} ${asset.code}`;
};
/** Convenience wrapper — call lock_assets on a pool and await on-chain confirmation. */