Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/app/airdrops/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { useState } from "react";
import {
Badge,
Box,
Expand All @@ -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) {
Expand All @@ -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],
Expand Down
34 changes: 34 additions & 0 deletions src/app/api/sign-fee-bump/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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");
});
});
16 changes: 12 additions & 4 deletions src/app/api/sign-fee-bump/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}
Expand Down Expand Up @@ -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 },
);
}
Expand All @@ -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 },
);
}
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/app/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -151,7 +152,8 @@ export default function HistoryPage() {
const [entries, setEntries] = useState<TxHistoryEntry[]>([]);
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<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);

Expand Down
51 changes: 51 additions & 0 deletions src/components/OnboardingOverlay/OnboardingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -55,6 +72,7 @@ export default function OnboardingOverlay() {
const handleClose = () => {
localStorage.setItem("smartdrop_onboarded", "true");
setIsOpen(false);
setCurrentStep(0);
};

const nextStep = () => {
Expand All @@ -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 (
Expand Down Expand Up @@ -156,6 +197,16 @@ export default function OnboardingOverlay() {
<Button variant="ghost" onClick={handleClose} color="app.muted">
Skip
</Button>
{currentStep > 0 && (
<Button
variant="ghost"
onClick={prevStep}
color="app.text"
borderRadius="full"
>
Back
</Button>
)}
{currentStep === STEPS.length - 1 ? (
isConnected ? (
<Button
Expand Down
42 changes: 27 additions & 15 deletions src/components/UnlockModal/UnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,35 +400,47 @@ export default function UnlockModal() {
_hover={{ borderColor: "app.accent" }}
_focus={{ boxShadow: "none", borderColor: "app.accent" }}
/>
{/* Tab order (#591): these controls sit visually on the right of
the amount field and come right after it in the DOM, so Tab
walks input → 50% → Max → actions. Real buttons (not click
handlers on Text) so keyboard users can actually reach them. */}
<Flex
position="absolute"
top="50%"
right="12px"
transform="translateY(-50%)"
gap={3}
gap={2}
align="center"
>
<Text fontSize="sm">{position.symbol}</Text>
<Text
fontSize="xs"
<Button
type="button"
size="xs"
variant="ghost"
color="app.accent"
cursor={canUnlock ? "pointer" : "not-allowed"}
onClick={canUnlock ? set50Pct : undefined}
_hover={canUnlock ? { opacity: 0.8 } : {}}
transition="opacity 0.2s"
minW="auto"
h="auto"
px={1}
fontSize="xs"
isDisabled={!canUnlock || isProcessing}
onClick={set50Pct}
>
50%
</Text>
<Text
fontSize="xs"
</Button>
<Button
type="button"
size="xs"
variant="ghost"
color="app.accent"
cursor={canUnlock ? "pointer" : "not-allowed"}
onClick={canUnlock ? setMax : undefined}
_hover={canUnlock ? { opacity: 0.8 } : {}}
transition="opacity 0.2s"
minW="auto"
h="auto"
px={1}
fontSize="xs"
isDisabled={!canUnlock || isProcessing}
onClick={setMax}
>
Max
</Text>
</Button>
</Flex>
</Box>
</Flex>
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/useLeaderboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>() {
let resolve!: (value: T) => void;
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useLeaderboard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useUrlPagination } from "./useUrlPagination";

export type SortKey = "credits" | "stake";

Expand Down Expand Up @@ -33,7 +34,8 @@ export function useLeaderboard(publicKey: string | null) {
const [sortKey, setSortKeyState] = useState<SortKey>("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<Date | null>(null);

useEffect(() => {
Expand Down
64 changes: 64 additions & 0 deletions src/hooks/useUrlPagination.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading