From 59ffb8d15a8f357277a2bf59067273dab4e16799 Mon Sep 17 00:00:00 2001 From: benfoster Date: Fri, 16 Oct 2026 04:38:58 +0100 Subject: [PATCH] fix: sanitize API errors, URL pagination, guided tour, form tab order (#591-#594) - #594 return generic fee-bump errors; log internals server-side only - #593 sync page state to ?page= on airdrops/history/leaderboard - #592 expand onboarding into a keyboard-navigable guided tour - #591 make UnlockModal 50%/Max real buttons in tab order --- src/app/airdrops/page.tsx | 5 +- src/app/api/sign-fee-bump/route.test.ts | 34 ++++++++++ src/app/api/sign-fee-bump/route.ts | 16 +++-- src/app/history/page.tsx | 4 +- .../OnboardingOverlay/OnboardingOverlay.tsx | 51 +++++++++++++++ src/components/UnlockModal/UnlockModal.tsx | 42 +++++++----- src/hooks/useLeaderboard.test.ts | 7 ++ src/hooks/useLeaderboard.ts | 4 +- src/hooks/useUrlPagination.test.ts | 64 +++++++++++++++++++ src/hooks/useUrlPagination.ts | 39 +++++++++++ 10 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 src/hooks/useUrlPagination.test.ts create mode 100644 src/hooks/useUrlPagination.ts diff --git a/src/app/airdrops/page.tsx b/src/app/airdrops/page.tsx index f58e417..513ceb6 100644 --- a/src/app/airdrops/page.tsx +++ b/src/app/airdrops/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import { Badge, Box, @@ -13,6 +12,7 @@ import { import { useQuery } from "@tanstack/react-query"; import { listAirdrops, backendQueryRetry } from "@/lib/backend"; import { QueryErrorAlert } from "@/components/QueryErrorAlert/QueryErrorAlert"; +import { useUrlPagination } from "@/hooks/useUrlPagination"; function statusColor(status: string) { switch (status) { @@ -29,7 +29,8 @@ function statusColor(status: string) { } export default function AirdropsPage() { - const [page, setPage] = useState(1); + // Page lives in the URL so listings can be shared and the back button works (#593). + const { page, setPage } = useUrlPagination(); const { data, isLoading, isError, error, isFetching, refetch } = useQuery({ queryKey: ["airdrops", page], diff --git a/src/app/api/sign-fee-bump/route.test.ts b/src/app/api/sign-fee-bump/route.test.ts index 95d8bb1..87f6a00 100644 --- a/src/app/api/sign-fee-bump/route.test.ts +++ b/src/app/api/sign-fee-bump/route.test.ts @@ -66,4 +66,38 @@ describe("POST /api/sign-fee-bump", () => { expect(response.status).toBe(400); expect(body.error).toBe("Missing innerTxXdr in request body"); }); + + it("does not leak XDR parser internals on invalid XDR (#594)", async () => { + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + (TransactionBuilder.fromXDR as unknown as ReturnType).mockImplementationOnce( + () => { + throw new Error("XDR decode overflow at offset 42 secret=SSPONSOR"); + }, + ); + + const response = await POST(makeRequest({ innerTxXdr: "AAAA" })); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("Invalid inner transaction XDR"); + expect(body.error).not.toContain("offset 42"); + expect(body.error).not.toContain("SSPONSOR"); + }); + + it("does not echo internal sponsorship policy messages (#594)", async () => { + const { assertSponsorableInnerTransaction } = await import("@/lib/feeBumpGuard"); + (assertSponsorableInnerTransaction as unknown as ReturnType).mockImplementationOnce( + () => { + throw new Error("opcode lock_assets not in allowlist for pool factory contract ABC"); + }, + ); + + const response = await POST(makeRequest({ innerTxXdr: "AAAA" })); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("Transaction is not eligible for fee sponsorship"); + expect(body.error).not.toContain("allowlist"); + expect(body.error).not.toContain("pool factory"); + }); }); diff --git a/src/app/api/sign-fee-bump/route.ts b/src/app/api/sign-fee-bump/route.ts index 588f813..1746749 100644 --- a/src/app/api/sign-fee-bump/route.ts +++ b/src/app/api/sign-fee-bump/route.ts @@ -10,7 +10,7 @@ export async function POST(request: Request) { if (!sponsorSecret) { console.error('[SignFeeBump] Sponsor secret (STELLAR_FEE_SPONSOR_SECRET) is not configured.'); return NextResponse.json( - { error: 'Sponsor secret key is not configured on the server' }, + { error: 'Fee sponsorship is temporarily unavailable' }, { status: 500 }, ); } @@ -44,7 +44,7 @@ export async function POST(request: Request) { const msg = err instanceof Error ? err.message : String(err); console.error('[SignFeeBump] Invalid sponsor secret key format:', msg); return NextResponse.json( - { error: 'Invalid sponsor secret key configuration' }, + { error: 'Fee sponsorship is temporarily unavailable' }, { status: 500 }, ); } @@ -57,9 +57,12 @@ export async function POST(request: Request) { networkPassphrase, ) as Transaction; } catch (err: unknown) { + // Log the SDK error server-side only — the client gets a stable, + // generic message so parser internals are never disclosed (#594). const msg = err instanceof Error ? err.message : String(err); + console.warn('[SignFeeBump] Failed to parse inner transaction XDR:', msg); return NextResponse.json( - { error: `Invalid inner transaction XDR: ${msg}` }, + { error: 'Invalid inner transaction XDR' }, { status: 400 }, ); } @@ -73,7 +76,12 @@ export async function POST(request: Request) { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.warn('[SignFeeBump] Rejected non-sponsorable inner transaction:', msg); - return NextResponse.json({ error: msg }, { status: 400 }); + // Do not echo the assertion message: it describes internal policy and + // contract topology (#594). + return NextResponse.json( + { error: 'Transaction is not eligible for fee sponsorship' }, + { status: 400 }, + ); } // Build the fee-bump transaction diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 23e6806..696386a 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -28,6 +28,7 @@ import { useStellarWallet } from "@/context/StellarWalletContext"; import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletButton"; import LiveRegion from "@/components/LiveRegion/LiveRegion"; import { useLiveAnnouncer } from "@/hooks/useLiveAnnouncer"; +import { useUrlPagination } from "@/hooks/useUrlPagination"; import { getUserTransactionHistory, stellarExpertTxUrl, @@ -151,7 +152,8 @@ export default function HistoryPage() { const [entries, setEntries] = useState([]); const [truncated, setTruncated] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [page, setPage] = useState(1); + // Page lives in the URL so history pages can be shared (#593). + const { page, setPage } = useUrlPagination(); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); diff --git a/src/components/OnboardingOverlay/OnboardingOverlay.tsx b/src/components/OnboardingOverlay/OnboardingOverlay.tsx index d1560fb..4e598bf 100644 --- a/src/components/OnboardingOverlay/OnboardingOverlay.tsx +++ b/src/components/OnboardingOverlay/OnboardingOverlay.tsx @@ -31,14 +31,31 @@ const STEPS = [ "Deposit your assets into farming pools. The longer you lock, the more credits you earn.", icon: "🔒", }, + { + title: "Boost Your Farm", + description: + "Allocate a share of your credits as boost to multiply farming rewards on your positions.", + icon: "🚀", + }, { title: "Claim Airdrops", description: "Your earned credits can be redeemed for exclusive ecosystem airdrops and rewards.", icon: "🎁", }, + { + title: "Track Everything", + description: + "Use the leaderboard, history, and alerts pages to follow rankings, past transactions, and notifications.", + icon: "📊", + }, ]; +/** + * Guided tour for first-time users (#592). Steps can be advanced with Next, + * the arrow keys, or dismissed with Skip/Esc. Re-launchable via + * `localStorage.removeItem("smartdrop_onboarded")` or the footer tour link. + */ export default function OnboardingOverlay() { const [isOpen, setIsOpen] = useState(false); const [currentStep, setCurrentStep] = useState(0); @@ -55,6 +72,7 @@ export default function OnboardingOverlay() { const handleClose = () => { localStorage.setItem("smartdrop_onboarded", "true"); setIsOpen(false); + setCurrentStep(0); }; const nextStep = () => { @@ -65,6 +83,29 @@ export default function OnboardingOverlay() { } }; + const prevStep = () => { + setCurrentStep((s) => Math.max(0, s - 1)); + }; + + // Keyboard tour controls (#592): Left/Right move between steps, Esc skips. + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowRight") { + e.preventDefault(); + nextStep(); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + prevStep(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, currentStep]); + if (!isOpen) return null; return ( @@ -156,6 +197,16 @@ export default function OnboardingOverlay() { + {currentStep > 0 && ( + + )} {currentStep === STEPS.length - 1 ? ( isConnected ? ( diff --git a/src/hooks/useLeaderboard.test.ts b/src/hooks/useLeaderboard.test.ts index e315f8f..8388cb0 100644 --- a/src/hooks/useLeaderboard.test.ts +++ b/src/hooks/useLeaderboard.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@/test/renderHook"; import type { SortKey, LeaderboardEntry } from "./useLeaderboard"; +// useLeaderboard now syncs page to the URL (#593); keep unit tests offline. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + usePathname: () => "/leaderboard", + useSearchParams: () => new URLSearchParams(), +})); + // Manually resolvable promises for controlling resolution order function createDeferred() { let resolve!: (value: T) => void; diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts index e641ef7..694a0dc 100644 --- a/src/hooks/useLeaderboard.ts +++ b/src/hooks/useLeaderboard.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { useUrlPagination } from "./useUrlPagination"; export type SortKey = "credits" | "stake"; @@ -33,7 +34,8 @@ export function useLeaderboard(publicKey: string | null) { const [sortKey, setSortKeyState] = useState("credits"); const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); - const [page, setPageState] = useState(1); + // URL-backed so leaderboard pages are shareable and back/forward work (#593). + const { page, setPage: setPageState } = useUrlPagination(); const [lastRefreshed, setLastRefreshed] = useState(null); useEffect(() => { diff --git a/src/hooks/useUrlPagination.test.ts b/src/hooks/useUrlPagination.test.ts new file mode 100644 index 0000000..167ca3f --- /dev/null +++ b/src/hooks/useUrlPagination.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@/test/renderHook"; +import { useUrlPagination } from "./useUrlPagination"; + +const replace = vi.fn(); + +function mockSearchParams(initial: string) { + return new URLSearchParams(initial); +} + +let search = ""; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace, push: vi.fn() }), + usePathname: () => "/airdrops", + useSearchParams: () => mockSearchParams(search), +})); + +describe("useUrlPagination", () => { + beforeEach(() => { + replace.mockClear(); + search = ""; + }); + + it("defaults to page 1 with no query param", () => { + const { result } = renderHook(() => useUrlPagination()); + expect(result.current.page).toBe(1); + }); + + it("reads a valid page from the URL", () => { + search = "page=3"; + const { result } = renderHook(() => useUrlPagination()); + expect(result.current.page).toBe(3); + }); + + it("falls back to page 1 for invalid page values", () => { + search = "page=banana"; + const { result } = renderHook(() => useUrlPagination()); + expect(result.current.page).toBe(1); + + search = "page=0"; + const { result: zero } = renderHook(() => useUrlPagination()); + expect(zero.current.page).toBe(1); + }); + + it("writes page to the URL and drops it on page 1", () => { + const { result } = renderHook(() => useUrlPagination()); + act(() => result.current.setPage(2)); + expect(replace).toHaveBeenCalledWith("/airdrops?page=2", { scroll: false }); + + replace.mockClear(); + search = "page=2"; + const { result: page2 } = renderHook(() => useUrlPagination()); + act(() => page2.current.setPage(1)); + expect(replace).toHaveBeenCalledWith("/airdrops", { scroll: false }); + }); + + it("accepts a functional updater", () => { + search = "page=2"; + const { result } = renderHook(() => useUrlPagination()); + act(() => result.current.setPage((p) => p + 1)); + expect(replace).toHaveBeenCalledWith("/airdrops?page=3", { scroll: false }); + }); +}); diff --git a/src/hooks/useUrlPagination.ts b/src/hooks/useUrlPagination.ts new file mode 100644 index 0000000..a01220f --- /dev/null +++ b/src/hooks/useUrlPagination.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +/** + * Pagination state synced to the `?page=` query param (#593). + * + * Keeping page in the URL means a page can be shared/bookmarked, the browser + * back button steps through pages, and a hard refresh restores position. + * Invalid or missing values fall back to page 1 without throwing. + */ +export function useUrlPagination(defaultPage = 1) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const page = useMemo(() => { + const raw = searchParams.get("page"); + if (!raw) return defaultPage; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 1 ? parsed : defaultPage; + }, [searchParams, defaultPage]); + + const setPage = useCallback( + (next: number | ((current: number) => number)) => { + const value = typeof next === "function" ? next(page) : next; + const clamped = Math.max(1, Math.trunc(value) || 1); + const params = new URLSearchParams(searchParams.toString()); + if (clamped <= 1) params.delete("page"); + else params.set("page", String(clamped)); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [page, pathname, router, searchParams], + ); + + return { page, setPage }; +}