-
-
- ${protocolBalanceUsd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
Protocol Balance
- {lastSynced && (
-
- Last updated {formatDistanceToNow(lastSynced)} ago
+ {vaultsLoading ? (
+
+ ) : (
+
+
+ ${fmtUsd(totalBalanceUsd)}
- )}
-
+
Protocol Balance
+ {lastSynced && (
+
+ Last updated {formatDistanceToNow(lastSynced)} ago
+
+ )}
+
+ )}
Position APY
-
- {(avgApy * 100).toFixed(2)}%
-
+ {vaultsLoading ? (
+
+ ) : (
+
+ {(avgApy * 100).toFixed(2)}%
+
+ )}
-
Total earnings
-
- ${totalYield.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
+
Total earnings
+ {vaultsLoading ? (
+
+ ) : (
+
+ ${fmtUsd(totalYield)}
+
+ )}
+>>>>>>> theirs
@@ -164,34 +600,25 @@ export default function Dashboard() {
{/* Right — chart */}
- {CHART_PERIODS.map((period) => (
+ {CHART_PERIODS.map((p) => (
))}
@@ -222,74 +649,20 @@ export default function Dashboard() {
>
Positions
-
+
+ New Position
- {positions.length === 0 ? (
-
-
No Positions
-
- Create a position by depositing an asset from your wallet.
-
-
- ) : (
-
-
-
-
- | Vault |
- Balance |
- APY |
- Yield |
- Status |
- Action |
-
-
-
- {positions.map((position) => (
-
-
-
-
- {getVaultIcon(position.vaultName)}
-
-
- {position.vaultName}
- {position.asset}
-
-
- |
-
- ${position.currentValue.toFixed(2)}
- |
-
- {((position.apy ?? 0) * 100).toFixed(1)}%
- |
-
- +${position.yieldEarned.toFixed(4)}
- |
-
-
- {position.isMatured ? "Matured" : `${position.daysRemaining}d left`}
-
- |
-
-
- |
-
- ))}
-
-
-
- )}
+
@@ -308,70 +681,37 @@ export default function Dashboard() {
- {/* ── Recent Activity ── */}
- {recentTransactions.length > 0 && (
-
-
-
Recent Activity
-
-
- {recentTransactions.map((tx) => (
-
-
-
- {tx.type === "Deposit" ? (
-
- ) : tx.type === "Withdrawal" ? (
-
- ) : (
-
- )}
-
-
-
{tx.type}
-
- {tx.vaultName} · {new Date(tx.timestamp).toLocaleString()}
-
-
-
-
-
-
{tx.amount} {tx.asset}
-
- {tx.status}
-
-
- {tx.isOnChain && tx.txHash && (
-
-
-
- )}
-
-
- ))}
-
-
- )}
+ {/* ── Recent Activity (settlements) ── */}
+
+
+
Recent Activity
+
+
+
+ {!settlementsLoading && settlements.length === 0 && (
+
+
No recent activity
+
+ Off-ramp settlements will appear here once you initiate a withdrawal.
+
+
+ )}
+
+
+ {/* Withdraw modal — uses existing PortfolioPosition shape */}
setSelectedPosition(null)}
- position={selectedPosition}
+ open={!!selectedVault}
+ onClose={() => setSelectedVault(null)}
+ position={selectedVault ? vaultToPosition(selectedVault) : null}
/>
;
- tokenPrices: { XLM: number; USDC: number };
-}) {
- const assets = [
- { code: "XLM", name: "Stellar Lumens", logo: "/xlm.png", balance: balances.XLM ?? 0, price: tokenPrices.XLM },
- { code: "USDC", name: "USD Coin", logo: "/usdc.png", balance: balances.USDC ?? 0, price: tokenPrices.USDC },
- ];
-
- const hasBalance = assets.some((a) => a.balance > 0);
-
- if (!hasBalance) {
- return (
-
-
No Wallet Balance
-
- Fund your wallet to start depositing into vaults.
-
-
- );
- }
-
- return (
-
-
-
- | Asset |
- Balance |
- Price |
- USD Value |
-
-
-
- {assets.map((asset) => (
-
-
-
-
-
- {asset.code}
- {asset.name}
-
-
- |
-
- {asset.balance.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 7 })}
- |
-
- ${asset.price.toFixed(4)}
- |
-
- ${(asset.balance * asset.price).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- |
-
- ))}
-
-
- );
-}
diff --git a/apps/dapp/frontend/app/layout.tsx b/apps/dapp/frontend/app/layout.tsx
index 09271d1a..1a642b44 100644
--- a/apps/dapp/frontend/app/layout.tsx
+++ b/apps/dapp/frontend/app/layout.tsx
@@ -3,6 +3,7 @@ import Script from "next/script";
import { Inter } from "next/font/google";
import { PortfolioProvider } from "@/components/portfolio-provider";
import { WalletProvider } from "@/components/wallet-provider";
+import { AuthProvider } from "@/components/auth-provider";
import { NotificationsProvider } from "@/components/notifications-provider";
import { NotificationsToaster } from "@/components/notifications-toaster";
import { WebSocketProvider } from "@/components/websocket-provider";
@@ -69,20 +70,22 @@ export default function RootLayout({
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
diff --git a/apps/dapp/frontend/components/auth-provider.tsx b/apps/dapp/frontend/components/auth-provider.tsx
index 721c5467..2ef41e6f 100644
--- a/apps/dapp/frontend/components/auth-provider.tsx
+++ b/apps/dapp/frontend/components/auth-provider.tsx
@@ -1,42 +1,157 @@
"use client";
import {
- createContext,
- useContext,
- useState,
- useEffect,
- type ReactNode,
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ type ReactNode,
} from "react";
import { useWallet } from "@/components/wallet-provider";
+import { api } from "@/lib/api/client";
+
+const TOKEN_KEY = "nester_auth_token";
+const USER_ID_KEY = "nester_user_id";
interface AuthContextType {
- token: string | null;
- setToken: (token: string | null) => void;
+ token: string | null;
+ userId: string | null;
+ isAuthenticated: boolean;
+ isSigningIn: boolean;
+ authError: string | null;
+ signIn: () => Promise;
+ signOut: () => void;
}
const AuthContext = createContext({
- token: null,
- setToken: () => {},
+ token: null,
+ userId: null,
+ isAuthenticated: false,
+ isSigningIn: false,
+ authError: null,
+ signIn: async () => {},
+ signOut: () => {},
});
+function readStorage(key: string): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(key);
+}
+
+function writeStorage(token: string | null, userId: string | null) {
+ if (typeof window === "undefined") return;
+ if (token) {
+ window.localStorage.setItem(TOKEN_KEY, token);
+ } else {
+ window.localStorage.removeItem(TOKEN_KEY);
+ }
+ if (userId) {
+ window.localStorage.setItem(USER_ID_KEY, userId);
+ } else {
+ window.localStorage.removeItem(USER_ID_KEY);
+ }
+}
+
export function AuthProvider({ children }: { children: ReactNode }) {
- const [token, setTokenState] = useState(null);
- const { address } = useWallet();
+ const { address } = useWallet();
+
+ const [token, setToken] = useState(() => readStorage(TOKEN_KEY));
+ const [userId, setUserId] = useState(() => readStorage(USER_ID_KEY));
+ const [isSigningIn, setIsSigningIn] = useState(false);
+ const [authError, setAuthError] = useState(null);
- // Clear token synchronously if wallet disconnects (using render phase)
- const tokenToUse = address ? token : null;
+ // Clear session when wallet disconnects
+ useEffect(() => {
+ if (!address) {
+ writeStorage(null, null);
+ setToken(null);
+ setUserId(null);
+ }
+ }, [address]);
- const setToken = (newToken: string | null) => {
- setTokenState(newToken);
+ // Sync across browser tabs
+ useEffect(() => {
+ const handler = (e: StorageEvent) => {
+ if (e.key === TOKEN_KEY) setToken(e.newValue);
+ if (e.key === USER_ID_KEY) setUserId(e.newValue);
};
+ window.addEventListener("storage", handler);
+ return () => window.removeEventListener("storage", handler);
+ }, []);
+
+ const signIn = useCallback(async () => {
+ if (!address || token) return; // already signed in or no wallet
+ setIsSigningIn(true);
+ setAuthError(null);
+
+ try {
+ // 1. Request challenge nonce
+ const { challenge } = await api.auth.requestChallenge(address);
+
+ // 2. Sign with Freighter/StellarWalletsKit
+ const { signMessage } = await import("@stellar/freighter-api");
+ const raw = await signMessage(challenge, { address });
+ // v3 returns string directly; v6 (used in SWK) returns { signature }
+ const signature =
+ typeof raw === "string"
+ ? raw
+ : (raw as unknown as { signature: string }).signature;
+
+ // 3. Verify and receive JWT
+ const { token: jwt } = await api.auth.verify(address, signature, challenge);
+
+ // 4. Resolve / create user record
+ let uid: string | null = null;
+ try {
+ const user = await api.users.getByWallet(address);
+ uid = user.id;
+ } catch {
+ try {
+ const newUser = await api.users.register(
+ address,
+ `${address.slice(0, 4)}…${address.slice(-4)}`
+ );
+ uid = newUser.id;
+ } catch {
+ // token is still valid even if user create failed
+ }
+ }
+
+ writeStorage(jwt, uid);
+ setToken(jwt);
+ setUserId(uid);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Sign-in failed";
+ setAuthError(msg);
+ } finally {
+ setIsSigningIn(false);
+ }
+ }, [address, token]);
+
+ const signOut = useCallback(() => {
+ writeStorage(null, null);
+ setToken(null);
+ setUserId(null);
+ }, []);
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
export function useAuth() {
- return useContext(AuthContext);
+ return useContext(AuthContext);
}
diff --git a/apps/dapp/frontend/components/ui/skeletons.tsx b/apps/dapp/frontend/components/ui/skeletons.tsx
new file mode 100644
index 00000000..baed9b31
--- /dev/null
+++ b/apps/dapp/frontend/components/ui/skeletons.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+/** Animated shimmer placeholder for a single line. */
+export function SkeletonLine({
+ className,
+}: {
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+/** Stat card skeleton — mirrors the balance / APY / yield cards. */
+export function SkeletonStatCard() {
+ return (
+
+
+
+
+ );
+}
+
+/** Table row skeleton for the positions table. */
+export function SkeletonTableRow({ cols = 5 }: { cols?: number }) {
+ return (
+
+ {Array.from({ length: cols }).map((_, i) => (
+ |
+
+ |
+ ))}
+
+ );
+}
+
+/** Full positions-table skeleton. */
+export function SkeletonPositionsTable({ rows = 3 }: { rows?: number }) {
+ return (
+
+
+
+ {["Vault", "Balance", "APY", "Yield", "Status", ""].map((h) => (
+ |
+
+ |
+ ))}
+
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+/** Activity feed item skeleton. */
+export function SkeletonActivityItem() {
+ return (
+
+ );
+}
diff --git a/apps/dapp/frontend/hooks/useNesterAuth.ts b/apps/dapp/frontend/hooks/useNesterAuth.ts
new file mode 100644
index 00000000..17b7f699
--- /dev/null
+++ b/apps/dapp/frontend/hooks/useNesterAuth.ts
@@ -0,0 +1,131 @@
+"use client";
+
+/**
+ * useNesterAuth — challenge/verify login flow + JWT persistence.
+ *
+ * 1. Request a nonce (challenge) from POST /api/v1/auth/challenge
+ * 2. Have the wallet sign it with signMessage()
+ * 3. POST /api/v1/auth/verify → get a JWT
+ * 4. Persist the JWT in localStorage so apiFetch() can include it
+ *
+ * The hook is idempotent — calling signIn() when already authenticated is a
+ * no-op.
+ */
+
+import { useState, useCallback, useEffect } from "react";
+import { api } from "@/lib/api/client";
+
+const TOKEN_KEY = "nester_auth_token";
+const USER_ID_KEY = "nester_user_id";
+
+function loadStoredToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(TOKEN_KEY);
+}
+
+function loadStoredUserId(): string | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(USER_ID_KEY);
+}
+
+function persistToken(token: string | null, userId: string | null) {
+ if (typeof window === "undefined") return;
+ if (token) {
+ window.localStorage.setItem(TOKEN_KEY, token);
+ } else {
+ window.localStorage.removeItem(TOKEN_KEY);
+ }
+ if (userId) {
+ window.localStorage.setItem(USER_ID_KEY, userId);
+ } else {
+ window.localStorage.removeItem(USER_ID_KEY);
+ }
+}
+
+export function useNesterAuth() {
+ const [token, setToken] = useState(loadStoredToken);
+ const [userId, setUserId] = useState(loadStoredUserId);
+ const [isSigningIn, setIsSigningIn] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Keep state in sync with other tabs
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === TOKEN_KEY) {
+ setToken(e.newValue);
+ }
+ if (e.key === USER_ID_KEY) {
+ setUserId(e.newValue);
+ }
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ const signIn = useCallback(async (walletAddress: string) => {
+ if (token) return; // already authenticated
+ setIsSigningIn(true);
+ setError(null);
+
+ try {
+ // Step 1: get challenge nonce
+ const { challenge } = await api.auth.requestChallenge(walletAddress);
+
+ // Step 2: sign with freighter / stellar-wallets-kit
+ // We use @stellar/freighter-api's signMessage (signs the raw string)
+ const { signMessage } = await import("@stellar/freighter-api");
+ const result = await signMessage(challenge, { address: walletAddress });
+ // signMessage returns { signature: string } (base64)
+ const signature =
+ typeof result === "string" ? result : (result as unknown as { signature: string }).signature;
+
+ // Step 3: verify and get JWT
+ const { token: jwt } = await api.auth.verify(walletAddress, signature, challenge);
+
+ // Step 4: look up or auto-register user
+ let uid: string | null = null;
+ try {
+ const user = await api.users.getByWallet(walletAddress);
+ uid = user.id;
+ } catch {
+ // user not found — auto-register
+ try {
+ const newUser = await api.users.register(
+ walletAddress,
+ walletAddress.slice(0, 8) // use first 8 chars as display name
+ );
+ uid = newUser.id;
+ } catch {
+ // ignore register failure — we still have the token
+ }
+ }
+
+ persistToken(jwt, uid);
+ setToken(jwt);
+ setUserId(uid);
+ } catch (err) {
+ const msg =
+ err instanceof Error ? err.message : "Authentication failed";
+ setError(msg);
+ throw err;
+ } finally {
+ setIsSigningIn(false);
+ }
+ }, [token]);
+
+ const signOut = useCallback(() => {
+ persistToken(null, null);
+ setToken(null);
+ setUserId(null);
+ }, []);
+
+ return {
+ token,
+ userId,
+ isAuthenticated: !!token,
+ isSigningIn,
+ authError: error,
+ signIn,
+ signOut,
+ };
+}
diff --git a/apps/dapp/frontend/hooks/useSettlements.ts b/apps/dapp/frontend/hooks/useSettlements.ts
new file mode 100644
index 00000000..46c3cb4f
--- /dev/null
+++ b/apps/dapp/frontend/hooks/useSettlements.ts
@@ -0,0 +1,52 @@
+"use client";
+
+/**
+ * useSettlements — live settlement history for the authenticated user.
+ */
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import { api, type ApiSettlement, ApiError } from "@/lib/api/client";
+
+const POLL_INTERVAL = 30_000;
+
+interface UseSettlementsResult {
+ settlements: ApiSettlement[];
+ isLoading: boolean;
+ error: string | null;
+ refresh: () => void;
+}
+
+export function useSettlements(userId: string | null): UseSettlementsResult {
+ const [settlements, setSettlements] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const timerRef = useRef | null>(null);
+
+ const fetch = useCallback(async () => {
+ if (!userId) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await api.settlements.list(userId);
+ setSettlements(data);
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ setSettlements([]);
+ } else {
+ setError(err instanceof Error ? err.message : "Failed to load settlements");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ fetch();
+ timerRef.current = setInterval(fetch, POLL_INTERVAL);
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ };
+ }, [fetch]);
+
+ return { settlements, isLoading, error, refresh: fetch };
+}
diff --git a/apps/dapp/frontend/hooks/useVaultHistory.ts b/apps/dapp/frontend/hooks/useVaultHistory.ts
new file mode 100644
index 00000000..a5616320
--- /dev/null
+++ b/apps/dapp/frontend/hooks/useVaultHistory.ts
@@ -0,0 +1,70 @@
+"use client";
+
+/**
+ * useVaultHistory — fetches APY/balance history for the portfolio chart.
+ * Merges snapshots from all user vaults into a single time-series.
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import { api, ApiError } from "@/lib/api/client";
+
+export interface ChartPoint {
+ date: string; // ISO date string
+ value: number; // cumulative balance in asset units (sum across vaults)
+}
+
+interface UseVaultHistoryResult {
+ history: ChartPoint[];
+ isLoading: boolean;
+}
+
+export function useVaultHistory(
+ vaultIds: string[],
+ period: string = "30d"
+): UseVaultHistoryResult {
+ const [history, setHistory] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetch = useCallback(async () => {
+ if (vaultIds.length === 0) {
+ setHistory([]);
+ return;
+ }
+ setIsLoading(true);
+ try {
+ // Fetch history for every vault in parallel
+ const allSnapshots = await Promise.all(
+ vaultIds.map((id) =>
+ api.performance.getHistory(id, period).catch(() => [])
+ )
+ );
+
+ // Build a date→balance map by summing all vaults
+ const map = new Map();
+ for (const snaps of allSnapshots) {
+ for (const s of snaps) {
+ const day = s.recorded_at.slice(0, 10); // "YYYY-MM-DD"
+ map.set(day, (map.get(day) ?? 0) + s.balance);
+ }
+ }
+
+ const sorted = Array.from(map.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([date, value]) => ({ date, value }));
+
+ setHistory(sorted);
+ } catch (err) {
+ if (!(err instanceof ApiError && err.status === 401)) {
+ console.warn("useVaultHistory:", err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, [vaultIds.join(","), period]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ fetch();
+ }, [fetch]);
+
+ return { history, isLoading };
+}
diff --git a/apps/dapp/frontend/hooks/useVaults.ts b/apps/dapp/frontend/hooks/useVaults.ts
index abcdcd0e..a3d521c1 100644
--- a/apps/dapp/frontend/hooks/useVaults.ts
+++ b/apps/dapp/frontend/hooks/useVaults.ts
@@ -1,39 +1,72 @@
-import { useQuery } from '@tanstack/react-query';
-
-export interface Vault {
- id: string;
- name: string;
- strategy: string;
- contractAddress: string;
- minDeposit: number;
- apy?: number;
- tvl?: number;
- asset: "USDC" | "XLM";
- managementFeePct?: number;
- performanceFeePct?: number;
+"use client";
+
+/**
+ * useVaults — live vault data for the authenticated user.
+ *
+ * Polls every 30 s so the dashboard stays fresh without WebSocket overhead.
+ */
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import { api, type ApiVault, type ApiPerformanceSummary, ApiError } from "@/lib/api/client";
+
+const POLL_INTERVAL = 30_000; // 30 s
+
+export interface VaultWithPerf extends ApiVault {
+ performance?: ApiPerformanceSummary;
}
-export function formatTvl(value: number | undefined): string {
- if (value === undefined) return "TVL unavailable";
- if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`;
- if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
- if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
- return `$${value}`;
+interface UseVaultsResult {
+ vaults: VaultWithPerf[];
+ isLoading: boolean;
+ error: string | null;
+ refresh: () => void;
}
-export function useVaults() {
- return useQuery({
- queryKey: ['vaults'],
- queryFn: async () => {
- const res = await fetch('/api/v1/vaults');
- if (!res.ok) throw new Error('Failed to fetch vaults');
- const vaults = await res.json() as Vault[];
- return vaults.map((v) => ({
- ...v,
- asset: (v.asset ?? (v.name.toLowerCase().includes("xlm") ? "XLM" : "USDC")) as "USDC" | "XLM",
- }));
- },
- refetchInterval: 60000,
- staleTime: 30000,
- });
+export function useVaults(userId: string | null): UseVaultsResult {
+ const [vaults, setVaults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const timerRef = useRef | null>(null);
+
+ const fetch = useCallback(async () => {
+ if (!userId) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ const raw = await api.vaults.list(userId);
+
+ // Enrich with performance summary (best-effort — don't fail if it errors)
+ const enriched: VaultWithPerf[] = await Promise.all(
+ raw.map(async (v) => {
+ try {
+ const performance = await api.performance.getSummary(v.id);
+ return { ...v, performance };
+ } catch {
+ return v;
+ }
+ })
+ );
+
+ setVaults(enriched);
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ // Token expired — don't surface as a noisy error
+ setVaults([]);
+ } else {
+ setError(err instanceof Error ? err.message : "Failed to load vaults");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ fetch();
+ timerRef.current = setInterval(fetch, POLL_INTERVAL);
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ };
+ }, [fetch]);
+
+ return { vaults, isLoading, error, refresh: fetch };
}
diff --git a/apps/dapp/frontend/lib/api/client.ts b/apps/dapp/frontend/lib/api/client.ts
index ada7793c..01c42edf 100644
--- a/apps/dapp/frontend/lib/api/client.ts
+++ b/apps/dapp/frontend/lib/api/client.ts
@@ -1,14 +1,39 @@
import config from "@/lib/config";
+/**
+ * Typed API client for the Nester Go backend.
+ *
+ * All routes under /api/v1/ require a Bearer JWT.
+ * The token is read from the auth-store (localStorage) on every request so it
+ * always reflects the current login state without needing to thread it through
+ * props/context.
+ */
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
+
export function getStoredToken(): string {
if (typeof window === "undefined") return "";
- return localStorage.getItem("nester_token") ?? "";
+ return window.localStorage.getItem("nester_token") ?? "";
+}
+
+export class ApiError extends Error {
+ constructor(
+ public readonly status: number,
+ public readonly code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
}
type ApiEnvelope = {
success: boolean;
data: T;
- error?: { message: string };
+ error?: { code?: string; message: string };
};
export async function apiRequest(
@@ -30,3 +55,248 @@ export async function apiRequest(
}
return json.data;
}
+
+async function apiFetch(
+ path: string,
+ init?: RequestInit & { skipAuth?: boolean }
+): Promise {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...(init?.headers as Record),
+ };
+
+ if (!init?.skipAuth) {
+ const token = getStoredToken();
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+ }
+
+ const res = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ headers,
+ });
+
+ const json = (await res.json()) as ApiEnvelope;
+
+ if (!json.success) {
+ throw new ApiError(
+ res.status,
+ json.error?.code ?? "UNKNOWN",
+ json.error?.message ?? `API error ${res.status}`
+ );
+ }
+
+ return json.data as T;
+}
+
+// ── Domain types ──────────────────────────────────────────────────────────────
+
+export interface ApiVault {
+ id: string;
+ user_id: string;
+ contract_address: string;
+ total_deposited: string;
+ current_balance: string;
+ currency: string;
+ status: "active" | "paused" | "closed";
+ yield_earned: string;
+ fees_paid: string;
+ last_synced_at?: string;
+ allocations?: ApiAllocation[];
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ApiAllocation {
+ id: string;
+ vault_id: string;
+ protocol: string;
+ amount: string;
+ apy: string;
+ status: string;
+ allocated_at: string;
+ updated_at?: string;
+}
+
+export interface ApiSettlement {
+ id: string;
+ user_id: string;
+ vault_id: string;
+ amount: string;
+ currency: string;
+ fiat_currency: string;
+ fiat_amount: string;
+ exchange_rate: string;
+ destination: {
+ type: string;
+ provider: string;
+ account_number: string;
+ account_name: string;
+ bank_code?: string;
+ };
+ status:
+ | "initiated"
+ | "liquidity_matched"
+ | "fiat_dispatched"
+ | "confirmed"
+ | "failed";
+ retry_count: number;
+ error_message?: string;
+ notes?: string;
+ estimated_fee?: string;
+ created_at: string;
+ completed_at?: string;
+}
+
+export interface ApiUser {
+ id: string;
+ wallet_address: string;
+ display_name: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ApiPerformanceSummary {
+ vault_id: string;
+ current_balance: number;
+ total_deposited: number;
+ total_yield: number;
+ roi_pct: number;
+ apy_7d: number;
+ apy_30d: number;
+ apy_90d: number;
+ snapshot_count: number;
+}
+
+export interface ApiPerformanceSnapshot {
+ id: string;
+ vault_id: string;
+ balance: number;
+ apy: number;
+ recorded_at: string;
+}
+
+export interface ApiTransaction {
+ id: string;
+ vault_id: string;
+ type: "deposit" | "withdrawal" | "settlement";
+ amount: string;
+ currency: string;
+ tx_hash: string;
+ created_at: string;
+}
+
+// Auth types
+export interface ChallengeResponse {
+ challenge: string;
+}
+
+export interface VerifyResponse {
+ token: string;
+}
+
+// ── API surface ───────────────────────────────────────────────────────────────
+
+export const api = {
+ /** Challenge / verify wallet login */
+ auth: {
+ requestChallenge: (walletAddress: string) =>
+ apiFetch("/auth/challenge", {
+ method: "POST",
+ body: JSON.stringify({ wallet_address: walletAddress }),
+ skipAuth: true,
+ }),
+
+ verify: (
+ walletAddress: string,
+ signature: string,
+ challenge: string
+ ) =>
+ apiFetch("/auth/verify", {
+ method: "POST",
+ body: JSON.stringify({ wallet_address: walletAddress, signature, challenge }),
+ skipAuth: true,
+ }),
+ },
+
+ /** User lookups */
+ users: {
+ getByWallet: (address: string) =>
+ apiFetch(`/users/wallet/${address}`),
+
+ getById: (id: string) =>
+ apiFetch(`/users/${id}`),
+
+ register: (walletAddress: string, displayName: string) =>
+ apiFetch("/users", {
+ method: "POST",
+ body: JSON.stringify({ wallet_address: walletAddress, display_name: displayName }),
+ skipAuth: true,
+ }),
+ },
+
+ /** Vault CRUD */
+ vaults: {
+ list: (userId: string) =>
+ apiFetch(`/vaults?userId=${userId}`),
+
+ getById: (vaultId: string) =>
+ apiFetch(`/vaults/${vaultId}`),
+
+ getAllocations: (vaultId: string) =>
+ apiFetch(`/vaults/${vaultId}/allocations`),
+
+ create: (contractAddress: string, currency: string) =>
+ apiFetch("/vaults", {
+ method: "POST",
+ body: JSON.stringify({ contract_address: contractAddress, currency }),
+ }),
+ },
+
+ /** Performance metrics */
+ performance: {
+ getSummary: (vaultId: string) =>
+ apiFetch(`/vaults/${vaultId}/performance`),
+
+ getHistory: (vaultId: string, period = "30d") =>
+ apiFetch(
+ `/vaults/${vaultId}/performance/history?period=${period}`
+ ),
+
+ getApy: (vaultId: string) =>
+ apiFetch>(`/vaults/${vaultId}/performance/apy`),
+ },
+
+ /** Settlements */
+ settlements: {
+ list: (userId: string, status?: string) =>
+ apiFetch(
+ `/settlements?userId=${userId}${status ? \`&status=\${status}\` : ""}`
+ ),
+
+ getById: (settlementId: string) =>
+ apiFetch(`/settlements/${settlementId}`),
+
+ create: (req: {
+ user_id: string;
+ vault_id: string;
+ amount: string;
+ currency: string;
+ fiat_currency: string;
+ fiat_amount: string;
+ exchange_rate: string;
+ destination: {
+ type: string;
+ provider: string;
+ account_number: string;
+ account_name: string;
+ bank_code?: string;
+ };
+ }) =>
+ apiFetch("/settlements", {
+ method: "POST",
+ body: JSON.stringify(req),
+ }),
+ },
+};
diff --git a/apps/dapp/frontend/next.config.ts b/apps/dapp/frontend/next.config.ts
index 3373ba89..aea26d0f 100644
--- a/apps/dapp/frontend/next.config.ts
+++ b/apps/dapp/frontend/next.config.ts
@@ -14,9 +14,17 @@ const nextConfig: NextConfig = {
async rewrites() {
const intelligenceUrl =
process.env.INTELLIGENCE_SERVICE_URL ?? "http://localhost:8000";
+ const apiUrl =
+ process.env.NEXT_PUBLIC_API_URL?.replace(/\/api\/v1$/, "") ?? "http://localhost:8080";
return [
+ // Go backend — all /api/v1/* calls
{
source: "/api/v1/:path*",
+ destination: `${apiUrl}/api/v1/:path*`,
+ },
+ // Intelligence / AI service
+ {
+ source: "/api/intelligence/:path*",
destination: `${intelligenceUrl}/:path*`,
},
];
From d5322dc52b9aa572411a44b8353be8a117978ef4 Mon Sep 17 00:00:00 2001
From: Aman koli <2025.amana@isu.ac.in>
Date: Fri, 29 May 2026 23:17:56 +0530
Subject: [PATCH 2/5] Refresh PR - resolve conflicts
From bf0ba9ef3abcc7869e237a129f157eb3f1494420 Mon Sep 17 00:00:00 2001
From: Aman koli <2025.amana@isu.ac.in>
Date: Fri, 29 May 2026 23:28:36 +0530
Subject: [PATCH 3/5] fix: implement Copilot review suggestions
- Fix API URL handling: use relative paths for browser, absolute for server
- Fix next.config.ts regex to properly strip /api/v1 with optional trailing slash
- Handle non-JSON/empty API responses gracefully
- Rename fetch callback to fetchVaults to avoid shadowing global fetch
- Fix polling interval to only start when userId is present
- Fix useVaultHistory dependency array using stable vaultIds
- Fix SVG chart rendering for single-point case
- Fix USD balance calculation to use token prices instead of summing raw balances
- Remove unused currentNetwork variable from ActivityFeed
- Consolidate auth logic by having useNesterAuth delegate to AuthProvider
---
apps/dapp/frontend/app/dashboard/page.tsx | 31 +++--
apps/dapp/frontend/hooks/useNesterAuth.ts | 127 ++------------------
apps/dapp/frontend/hooks/useVaultHistory.ts | 9 +-
apps/dapp/frontend/hooks/useVaults.ts | 15 ++-
apps/dapp/frontend/lib/api/client.ts | 51 +++++++-
apps/dapp/frontend/next.config.ts | 5 +-
6 files changed, 97 insertions(+), 141 deletions(-)
diff --git a/apps/dapp/frontend/app/dashboard/page.tsx b/apps/dapp/frontend/app/dashboard/page.tsx
index ed64d5c5..2fc771fb 100644
--- a/apps/dapp/frontend/app/dashboard/page.tsx
+++ b/apps/dapp/frontend/app/dashboard/page.tsx
@@ -141,14 +141,21 @@ function PortfolioChart({
const H = 120;
const pad = 10;
+ // Handle single-point case
+ const isSinglePoint = history.length === 1;
const pts = history.map((p, i) => {
- const x = (i / (history.length - 1)) * (W - pad * 2) + pad;
+ const x = isSinglePoint ? pad : (i / (history.length - 1)) * (W - pad * 2) + pad;
const y = H - pad - ((p.value - minV) / range) * (H - pad * 2);
- return `${x},${y}`;
+ return { x, y };
});
- const linePath = `M${pts.join(" L")}`;
- const areaPath = `M${pts[0]} L${pts.join(" L")} L${W - pad},${H} L${pad},${H}Z`;
+ // For single point, render a horizontal line; otherwise use normal path
+ const pathPoints = isSinglePoint
+ ? [`${pad},${pts[0].y}`, `${W - pad},${pts[0].y}`]
+ : pts.map(({ x, y }) => `${x},${y}`);
+
+ const linePath = `M${pathPoints.join(" L")}`;
+ const areaPath = `M${pathPoints[0]} L${pathPoints.join(" L")} L${W - pad},${H} L${pad},${H}Z`;
return (
diff --git a/apps/dapp/frontend/lib/api/client.ts b/apps/dapp/frontend/lib/api/client.ts
index 6b4ce839..885475bd 100644
--- a/apps/dapp/frontend/lib/api/client.ts
+++ b/apps/dapp/frontend/lib/api/client.ts
@@ -93,11 +93,7 @@ async function apiFetch