From 92a52bfa29a2a303aa311e5c2274528172fb5f5d Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Wed, 29 Jul 2026 11:54:44 +0000 Subject: [PATCH 001/167] [Backend] claimable.service.ts cache key does not include token address, so streams with the same id across tokens could collide --- backend/src/controllers/stream.controller.ts | 3 ++ backend/src/routes/v1/streams/withdraw.ts | 1 + backend/src/services/claimable.service.ts | 3 +- backend/tests/claimable.service.test.ts | 45 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 968def20..81c4dd8c 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -512,6 +512,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { where: { streamId: parsedStreamId }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, @@ -597,6 +598,7 @@ export const getUserStreamSummary = async ( where: { sender: address }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, @@ -613,6 +615,7 @@ export const getUserStreamSummary = async ( where: { recipient: address }, select: { streamId: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, diff --git a/backend/src/routes/v1/streams/withdraw.ts b/backend/src/routes/v1/streams/withdraw.ts index 9abc268c..85667531 100644 --- a/backend/src/routes/v1/streams/withdraw.ts +++ b/backend/src/routes/v1/streams/withdraw.ts @@ -68,6 +68,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) streamId: true, sender: true, recipient: true, + tokenAddress: true, ratePerSecond: true, depositedAmount: true, withdrawnAmount: true, diff --git a/backend/src/services/claimable.service.ts b/backend/src/services/claimable.service.ts index 78b75839..569b5e5b 100644 --- a/backend/src/services/claimable.service.ts +++ b/backend/src/services/claimable.service.ts @@ -5,6 +5,7 @@ const I128_MIN = -(1n << 127n); export interface ClaimableStreamState { streamId: number; + tokenAddress: string; ratePerSecond: string; depositedAmount: string; withdrawnAmount: string; @@ -102,7 +103,7 @@ export class ClaimableAmountService { ? Math.floor(requestedAt) : Math.floor(this.nowMs() / 1000); - const cacheKey = `claimable:${stream.streamId}:${getStateFingerprint(stream)}:${calculatedAt}`; + const cacheKey = `claimable:${stream.tokenAddress}:${stream.streamId}:${getStateFingerprint(stream)}:${calculatedAt}`; const cachedEntry = cache.get>(cacheKey); if (cachedEntry) { diff --git a/backend/tests/claimable.service.test.ts b/backend/tests/claimable.service.test.ts index c4628b5a..a296015f 100644 --- a/backend/tests/claimable.service.test.ts +++ b/backend/tests/claimable.service.test.ts @@ -4,6 +4,7 @@ import { ClaimableAmountService } from '../src/services/claimable.service.js'; function makeStreamState(overrides: Partial[0]> = {}) { return { streamId: 1, + tokenAddress: 'TEST_TOKEN_ADDRESS', ratePerSecond: '10', depositedAmount: '100', withdrawnAmount: '0', @@ -196,4 +197,48 @@ describe('ClaimableAmountService', () => { expect(cancelRefund + withdrawn + claimable <= deposited, `iteration ${iteration}: cancel settlement exceeded deposit`).toBe(true); } }); + + it('maintains independent cache entries for same streamId across different tokens', () => { + vi.setSystemTime(5_000); + const service = new ClaimableAmountService({ + cacheTtlMs: 10_000, + }); + + // Two streams with the same numeric ID but different token contracts + const streamTokenA = makeStreamState({ + streamId: 42, + tokenAddress: 'TOKEN_A', + ratePerSecond: '10', + depositedAmount: '1000', + withdrawnAmount: '0', + }); + + const streamTokenB = makeStreamState({ + streamId: 42, + tokenAddress: 'TOKEN_B', + ratePerSecond: '5', + depositedAmount: '2000', + withdrawnAmount: '500', + }); + + const resultA = service.getClaimableAmount(streamTokenA, 100); + const resultB = service.getClaimableAmount(streamTokenB, 100); + + // Both should be cache misses (different token = different cache key) + expect(resultA.cached).toBe(false); + expect(resultB.cached).toBe(false); + + // The values should differ because the streams have different parameters + expect(resultA.claimableAmount).not.toBe(resultB.claimableAmount); + + // Second call for Token A should be a cache hit + const resultA2 = service.getClaimableAmount(streamTokenA, 100); + expect(resultA2.cached).toBe(true); + expect(resultA2.claimableAmount).toBe(resultA.claimableAmount); + + // Second call for Token B should also be a cache hit (independent of A) + const resultB2 = service.getClaimableAmount(streamTokenB, 100); + expect(resultB2.cached).toBe(true); + expect(resultB2.claimableAmount).toBe(resultB.claimableAmount); + }); }); From 5bea055c98435c7656c2fea152d60292e85f199a Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 30 Jul 2026 07:39:23 +0100 Subject: [PATCH 002/167] feat: add unsaved changes confirmation to settings page --- .../__tests__/settings-content.test.tsx | 192 ++++++++++++++++++ .../src/app/settings/settings-content.tsx | 83 +++++++- 2 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/settings/__tests__/settings-content.test.tsx diff --git a/frontend/src/app/settings/__tests__/settings-content.test.tsx b/frontend/src/app/settings/__tests__/settings-content.test.tsx new file mode 100644 index 00000000..e32d6244 --- /dev/null +++ b/frontend/src/app/settings/__tests__/settings-content.test.tsx @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import React from "react"; + +// ─── Mocks ────────────────────────────────────────────────────────────── + +const push = vi.fn(); +const disconnect = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), +})); + +vi.mock("react-hot-toast", () => ({ + default: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + session: { + publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + network: "TESTNET", + walletName: "Freighter", + }, + disconnect, + isHydrated: true, + }), +})); + +vi.mock("@/lib/wallet", () => ({ + shortenPublicKey: (key: string) => `${key.slice(0, 4)}...${key.slice(-4)}`, + formatNetwork: (n: string) => n, + STELLAR_NETWORK: "TESTNET", +})); + +vi.mock("@/lib/api/_shared", () => ({ + getApiBaseUrl: () => "http://localhost:4000", +})); + +import SettingsContent from "../settings-content"; + +function getThemeButtons(): HTMLElement[] { + const buttons = screen.getAllByRole("button"); + return buttons.filter( + (b) => b.textContent === "Light" || b.textContent === "Dark" || b.textContent === "System" + ); +} + +function getCurrencySelect(): HTMLSelectElement | null { + return screen.queryByLabelText(/default token/i) as HTMLSelectElement | null; +} + +describe("SettingsContent dirty-state detection", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Set default localStorage values + localStorage.clear(); + localStorage.setItem("flowfi-theme", "dark"); + localStorage.setItem("flowfi-currency", "USD"); + localStorage.setItem("flowfi-amount-format", "full"); + localStorage.setItem("flowfi-decimal-places", "7"); + vi.spyOn(window, "confirm").mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts clean (not dirty) on initial render", () => { + render(); + + // Navigate away without changes — should proceed without confirmation + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(push).toHaveBeenCalledWith("/"); + }); + + it("detects dirty state when theme is changed", () => { + render(); + + const themeButtons = getThemeButtons(); + const lightButton = themeButtons.find((b) => b.textContent === "Light"); + expect(lightButton).toBeDefined(); + + // Change theme to Light (different from initial "dark") + act(() => { + fireEvent.click(lightButton!); + }); + + // Try to navigate away — confirm should fire (mock returns false = declined) + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); // Declined navigation + }); + + it("detects dirty state when display currency is changed", () => { + // Accept the confirm dialog + vi.mocked(window.confirm).mockReturnValue(true); + + render(); + + const select = getCurrencySelect(); + expect(select).not.toBeNull(); + + // Change currency value + act(() => { + fireEvent.change(select!, { target: { value: "XLM" } }); + }); + + // Try to navigate away — confirm should fire + act(() => { + fireEvent.click(screen.getByText(/connect wallet/i)); + }); + + expect(window.confirm).toHaveBeenCalled(); + expect(push).toHaveBeenCalled(); // Accepted navigation + }); + + it("detects dirty state when amount format is changed", () => { + render(); + + // Find the amount format buttons + const allButtons = screen.getAllByRole("button"); + const compactButton = allButtons.find((b) => b.textContent?.includes("Compact")); + expect(compactButton).toBeDefined(); + + act(() => { + fireEvent.click(compactButton!); + }); + + // Navigate with the disconnect button + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); + + it("detects dirty state when decimal places is changed", () => { + render(); + + // Find the decimal places buttons + const allButtons = screen.getAllByRole("button"); + const fourDecimalsBtn = allButtons.find((b) => b.textContent?.includes("4 decimals")); + expect(fourDecimalsBtn).toBeDefined(); + + act(() => { + fireEvent.click(fourDecimalsBtn!); + }); + + // Navigate with the disconnect button + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).toHaveBeenCalled(); + }); + + it("does not show confirm when no changes were made", () => { + render(); + + // Navigate away with disconnect — no changes made + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + // Should NOT show confirm + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + }); + + it("restores dirty state to clean after disconnect", () => { + render(); + + // No changes were made, so disconnect should work without confirm + const disconnectBtn = screen.getByText(/disconnect wallet/i); + act(() => { + fireEvent.click(disconnectBtn); + }); + + expect(window.confirm).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 5483dd9b..7d62e751 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -1,11 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react"; import { STELLAR_NETWORK, shortenPublicKey } from "@/lib/wallet"; import { useWallet } from "@/context/wallet-context"; import { useRouter } from "next/navigation"; -import Link from "next/link"; import { formatNetwork } from "@/lib/wallet"; import toast from "react-hot-toast"; import { getApiBaseUrl } from "@/lib/api/_shared"; @@ -18,6 +17,12 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; const INDEXER_URL = `${getApiBaseUrl()}/v1`; +function useDirtyTracker(initial: Record, current: Record): boolean { + return useMemo(() => { + return Object.keys(initial).some((key) => initial[key] !== current[key]); + }, [initial, current]); +} + export default function SettingsContent() { const router = useRouter(); const { session, disconnect, isHydrated } = useWallet(); @@ -64,6 +69,51 @@ export default function SettingsContent() { const [copied, setCopied] = useState(false); + // ── Track initial values for dirty detection ────────────────────────── + const initialValuesRef = useRef({ + browserPush: false, + theme: theme as string, + displayCurrency: displayCurrency as string, + amountFormat: amountFormat as string, + decimalPlaces: decimalPlaces as number, + }); + + const currentValues = { + browserPush, + theme, + displayCurrency, + amountFormat, + decimalPlaces, + }; + + const isDirty = useDirtyTracker(initialValuesRef.current, currentValues); + + // ── beforeunload handler for tab close / refresh / back ─────────────── + useEffect(() => { + if (!isDirty) return; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + // ── Confirm before internal navigation ──────────────────────────────── + const navigateWithConfirm = useCallback( + (path: string) => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Are you sure you want to leave?" + ); + if (!confirmed) return; + } + router.push(path); + },[isDirty, router] + ); + const toggleTheme = (newTheme: "light" | "dark" | "system") => { setTheme(newTheme); localStorage.setItem("flowfi-theme", newTheme); @@ -75,6 +125,19 @@ export default function SettingsContent() { } }; + // ── Confirm-aware disconnect ────────────────────────────────────────── + const handleDisconnectWithConfirm = useCallback(() => { + if (isDirty) { + const confirmed = window.confirm( + "You have unsaved changes. Are you sure you want to disconnect?" + ); + if (!confirmed) return; + } + disconnect(); + toast.success("Wallet disconnected"); + router.push("/"); + }, [isDirty, disconnect, router]); + const copyAddress = async () => { if (session?.publicKey) { await navigator.clipboard.writeText(session.publicKey); @@ -84,13 +147,10 @@ export default function SettingsContent() { } }; - const handleDisconnect = () => { - disconnect(); - toast.success("Wallet disconnected"); - router.push("/"); - }; + const handleBrowserPushToggle = async () => { + // toggling notifications is tracked by isDirty via browserPush state if (!browserPush) { try { await Notification.requestPermission(); @@ -360,9 +420,12 @@ export default function SettingsContent() {

Not connected - +
)} @@ -422,7 +485,7 @@ export default function SettingsContent() { {/* Disconnect */} {session && ( + + + +
+

No templates yet. Save your first stream setup.

+
+ + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 6a0c252c..188ab0ea 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "next/link"; +import dynamic from "next/dynamic"; import toast from "react-hot-toast"; /** @@ -52,6 +53,31 @@ import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; +// @ts-expect-error unused var +const DashboardOverviewDynamic = dynamic(() => import("./DashboardOverview"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardIncomingDynamic = dynamic(() => import("./DashboardIncoming"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardOutgoingDynamic = dynamic(() => import("./DashboardOutgoing"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardPausedDynamic = dynamic(() => import("./DashboardPaused"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardActivityDynamic = dynamic(() => import("./DashboardActivity"), { + ssr: false, +}); +// @ts-expect-error unused var +const DashboardSettingsDynamic = dynamic(() => import("./DashboardSettings"), { + ssr: false, +}); + // ─── Types ──────────────────────────────────────────────────────────────────── interface DashboardViewProps { @@ -143,8 +169,8 @@ function DashboardSkeleton() { ); } -/** Generic empty state with an optional CTA */ -function EmptyState({ +/** Generic empty state with an optional CTE */ +export function EmptyState({ icon, title, description, @@ -207,7 +233,7 @@ function ErrorState({ // ─── Icon helpers ───────────────────────────────────────────────────────────── -function BoltIcon() { +export function BoltIcon() { return (

{metric.label}

- {isUnavailable - ? "No data" - : formatAnalyticsValue(metric.value!, metric.format)} + {isUnavailable ? "No data" : formatAnalyticsValue(metric.value!, metric.format)}

- {isUnavailable ? metric.unavailableText : metric.detail} + {isUnavailable ? "No data" : metric.detail} ); @@ -545,9 +569,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { .then(setSnapshot) .catch((err) => { setSnapshotError( - err instanceof Error - ? err.message - : "Failed to refresh dashboard", + err instanceof Error ? err.message : "Failed to refresh dashboard", ); }); } @@ -649,9 +671,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { if (!cancelled) { setSnapshot(null); setSnapshotError( - err instanceof Error - ? err.message - : "Failed to fetch dashboard data.", + err instanceof Error ? err.message : "Failed to fetch dashboard data.", ); } } finally { @@ -761,7 +781,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { setStreamFormMessage(null); }; - // ── Optimistic helpers ──────────────────────────────────────────────────── + // ── Optimistic helpers ───────────────────────────────────────────────────── const removeStreamLocally = (streamId: string) => { queryClient.setQueryData(dashboardQueryKey(session.publicKey), (prev) => { @@ -982,7 +1002,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { ); } - // ── Overview ────────────────────────────────────────────────────────── + // ── Overview (default tab, rendered synchronously) ───────────────────── if (activeTab === "overview") { return (
@@ -999,354 +1019,71 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { ); } - // ── Incoming ────────────────────────────────────────────────────────── - if (activeTab === "incoming") { - if (snapshot!.incomingStreams.length === 0) { - return ( - } - title="No incoming streams yet" - description="No streams are sending you funds yet. Share your wallet address with a sender to receive streaming payments." - /> - ); - } + // ── Non-default tabs: lazy-loaded via next/dynamic ───────────────────── + const tabComponents: Record = { + incoming: DashboardIncomingDynamic, + outgoing: DashboardOutgoingDynamic, + paused: DashboardPausedDynamic, + activity: DashboardActivityDynamic, + settings: DashboardSettingsDynamic, + }; + + const TabComponent = tabComponents[activeTab]; + if (!TabComponent) { return ( -
- +
+

Under Construction

+

This tab is currently under development.

); } - // ── Outgoing ────────────────────────────────────────────────────────── - if (activeTab === "outgoing") { - const activeOutgoing = snapshot!.outgoingStreams.filter( - (s) => s.status === "Active", + // Pass required props to each tab component + if (activeTab === "incoming") { + return ( + ); - if (activeOutgoing.length === 0) { - return ( - } - title="No active outgoing streams" - description="You don't have any active outgoing payment streams. Create one to start streaming tokens to a recipient." - action={ - - } - /> - ); - } + } + + if (activeTab === "outgoing") { return ( -
- {renderStreams( - { ...snapshot!, outgoingStreams: activeOutgoing }, - (s) => setModal({ type: "topup", stream: s }), - (s) => setModal({ type: "cancel", stream: s }), - (s) => setModal({ type: "details", stream: s }), - )} -
+ setModal({ type: "topup", stream: s })} + onCancel={(s) => setModal({ type: "cancel", stream: s })} + onShowDetails={(s) => setModal({ type: "details", stream: s })} + /> ); } - // ── Paused ──────────────────────────────────────────────────────────── if (activeTab === "paused") { - const pausedStreams = [ - ...snapshot!.outgoingStreams.filter((s) => s.status === "Paused"), - ...snapshot!.incomingStreams.filter((s) => s.status === "Paused"), - ]; - if (pausedStreams.length === 0) { - return ( -
- No paused streams found. -
- ); - } return ( -
- - - - - - - - - - - {pausedStreams.map((s) => ( - - - - - - - ))} - -
Stream IDCounterpartyTokenStatus
#{s.id}{s.recipient}{s.token} - - Paused - -
-
+ ); } - // ── Activity ────────────────────────────────────────────────────────── if (activeTab === "activity") { return ( -
- {renderRecentActivity(snapshot, () => setShowWizard(true))} -
+ setShowWizard(true)} + /> ); } - // ── Settings ────────────────────────────────────────────────────────── if (activeTab === "settings") { return ( -
-
-
-

Create Stream

- Save and reuse recurring configurations -
- - {streamFormMessage ? ( -

- {streamFormMessage.text} -

- ) : null} - -
-
-

Template Library

-

- Save recurring stream settings once, apply instantly, then - override before submitting. -

- -
- setTemplateNameInput(e.target.value)} - placeholder="e.g. Monthly Contributor Payroll" - aria-label="Template name" - /> -
- - {editingTemplateId ? ( - - ) : null} -
-
- - {templates.length === 0 ? ( -
-

No templates yet. Save your first stream setup.

-
- ) : ( -
    - {templates.map((t) => ( -
  • -
    - {t.name} - - Updated {formatTemplateUpdatedAt(t.updatedAt)} - -
    -
    - - - -
    -
  • - ))} -
- )} -
- -
-
-
-

Stream Configuration

-

- {requiredFieldsCompleted} / 5 required fields completed -

-
- -
- - -
- - -
-
- - -
-
- -
-