diff --git a/apps/web/src/features/faucet/components/faucet-claim-all.test.tsx b/apps/web/src/features/faucet/components/faucet-claim-all.test.tsx
new file mode 100644
index 0000000..b08b761
--- /dev/null
+++ b/apps/web/src/features/faucet/components/faucet-claim-all.test.tsx
@@ -0,0 +1,200 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { HttpResponse, http } from "msw"
+import { Account, Networks, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"
+import { toast } from "sonner"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import { walletKit } from "@/features/wallet/lib/wallet-kit"
+import { server } from "@/test/msw/server"
+import { fakeWalletAddress } from "@/test/fakes/wallet"
+import { FaucetPage } from "./faucet-page"
+
+// ── Seed values ────────────────────────────────────────────────────────────────
+const CLAIM_AMOUNT_RAW = 10_000_000n
+const BALANCE_RAW = 50_000_000n
+const COOLDOWN_LEDGERS = 100
+const LAST_CLAIM_LEDGER = 0
+
+// Precomputed base64-XDR constants:
+// new SorobanDataBuilder().build().toXDR("base64")
+const EMPTY_SOROBAN_DATA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+// xdr.ScVal.scvVoid().toXDR("base64")
+const SCVAL_VOID = "AAAAAQ=="
+
+// ── UI-only mocks — useClaim and useFaucetData are left real ──────────────────
+vi.mock("@/ui/Navbar", () => ({ Navbar: () => }))
+vi.mock("@/shared/components/TokenIcon", () => ({
+ TokenIcon: ({ symbol }: { symbol: string }) => ,
+}))
+vi.mock("@/features/wallet/components/ConnectButton", () => ({
+ ConnectButton: () => ,
+}))
+vi.mock("@/features/wallet/components/NetworkMismatchBanner", () => ({
+ NetworkMismatchBanner: () => null,
+}))
+vi.mock("@/features/wallet/hooks/useNetwork", () => ({
+ useNetwork: () => ({ mismatch: false, network: "testnet" }),
+}))
+
+function tryGetFunctionName(txXdr: string): string {
+ try {
+ const tx = TransactionBuilder.fromXDR(txXdr, Networks.TESTNET)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const op = tx.operations[0] as any
+ if (op?.type !== "invokeHostFunction") return ""
+ return (op.func.value().functionName() as Buffer).toString("utf-8")
+ } catch {
+ return ""
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("FaucetPage — claim all success flow (#216)", () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+
+ useWalletStore.setState({
+ address: fakeWalletAddress,
+ walletId: "freighter",
+ status: "connected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+
+ vi.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(
+ new Account(fakeWalletAddress, "0"),
+ )
+
+ // Fake signer: echo unsigned XDR back so sendAndPoll can parse it
+ vi.spyOn(walletKit, "signTransaction").mockImplementation(async (xdr) => ({
+ signedTxXdr: xdr,
+ }))
+
+ // Intercept send/poll at the SDK prototype level to avoid building
+ // complex resultMetaXdr / envelopeXdr / resultXdr XDR for getTransaction
+ vi.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue({
+ status: "PENDING",
+ hash: "aBulkClaimTestTransactionHash",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any)
+ vi.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({
+ status: "SUCCESS",
+ txHash: "aBulkClaimTestTransactionHash",
+ latestLedger: 1001,
+ latestLedgerCloseTime: 1_700_000_000,
+ returnValue: undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any)
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(toast, "loading").mockReturnValue("mock-toast-id" as any)
+ vi.spyOn(toast, "success")
+
+ server.use(
+ http.post("https://soroban-testnet.stellar.org", async ({ request }) => {
+ const body = (await request.json()) as {
+ id?: string | number
+ method?: string
+ params?: { transaction?: string }
+ }
+
+ if (body.method !== "simulateTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id: body.id, result: {} })
+ }
+
+ const fnName = tryGetFunctionName(body.params?.transaction ?? "")
+
+ let retvalXdr: string
+ if (fnName === "claim_amount") {
+ retvalXdr = nativeToScVal(CLAIM_AMOUNT_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "balance") {
+ retvalXdr = nativeToScVal(BALANCE_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "last_claim_ledger") {
+ retvalXdr = nativeToScVal(LAST_CLAIM_LEDGER, { type: "u32" }).toXDR("base64")
+ } else if (fnName === "claim_many") {
+ retvalXdr = SCVAL_VOID
+ } else {
+ retvalXdr = nativeToScVal(COOLDOWN_LEDGERS, { type: "u32" }).toXDR("base64")
+ }
+
+ return HttpResponse.json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ latestLedger: 1000,
+ minResourceFee: "100",
+ transactionData: EMPTY_SOROBAN_DATA,
+ results: [{ xdr: retvalXdr, auth: [] }],
+ },
+ })
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+ })
+
+ function renderPage() {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it("shows Claiming… state while in flight then fires success toast", async () => {
+ const user = userEvent.setup()
+ renderPage()
+
+ // Wait for page data to load and bulk button to appear
+ const bulkButton = await screen.findByRole("button", { name: "Claim Test Tokens" }, { timeout: 3000 })
+
+ await user.click(bulkButton)
+
+ // isBulkPending → button label changes immediately
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: /Claiming/i })).toBeInTheDocument(),
+ )
+
+ // sendAndPoll sleeps 1 s before first poll — allow up to 5 s
+ await waitFor(
+ () =>
+ expect(toast.success).toHaveBeenCalledWith("Test tokens claimed!", expect.any(Object)),
+ { timeout: 5000 },
+ )
+ })
+
+ it("invalidates faucet query cache after successful bulk claim", async () => {
+ const user = userEvent.setup()
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
+ renderPage()
+
+ const bulkButton = await screen.findByRole("button", { name: "Claim Test Tokens" }, { timeout: 3000 })
+ await user.click(bulkButton)
+
+ await waitFor(
+ () =>
+ expect(invalidateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["faucet", "data", fakeWalletAddress],
+ }),
+ ),
+ { timeout: 5000 },
+ )
+ })
+})
diff --git a/apps/web/src/features/faucet/components/faucet-claim-one.test.tsx b/apps/web/src/features/faucet/components/faucet-claim-one.test.tsx
new file mode 100644
index 0000000..6e00891
--- /dev/null
+++ b/apps/web/src/features/faucet/components/faucet-claim-one.test.tsx
@@ -0,0 +1,213 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { render, screen, waitFor, fireEvent } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { HttpResponse, http } from "msw"
+import { Account, Networks, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"
+import { toast } from "sonner"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import { walletKit } from "@/features/wallet/lib/wallet-kit"
+import { server } from "@/test/msw/server"
+import { fakeWalletAddress } from "@/test/fakes/wallet"
+import { FaucetPage } from "./faucet-page"
+
+// ── Seed values ────────────────────────────────────────────────────────────────
+const CLAIM_AMOUNT_RAW = 10_000_000n // → fromContractAmount → 1.0 → "1 TUSDC"
+const BALANCE_RAW = 50_000_000n // → fromContractAmount → 5.0 → "5 TUSDC"
+const COOLDOWN_LEDGERS = 100
+const LAST_CLAIM_LEDGER = 0 // 0 → no prior claim recorded
+
+// Hardcoded base64-XDR constants (precomputed via stellar-sdk):
+// new SorobanDataBuilder().build().toXDR("base64")
+const EMPTY_SOROBAN_DATA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+// xdr.ScVal.scvVoid().toXDR("base64")
+const SCVAL_VOID = "AAAAAQ=="
+
+// ── UI-only mocks — data/claim hooks are left real ────────────────────────────
+vi.mock("@/ui/Navbar", () => ({ Navbar: () => }))
+vi.mock("@/shared/components/TokenIcon", () => ({
+ TokenIcon: ({ symbol }: { symbol: string }) => ,
+}))
+vi.mock("@/features/wallet/components/ConnectButton", () => ({
+ ConnectButton: () => ,
+}))
+vi.mock("@/features/wallet/components/NetworkMismatchBanner", () => ({
+ NetworkMismatchBanner: () => null,
+}))
+vi.mock("@/features/wallet/hooks/useNetwork", () => ({
+ useNetwork: () => ({ mismatch: false, network: "testnet" }),
+}))
+
+// ── Helper: extract invoked function name from a transaction XDR ──────────────
+// Mirrors AssembledTransaction.validateInvokeContractOp (assembled_transaction.js:610-619).
+function tryGetFunctionName(txXdr: string): string {
+ try {
+ const tx = TransactionBuilder.fromXDR(txXdr, Networks.TESTNET)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const op = tx.operations[0] as any
+ if (op?.type !== "invokeHostFunction") return ""
+ return (op.func.value().functionName() as Buffer).toString("utf-8")
+ } catch {
+ return ""
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("FaucetPage — claim one success flow (#215)", () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+
+ useWalletStore.setState({
+ address: fakeWalletAddress,
+ walletId: "freighter",
+ status: "connected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+
+ // Prevent real getLedgerEntries HTTP calls for account lookup
+ vi.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(
+ new Account(fakeWalletAddress, "0"),
+ )
+
+ // Fake signer: echo the unsigned XDR back as "signed" so TransactionBuilder
+ // can parse it in sendAndPoll without a real wallet present.
+ vi.spyOn(walletKit, "signTransaction").mockImplementation(async (xdr) => ({
+ signedTxXdr: xdr,
+ }))
+
+ // MSW submission handler: intercept sendTransaction RPC and return PENDING.
+ // getTransaction is intercepted via prototype spy (SUCCESS response requires
+ // complex XDR fields — resultMetaXdr, envelopeXdr, resultXdr — that are
+ // impractical to build from scratch in a test).
+ vi.spyOn(rpc.Server.prototype, "sendTransaction").mockResolvedValue({
+ status: "PENDING",
+ hash: "aTestTransactionHashAbcDef123",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any)
+ vi.spyOn(rpc.Server.prototype, "getTransaction").mockResolvedValue({
+ status: "SUCCESS",
+ txHash: "aTestTransactionHashAbcDef123",
+ latestLedger: 1001,
+ latestLedgerCloseTime: 1_700_000_000,
+ returnValue: undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any)
+
+ // Spy on toast so we can assert calls without needing rendered
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(toast, "loading").mockReturnValue("mock-toast-id" as any)
+ vi.spyOn(toast, "success")
+
+ // MSW simulateTransaction handler: return XDR-encoded ScVal per function
+ server.use(
+ http.post("https://soroban-testnet.stellar.org", async ({ request }) => {
+ const body = (await request.json()) as {
+ id?: string | number
+ method?: string
+ params?: { transaction?: string }
+ }
+
+ if (body.method !== "simulateTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id: body.id, result: {} })
+ }
+
+ const fnName = tryGetFunctionName(body.params?.transaction ?? "")
+
+ let retvalXdr: string
+ if (fnName === "claim_amount") {
+ retvalXdr = nativeToScVal(CLAIM_AMOUNT_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "balance") {
+ retvalXdr = nativeToScVal(BALANCE_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "last_claim_ledger") {
+ retvalXdr = nativeToScVal(LAST_CLAIM_LEDGER, { type: "u32" }).toXDR("base64")
+ } else if (fnName === "claim_many") {
+ retvalXdr = SCVAL_VOID
+ } else {
+ // cooldown_ledgers (and any unrecognised function)
+ retvalXdr = nativeToScVal(COOLDOWN_LEDGERS, { type: "u32" }).toXDR("base64")
+ }
+
+ return HttpResponse.json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ latestLedger: 1000,
+ minResourceFee: "100",
+ // transactionData is required so AssembledTransaction.toXDR() succeeds
+ transactionData: EMPTY_SOROBAN_DATA,
+ results: [{ xdr: retvalXdr, auth: [] }],
+ },
+ })
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+ })
+
+ function renderPage() {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it("shows Claiming state while in flight then fires success toast", async () => {
+ renderPage()
+
+ // Wait for the page to finish loading initial faucet data
+ await waitFor(
+ () => expect(screen.getAllByRole("button", { name: "Claim" })[0]).toBeInTheDocument(),
+ { timeout: 3000 },
+ )
+
+ // Click the first token's Claim button
+ fireEvent.click(screen.getAllByRole("button", { name: "Claim" })[0])
+
+ // Button immediately enters loading state (pendingTokens updated synchronously)
+ await waitFor(() => expect(screen.getByText("Claiming")).toBeInTheDocument())
+
+ // sendAndPoll sleeps 1 s before first poll — allow up to 5 s for resolution
+ await waitFor(
+ () =>
+ expect(toast.success).toHaveBeenCalledWith("Test token claimed!", expect.any(Object)),
+ { timeout: 5000 },
+ )
+ })
+
+ it("invalidates faucet query cache after successful claim", async () => {
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
+ renderPage()
+
+ await waitFor(
+ () => expect(screen.getAllByRole("button", { name: "Claim" })[0]).toBeInTheDocument(),
+ { timeout: 3000 },
+ )
+
+ fireEvent.click(screen.getAllByRole("button", { name: "Claim" })[0])
+
+ await waitFor(
+ () =>
+ expect(invalidateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["faucet", "data", fakeWalletAddress],
+ }),
+ ),
+ { timeout: 5000 },
+ )
+ })
+})
diff --git a/apps/web/src/features/faucet/components/faucet-cooldown-error.test.tsx b/apps/web/src/features/faucet/components/faucet-cooldown-error.test.tsx
new file mode 100644
index 0000000..004732d
--- /dev/null
+++ b/apps/web/src/features/faucet/components/faucet-cooldown-error.test.tsx
@@ -0,0 +1,193 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { HttpResponse, http } from "msw"
+import { Account, Networks, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"
+import { toast } from "sonner"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import { server } from "@/test/msw/server"
+import { fakeWalletAddress } from "@/test/fakes/wallet"
+import { FaucetPage } from "./faucet-page"
+
+// ── Seed values for useFaucetData read calls ───────────────────────────────────
+const CLAIM_AMOUNT_RAW = 10_000_000n
+const BALANCE_RAW = 50_000_000n
+const COOLDOWN_LEDGERS = 100
+const LAST_CLAIM_LEDGER = 0
+
+// Precomputed: new SorobanDataBuilder().build().toXDR("base64")
+const EMPTY_SOROBAN_DATA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+
+// ── UI-only mocks — useClaim and useFaucetData are left real ──────────────────
+vi.mock("@/ui/Navbar", () => ({ Navbar: () => }))
+vi.mock("@/shared/components/TokenIcon", () => ({
+ TokenIcon: ({ symbol }: { symbol: string }) => ,
+}))
+vi.mock("@/features/wallet/components/ConnectButton", () => ({
+ ConnectButton: () => ,
+}))
+vi.mock("@/features/wallet/components/NetworkMismatchBanner", () => ({
+ NetworkMismatchBanner: () => null,
+}))
+vi.mock("@/features/wallet/hooks/useNetwork", () => ({
+ useNetwork: () => ({ mismatch: false, network: "testnet" }),
+}))
+
+function tryGetFunctionName(txXdr: string): string {
+ try {
+ const tx = TransactionBuilder.fromXDR(txXdr, Networks.TESTNET)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const op = tx.operations[0] as any
+ if (op?.type !== "invokeHostFunction") return ""
+ return (op.func.value().functionName() as Buffer).toString("utf-8")
+ } catch {
+ return ""
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("FaucetPage — cooldown failure flow (#217)", () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+
+ useWalletStore.setState({
+ address: fakeWalletAddress,
+ walletId: "freighter",
+ status: "connected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+
+ vi.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(
+ new Account(fakeWalletAddress, "0"),
+ )
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.spyOn(toast, "loading").mockReturnValue("mock-toast-id" as any)
+ vi.spyOn(toast, "error")
+
+ // MSW handler:
+ // - claim_many → contract error "Error(Contract, #6)"
+ // The SDK's AssembledTransaction.simulationData getter sees
+ // Api.isSimulationError(sim) === true and throws:
+ // SimulationFailed: Transaction simulation failed: "Error(Contract, #6)"
+ // useClaim.isClaimTooSoonError matches via /error\(contract,\s*#6\)/i.
+ // - all other simulate calls → normal success responses (for page load)
+ // - no sendTransaction / getTransaction calls are made; the error is
+ // thrown during simulation so signing and submission never occur.
+ server.use(
+ http.post("https://soroban-testnet.stellar.org", async ({ request }) => {
+ const body = (await request.json()) as {
+ id?: string | number
+ method?: string
+ params?: { transaction?: string }
+ }
+
+ if (body.method !== "simulateTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id: body.id, result: {} })
+ }
+
+ const fnName = tryGetFunctionName(body.params?.transaction ?? "")
+
+ if (fnName === "claim_many") {
+ return HttpResponse.json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ error: "Error(Contract, #6)",
+ latestLedger: 1000,
+ },
+ })
+ }
+
+ // Read-only calls needed for page load
+ let retvalXdr: string
+ if (fnName === "claim_amount") {
+ retvalXdr = nativeToScVal(CLAIM_AMOUNT_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "balance") {
+ retvalXdr = nativeToScVal(BALANCE_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "last_claim_ledger") {
+ retvalXdr = nativeToScVal(LAST_CLAIM_LEDGER, { type: "u32" }).toXDR("base64")
+ } else {
+ // cooldown_ledgers
+ retvalXdr = nativeToScVal(COOLDOWN_LEDGERS, { type: "u32" }).toXDR("base64")
+ }
+
+ return HttpResponse.json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ latestLedger: 1000,
+ minResourceFee: "100",
+ transactionData: EMPTY_SOROBAN_DATA,
+ results: [{ xdr: retvalXdr, auth: [] }],
+ },
+ })
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+ })
+
+ function renderPage() {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it("shows cooldown error toast after single token claim", async () => {
+ const user = userEvent.setup()
+ renderPage()
+
+ await waitFor(
+ () => expect(screen.getAllByRole("button", { name: "Claim" })[0]).toBeInTheDocument(),
+ { timeout: 3000 },
+ )
+
+ await user.click(screen.getAllByRole("button", { name: "Claim" })[0])
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(
+ "Cooldown active — please wait before claiming again.",
+ expect.any(Object),
+ ),
+ )
+ })
+
+ it("shows cooldown error toast after bulk claim", async () => {
+ const user = userEvent.setup()
+ renderPage()
+
+ const bulkButton = await screen.findByRole(
+ "button",
+ { name: "Claim Test Tokens" },
+ { timeout: 3000 },
+ )
+
+ await user.click(bulkButton)
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(
+ "Cooldown active — please wait before claiming again.",
+ expect.any(Object),
+ ),
+ )
+ })
+})
diff --git a/apps/web/src/features/faucet/components/faucet-page-connected.test.tsx b/apps/web/src/features/faucet/components/faucet-page-connected.test.tsx
new file mode 100644
index 0000000..0f0fd3a
--- /dev/null
+++ b/apps/web/src/features/faucet/components/faucet-page-connected.test.tsx
@@ -0,0 +1,171 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { HttpResponse, http } from "msw"
+import { Account, Networks, TransactionBuilder, nativeToScVal, rpc } from "@stellar/stellar-sdk"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import { server } from "@/test/msw/server"
+import { fakeWalletAddress } from "@/test/fakes/wallet"
+import { FaucetPage } from "./faucet-page"
+
+// ── Fixed seed values ─────────────────────────────────────────────────────────
+// fromContractAmount divides raw bigint by 1e7
+// 10_000_000n / 1e7 = 1 → formatToken → "1 TUSDC"
+// 50_000_000n / 1e7 = 5 → formatToken → "5 TUSDC"
+const CLAIM_AMOUNT_RAW = 10_000_000n
+const BALANCE_RAW = 50_000_000n
+const COOLDOWN_LEDGERS = 100
+const LAST_CLAIM_LEDGER = 999
+
+// ── UI and claim mocks ────────────────────────────────────────────────────────
+// useClaim is stubbed because it transitively imports @/lib/contracts.ts, which
+// instantiates contract clients at module-load time with test contract IDs that
+// fail Stellar strkey validation in bun's test runner. The data-fetching path
+// (useFaucetData / lib/clients / data/tokens) is left completely real.
+vi.mock("../hooks/useClaim", () => ({
+ useClaim: () => ({
+ claimOne: vi.fn(),
+ claimAll: vi.fn(),
+ pendingTokens: new Set(),
+ isBulkPending: false,
+ }),
+}))
+vi.mock("@/ui/Navbar", () => ({ Navbar: () => }))
+vi.mock("@/shared/components/TokenIcon", () => ({
+ TokenIcon: ({ symbol }: { symbol: string }) => ,
+}))
+vi.mock("@/features/wallet/components/ConnectButton", () => ({
+ ConnectButton: () => ,
+}))
+vi.mock("@/features/wallet/components/NetworkMismatchBanner", () => ({
+ NetworkMismatchBanner: () => null,
+}))
+vi.mock("@/features/wallet/hooks/useNetwork", () => ({
+ useNetwork: () => ({ mismatch: false, network: "testnet" }),
+}))
+
+// ── Helper: extract the Soroban function name from a transaction XDR ──────────
+// Mirrors the path used by AssembledTransaction.validateInvokeContractOp
+// (stellar-sdk/lib/contract/assembled_transaction.js:610-619).
+function tryGetFunctionName(txXdr: string): string {
+ try {
+ const tx = TransactionBuilder.fromXDR(txXdr, Networks.TESTNET)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const op = tx.operations[0] as any
+ if (op?.type !== "invokeHostFunction") return ""
+ // op.func is xdr.HostFunction; .value() returns InvokeContractArgs
+ return (op.func.value().functionName() as Buffer).toString("utf-8")
+ } catch {
+ return ""
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("FaucetPage — connected state renders RPC data (#214)", () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+
+ // Seed connected wallet
+ useWalletStore.setState({
+ address: fakeWalletAddress,
+ walletId: "freighter",
+ status: "connected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+
+ // Prevent real getLedgerEntries calls for account lookup (third-party SDK method)
+ vi.spyOn(rpc.Server.prototype, "getAccount").mockResolvedValue(
+ new Account(fakeWalletAddress, "0"),
+ )
+
+ // Intercept every simulateTransaction call and return an XDR-encoded ScVal
+ // whose type matches the contract method's declared return type.
+ server.use(
+ http.post("https://soroban-testnet.stellar.org", async ({ request }) => {
+ const body = (await request.json()) as {
+ id?: string | number
+ method?: string
+ params?: { transaction?: string }
+ }
+
+ if (body.method !== "simulateTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id: body.id, result: {} })
+ }
+
+ const fnName = tryGetFunctionName(body.params?.transaction ?? "")
+
+ let retvalXdr: string
+ if (fnName === "claim_amount") {
+ retvalXdr = nativeToScVal(CLAIM_AMOUNT_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "balance") {
+ retvalXdr = nativeToScVal(BALANCE_RAW, { type: "i128" }).toXDR("base64")
+ } else if (fnName === "last_claim_ledger") {
+ retvalXdr = nativeToScVal(LAST_CLAIM_LEDGER, { type: "u32" }).toXDR("base64")
+ } else {
+ // cooldown_ledgers (or any unrecognised function)
+ retvalXdr = nativeToScVal(COOLDOWN_LEDGERS, { type: "u32" }).toXDR("base64")
+ }
+
+ return HttpResponse.json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ latestLedger: 1000,
+ minResourceFee: "100",
+ results: [{ xdr: retvalXdr, auth: [] }],
+ },
+ })
+ }),
+ )
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ useWalletStore.setState({
+ address: null,
+ walletId: null,
+ status: "disconnected",
+ pendingTransactionXdr: null,
+ network: "testnet",
+ })
+ })
+
+ function renderPage() {
+ return render(
+
+
+ ,
+ )
+ }
+
+ it("renders token balances after data loads", async () => {
+ renderPage()
+ await waitFor(() => expect(screen.getByText("5 TUSDC")).toBeInTheDocument())
+ })
+
+ it("renders claim amounts after data loads", async () => {
+ renderPage()
+ await waitFor(() => expect(screen.getByText("1 TUSDC")).toBeInTheDocument())
+ })
+
+ it("renders cooldown ledger count after data loads", async () => {
+ renderPage()
+ await waitFor(() =>
+ expect(screen.getByText(/100 ledgers between claims/i)).toBeInTheDocument(),
+ )
+ })
+
+ it("renders last claim ledger text after data loads", async () => {
+ renderPage()
+ // Component renders "Last claim ledger {n}" once per token card (4 tokens)
+ await waitFor(() =>
+ expect(screen.getAllByText(/Last claim ledger 999/)).not.toHaveLength(0),
+ )
+ })
+})