From be1a1113c93fb31e36f64397cae78cea020d5b8e Mon Sep 17 00:00:00 2001 From: Purity-Euphemia <208311370+Purity-Euphemia@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:19:49 +0100 Subject: [PATCH] test(frontend): cover stream-creation amount, schedule, token and tx-lifecycle paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1794, Closes #1795, Closes #1797, Closes #1798 Adds deterministic regression tests for the four modules flagged by the testing sweep, plus a small fix to the flat create-stream form so the first validation error is actually surfaced as a toast. #1794 — create-wizard transaction lifecycle - frontend/src/lib/soroban.polling.test.ts: the confirmation polling loop (success, on-chain failure, 20-attempt timeout, wallet cancellation, broadcast error, simulation error, multisig routing). - frontend/src/app/streams/create/__tests__/create-stream-content.tx-lifecycle.test.tsx: the page-level states (signing -> confirming -> redirect, timeout, failed transaction, cancellation, validation and dependency failures). #1795 — frontend/src/components/stream-creation/__tests__/AmountStep.test.tsx 7-decimal token precision boundaries, zero/invalid amounts, over-balance detection, XLM base reserve, the Max button and balance lookup failures. #1797 — frontend/src/components/stream-creation/__tests__/ScheduleStep.test.tsx duration unit conversion to fixed seconds, timezone/DST invariant durations, duration validation and the 28-byte UTF-8 memo limit. #1798 — frontend/src/components/stream-creation/__tests__/TokenStep.test.tsx token selection and error contract, missing token metadata (avatar fallback), failed token lookup, and unsupported/failed decimals lookups. Fix: useStreamForm.validateAll() now returns the freshly computed errors instead of a boolean, so create-stream-content can report the first validation error as a toast (the previous code read the hook state before the state update had been applied, so the toast was never shown). --- ...reate-stream-content.tx-lifecycle.test.tsx | 345 ++++++++++++++++++ .../streams/create/create-stream-content.tsx | 9 +- .../__tests__/AmountStep.test.tsx | 255 +++++++++++++ .../__tests__/ScheduleStep.test.tsx | 320 ++++++++++++++++ .../__tests__/TokenStep.test.tsx | 298 +++++++++++++++ frontend/src/hooks/useStreamForm.ts | 16 +- frontend/src/lib/soroban.polling.test.ts | 238 ++++++++++++ 7 files changed, 1474 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/streams/create/__tests__/create-stream-content.tx-lifecycle.test.tsx create mode 100644 frontend/src/components/stream-creation/__tests__/AmountStep.test.tsx create mode 100644 frontend/src/components/stream-creation/__tests__/ScheduleStep.test.tsx create mode 100644 frontend/src/components/stream-creation/__tests__/TokenStep.test.tsx create mode 100644 frontend/src/lib/soroban.polling.test.ts diff --git a/frontend/src/app/streams/create/__tests__/create-stream-content.tx-lifecycle.test.tsx b/frontend/src/app/streams/create/__tests__/create-stream-content.tx-lifecycle.test.tsx new file mode 100644 index 00000000..33602243 --- /dev/null +++ b/frontend/src/app/streams/create/__tests__/create-stream-content.tx-lifecycle.test.tsx @@ -0,0 +1,345 @@ +/** + * Create-stream transaction lifecycle tests (issue #1794). + * + * Creating a stream is a multi-phase operation: the wallet signs the + * transaction, the network confirms it (the Soroban client polls the RPC + * until the transaction reaches a terminal state) and only then is the + * stream considered created. This file pins down every terminal state of + * that lifecycle as observed by the page: + * + * - success → the transaction is confirmed, the draft is discarded and the + * user is redirected to the dashboard, + * - timeout → the confirmation never arrives, + * - failure → the transaction failed on-chain, + * - cancellation → the user rejected the transaction in the wallet, + * - validation/dependency failures → the submission never reaches the network. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; + +const VALID_KEY = "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7"; + +const push = vi.fn(); +const searchParamsMock = { get: vi.fn() }; + +const mockUseWallet = vi.hoisted(() => + vi.fn(() => ({ + status: "disconnected", + session: null as null | { publicKey: string }, + })), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => searchParamsMock, +})); + +vi.mock("react-hot-toast", () => { + const toast = { success: vi.fn(), error: vi.fn(), loading: vi.fn() }; + return { default: toast, toast }; +}); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: mockUseWallet, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/soroban", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createStream: vi.fn(), + fetchTokenBalanceDisplay: vi.fn(), + }; +}); + +import CreateStreamContent from "../create-stream-content"; +import { createStream, fetchTokenBalanceDisplay, SorobanCallError } from "@/lib/soroban"; +import { toast } from "react-hot-toast"; + +const mockedCreateStream = vi.mocked(createStream); +const mockedFetchBalance = vi.mocked(fetchTokenBalanceDisplay); + +function getRecipientInput() { + return screen.getByLabelText(/recipient/i) as HTMLInputElement; +} + +function getAmountInput() { + return screen.getByLabelText(/total amount/i) as HTMLInputElement; +} + +function getSubmitButton() { + return screen.getByRole("button", { name: /start streaming|confirm in wallet|finalizing stream|submitting to network|processing/i }); +} + +/** Fill in a valid recipient and amount and submit the form. */ +async function submitValidForm(amount = "100") { + await act(async () => { + fireEvent.change(getRecipientInput(), { target: { value: VALID_KEY } }); + fireEvent.change(getAmountInput(), { target: { value: amount } }); + }); + + await act(async () => { + fireEvent.click(getSubmitButton()); + }); +} + +describe("CreateStreamContent transaction lifecycle (issue #1794)", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParamsMock.get.mockReturnValue(null); + mockUseWallet.mockReturnValue({ status: "connected", session: { publicKey: VALID_KEY } }); + mockedFetchBalance.mockResolvedValue("1000"); + mockedCreateStream.mockResolvedValue({ success: true, txHash: "tx_hash" }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── Success ──────────────────────────────────────────────────────────────── + + it("submits the stream with the converted amount and duration", async () => { + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(mockedCreateStream).toHaveBeenCalledTimes(1); + }); + + const call = mockedCreateStream.mock.calls[0]; + expect(call).toBeDefined(); + const [session, params] = call!; + expect(session.publicKey).toBe(VALID_KEY); + expect(params.recipient).toBe(VALID_KEY); + expect(params.amount).toBe(1_000_000_000n); // 100 * 10^7 stroops + expect(params.durationSeconds).toBe(2_592_000n); // 30 days + expect(typeof params.tokenAddress).toBe("string"); + }); + + it("shows the signing state while the transaction is being confirmed", async () => { + let resolveSubmit!: (value: { success: true; txHash: string }) => void; + mockedCreateStream.mockReturnValue( + new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /confirm in wallet/i })).toBeDisabled(); + }); + + await act(async () => { + resolveSubmit({ success: true, txHash: "tx_hash" }); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /finalizing stream/i })).toBeInTheDocument(); + }); + }); + + it("confirms the stream, notifies the user and redirects to the dashboard", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /finalizing stream/i })).toBeInTheDocument(); + }); + expect(toast.success).toHaveBeenCalledWith("Stream created successfully!"); + + // The redirect happens after the success toast has been shown. + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(push).toHaveBeenCalledWith("/dashboard"); + expect(screen.getByRole("button", { name: /start streaming/i })).toBeEnabled(); + }); + + // ── Timeout ──────────────────────────────────────────────────────────────── + + it("surfaces a confirmation timeout and does not redirect", async () => { + mockedCreateStream.mockRejectedValue( + new SorobanCallError("Transaction confirmation timed out.", "NetworkError"), + ); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Transaction confirmation timed out."); + }); + expect(push).not.toHaveBeenCalled(); + // The form is usable again so the user can retry. + expect(screen.getByRole("button", { name: /start streaming/i })).toBeEnabled(); + }); + + it("maps a generic timeout error to the network-congestion message", async () => { + mockedCreateStream.mockRejectedValue(new Error("Request timeout")); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Transaction timed out. The network may be congested — please try again.", + ); + }); + }); + + // ── Failed transaction ───────────────────────────────────────────────────── + + it("surfaces an on-chain failure and does not redirect", async () => { + mockedCreateStream.mockRejectedValue( + new SorobanCallError("Transaction failed on-chain.", "NetworkError"), + ); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Transaction failed on-chain."); + }); + expect(push).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /start streaming/i })).toBeEnabled(); + }); + + it("maps an insufficient-balance failure to a readable message", async () => { + mockedCreateStream.mockRejectedValue(new Error("insufficient balance")); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Insufficient balance to complete this transaction.", + ); + }); + }); + + it("logs the failure before reporting it to the user", async () => { + const { logger } = await import("@/lib/logger"); + mockedCreateStream.mockRejectedValue( + new SorobanCallError("Transaction failed on-chain.", "NetworkError"), + ); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(logger.error).toHaveBeenCalledWith( + "Stream creation failed:", + expect.any(SorobanCallError), + ); + }); + }); + + // ── Cancellation ─────────────────────────────────────────────────────────── + + it("reports a wallet cancellation with the rejection message", async () => { + mockedCreateStream.mockRejectedValue(new Error("User rejected the request")); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Transaction was rejected in your wallet.", + ); + }); + expect(push).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /start streaming/i })).toBeEnabled(); + }); + + it("reports a wallet cancellation raised as a SorobanCallError", async () => { + mockedCreateStream.mockRejectedValue( + new SorobanCallError("Transaction was rejected in wallet.", "WalletRejected"), + ); + + render(); + await submitValidForm("100"); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Transaction was rejected in wallet."); + }); + }); + + // ── Validation and dependency failures ───────────────────────────────────── + + it("does not submit when the recipient is invalid and reports the error", async () => { + render(); + + await act(async () => { + fireEvent.change(getRecipientInput(), { target: { value: "not-a-valid-key" } }); + fireEvent.change(getAmountInput(), { target: { value: "100" } }); + }); + + await act(async () => { + fireEvent.click(getSubmitButton()); + }); + + expect(mockedCreateStream).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + "Invalid Stellar public key (must start with 'G' and be 56 characters)", + ); + }); + + it("does not submit when the amount exceeds the wallet balance", async () => { + mockedFetchBalance.mockResolvedValue("100"); + + render(); + + await act(async () => { + fireEvent.change(getRecipientInput(), { target: { value: VALID_KEY } }); + fireEvent.change(getAmountInput(), { target: { value: "500" } }); + }); + + // Let the balance lookup settle so the amount can be validated against it. + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + fireEvent.click(getSubmitButton()); + }); + + expect(mockedCreateStream).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("Amount exceeds wallet balance"); + }); + + it("keeps the submit button disabled while the wallet is disconnected", () => { + mockUseWallet.mockReturnValue({ status: "disconnected", session: null }); + + render(); + + expect(screen.getByRole("button", { name: /start streaming/i })).toBeDisabled(); + expect( + screen.getByText(/please connect your wallet to create a stream/i), + ).toBeInTheDocument(); + }); + + it("surfaces a wallet balance lookup failure without blocking the form", async () => { + mockedFetchBalance.mockRejectedValue(new Error("rpc down")); + + render(); + + await act(async () => { + fireEvent.change(getAmountInput(), { target: { value: "100" } }); + }); + + await waitFor(() => { + expect( + screen.getByText(/unable to fetch wallet balance/i), + ).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: /start streaming/i })).toBeEnabled(); + }); +}); diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index 781cd08c..e8b9f7bc 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -71,9 +71,12 @@ export default function CreateStreamContent() { } // Use the shared validation (checks recipient format, amount, precision, balance) - if (!validateAll()) { - // Show the first error as a toast for flat-form UX - const firstError = Object.values(errors)[0]; + const validationErrors = validateAll(); + if (Object.keys(validationErrors).length > 0) { + // Show the first error as a toast for flat-form UX. The errors are read + // from the validation result, not from the hook state, because the + // state update has not been applied yet at this point. + const firstError = Object.values(validationErrors)[0]; if (firstError) toast.error(firstError); return; } diff --git a/frontend/src/components/stream-creation/__tests__/AmountStep.test.tsx b/frontend/src/components/stream-creation/__tests__/AmountStep.test.tsx new file mode 100644 index 00000000..7702e90b --- /dev/null +++ b/frontend/src/components/stream-creation/__tests__/AmountStep.test.tsx @@ -0,0 +1,255 @@ +/** + * AmountStep regression tests (issue #1795). + * + * The amount step is the last gate before a stream is submitted, so its + * public contract has to be pinned down: + * + * - token decimals — Stellar assets use 7 decimals; the shared validator + * must accept exactly 7 fractional digits and reject 8+, + * - zero amount — "0" is never a valid stream amount, + * - excessive precision — the amount must never be silently rounded, + * - dependency failures — an unavailable wallet balance must degrade + * gracefully instead of blocking the user or showing a bogus error. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +import { AmountStep } from "../AmountStep"; +import { + validateAmount, + getSpendableBalance, + XLM_BASE_RESERVE, +} from "@/lib/stream-validation"; + +function renderAmountStep(props: Partial> = {}) { + const onChange = vi.fn(); + const onSetMax = vi.fn(); + const utils = render( + , + ); + return { onChange, onSetMax, ...utils }; +} + +function getAmountInput() { + return screen.getByLabelText(/amount/i) as HTMLInputElement; +} + +describe("AmountStep — token decimals contract (issue #1795)", () => { + it("accepts an amount with exactly seven decimal places", () => { + expect(validateAmount("1.1234567", "1000", "USDC")).toBeNull(); + expect(validateAmount("1.1234567", "1000", "XLM")).toBeNull(); + }); + + it("rejects an amount with eight decimal places instead of rounding it", () => { + expect(validateAmount("1.12345678", "1000", "USDC")).toBe( + "Amount exceeds maximum precision (7 decimal places)", + ); + expect(validateAmount("1.12345678", "1000", "XLM")).toBe( + "Amount exceeds maximum precision (7 decimal places)", + ); + }); + + it("keeps the precision limit aligned with the token's 7 decimals", () => { + // Boundary: one digit below / one digit above the limit. + expect(validateAmount("0.123456", "1000", "USDC")).toBeNull(); + expect(validateAmount("0.12345678", "1000", "USDC")).toBe( + "Amount exceeds maximum precision (7 decimal places)", + ); + }); + + it("rejects a negative amount", () => { + expect(validateAmount("-1", "1000", "USDC")).toBe( + "Amount must be a positive number", + ); + }); + + it("surfaces the precision error through the step's error contract", () => { + const precisionError = validateAmount("1.12345678", "1000", "USDC"); + renderAmountStep({ value: "1.12345678", error: precisionError ?? undefined }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent( + "Amount exceeds maximum precision (7 decimal places)", + ); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "true"); + expect(getAmountInput()).toHaveAttribute("aria-describedby", "amount-error"); + }); +}); + +describe("AmountStep — zero amount (issue #1795)", () => { + it("rejects a zero amount", () => { + expect(validateAmount("0", "1000", "USDC")).toBe( + "Amount must be a positive number", + ); + }); + + it("rejects an amount that is only a decimal separator", () => { + expect(validateAmount("0.0", "1000", "USDC")).toBe( + "Amount must be a positive number", + ); + }); + + it("rejects an empty amount", () => { + expect(validateAmount("", "1000", "USDC")).toBe("Amount is required"); + }); + + it("does not show the stream summary for a zero amount", () => { + renderAmountStep({ value: "0" }); + + expect( + screen.queryByText(/you will stream/i), + ).not.toBeInTheDocument(); + // A zero amount is not an over-balance error either. + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "false"); + }); + + it("shows the stream summary once the amount is positive", () => { + renderAmountStep({ value: "12.5" }); + + expect(screen.getByText(/you will stream/i)).toHaveTextContent( + "You will stream 12.5 USDC to the recipient.", + ); + }); +}); + +describe("AmountStep — spendable balance (issue #1795)", () => { + it("flags an amount above the wallet balance", () => { + renderAmountStep({ + value: "500", + availableBalance: "100", + token: "USDC", + }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Amount exceeds wallet balance", + ); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "true"); + }); + + it("accepts an amount exactly equal to the spendable balance", () => { + renderAmountStep({ + value: "100", + availableBalance: "100", + token: "USDC", + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "false"); + }); + + it("subtracts the XLM base reserve from the displayed balance", () => { + renderAmountStep({ + value: "", + availableBalance: "5", + token: "XLM", + }); + + expect(screen.getByText(/available:/i)).toHaveTextContent( + `Available: ${5 - XLM_BASE_RESERVE} XLM`, + ); + expect( + screen.getByText(/is kept in reserve for network fees/i), + ).toHaveTextContent(`${XLM_BASE_RESERVE} XLM is kept in reserve for network fees.`); + }); + + it("does not subtract the reserve for non-XLM tokens", () => { + expect(getSpendableBalance("5", "USDC")).toBe(5); + expect(getSpendableBalance("5", "XLM")).toBe(4); + // A balance below the reserve leaves nothing spendable, never a negative. + expect(getSpendableBalance("0.5", "XLM")).toBe(0); + }); + + it("does not report an over-balance error when the balance is unknown", () => { + renderAmountStep({ + value: "1000000", + availableBalance: null, + token: "USDC", + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "false"); + }); +}); + +describe("AmountStep — Max button (issue #1795)", () => { + it("is disabled while the balance is loading", () => { + renderAmountStep({ + availableBalance: "100", + isBalanceLoading: true, + }); + + expect(screen.getByRole("button", { name: /max/i })).toBeDisabled(); + expect(screen.getByText(/loading balance/i)).toBeInTheDocument(); + }); + + it("is disabled when no spendable balance is known", () => { + renderAmountStep({ availableBalance: null }); + + expect(screen.getByRole("button", { name: /max/i })).toBeDisabled(); + }); + + it("is enabled and calls onSetMax when a spendable balance is known", () => { + const { onSetMax } = renderAmountStep({ availableBalance: "100" }); + + const maxButton = screen.getByRole("button", { name: /max/i }); + expect(maxButton).toBeEnabled(); + + fireEvent.click(maxButton); + expect(onSetMax).toHaveBeenCalledTimes(1); + }); + + it("is disabled when no onSetMax handler is provided", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /max/i })).toBeDisabled(); + }); +}); + +describe("AmountStep — dependency failures (issue #1795)", () => { + it("shows the balance error as a non-blocking status message", () => { + renderAmountStep({ + value: "10", + availableBalance: null, + balanceError: "Unable to fetch wallet balance right now.", + }); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent( + "Unable to fetch wallet balance right now.", + ); + // A dependency failure must not be reported as a validation error. + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(getAmountInput()).toHaveAttribute("aria-invalid", "false"); + }); + + it("hides the balance error once a validation error is present", () => { + renderAmountStep({ + value: "10", + availableBalance: null, + balanceError: "Unable to fetch wallet balance right now.", + error: "Amount must be a positive number", + }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Amount must be a positive number", + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("forwards every keystroke to onChange", () => { + const { onChange } = renderAmountStep({ value: "" }); + + fireEvent.change(getAmountInput(), { target: { value: "42.5" } }); + expect(onChange).toHaveBeenCalledWith("42.5"); + }); +}); diff --git a/frontend/src/components/stream-creation/__tests__/ScheduleStep.test.tsx b/frontend/src/components/stream-creation/__tests__/ScheduleStep.test.tsx new file mode 100644 index 00000000..09153038 --- /dev/null +++ b/frontend/src/components/stream-creation/__tests__/ScheduleStep.test.tsx @@ -0,0 +1,320 @@ +/** + * ScheduleStep regression tests (issue #1797). + * + * The schedule step owns the stream duration (and its unit) plus the optional + * memo. Durations are turned into a fixed number of *seconds* on-chain, so + * the conversion must not depend on the viewer's timezone, on calendar + * arithmetic, or on daylight-saving transitions. The memo is capped at 28 + * UTF-8 bytes (the Stellar memo limit). + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +import { ScheduleStep } from "../ScheduleStep"; +import { validateDuration } from "@/lib/stream-validation"; +import { toDurationSeconds, SECONDS_PER_UNIT } from "@/lib/soroban"; + +const MAX_MEMO_BYTES = 28; + +type ScheduleStepProps = ComponentProps; + +function renderScheduleStep(props: Partial = {}) { + const onUpdate = vi.fn(); + const utils = render( + , + ); + return { onUpdate, ...utils }; +} + +function getDurationInput() { + return screen.getByLabelText(/duration/i) as HTMLInputElement; +} + +function getUnitSelect() { + return screen.getByRole("combobox") as HTMLSelectElement; +} + +function getMemoTextarea() { + return screen.getByLabelText(/memo/i) as HTMLTextAreaElement; +} + +/** The "N / 28 bytes" counter (distinct from the warning copy, which also ends in "bytes"). */ +function getByteCounter() { + return screen.getByText(/^\d+ \/ 28 bytes$/); +} + +describe("ScheduleStep — duration unit conversion (issue #1797)", () => { + it("renders the three supported duration units", () => { + renderScheduleStep(); + + const options = Array.from(getUnitSelect().options).map((o) => o.value); + expect(options).toEqual(["days", "weeks", "months"]); + }); + + it("reports the selected duration unit to the parent", () => { + const { onUpdate } = renderScheduleStep(); + + fireEvent.change(getUnitSelect(), { target: { value: "weeks" } }); + expect(onUpdate).toHaveBeenCalledWith({ durationUnit: "weeks" }); + }); + + it("reports duration changes to the parent", () => { + const { onUpdate } = renderScheduleStep(); + + fireEvent.change(getDurationInput(), { target: { value: "45" } }); + expect(onUpdate).toHaveBeenCalledWith({ duration: "45" }); + }); + + it("converts every unit to a fixed number of seconds", () => { + expect(toDurationSeconds("1", "days")).toBe(86_400n); + expect(toDurationSeconds("1", "weeks")).toBe(604_800n); + expect(toDurationSeconds("1", "months")).toBe(2_592_000n); + + expect(SECONDS_PER_UNIT.days).toBe(86_400n); + expect(SECONDS_PER_UNIT.weeks).toBe(604_800n); + expect(SECONDS_PER_UNIT.months).toBe(2_592_000n); + }); + + it("converts a month to exactly 30 days, never to a calendar month", () => { + // A calendar month varies between 28 and 31 days; the on-chain duration + // must stay deterministic regardless of the month the stream starts in. + expect(toDurationSeconds("1", "months")).toBe(BigInt(30 * 86_400)); + expect(toDurationSeconds("12", "months")).toBe(BigInt(360 * 86_400)); + }); +}); + +describe("ScheduleStep — timezone and daylight-saving boundaries (issue #1797)", () => { + // 2026-03-08 is the US DST switch (local clocks jump from 02:00 to 03:00), + // 2026-11-01 is the US DST end (local clocks fall back one hour). + const DST_START_UTC = Date.UTC(2026, 2, 8, 6, 30, 0); + const DST_END_UTC = Date.UTC(2026, 10, 1, 5, 30, 0); + + it("keeps the elapsed duration invariant across a spring-forward transition", () => { + const oneDayMs = 86_400_000; + const end = DST_START_UTC + oneDayMs; + + // The stream runs for exactly 24 hours of wall-clock time... + expect(end - DST_START_UTC).toBe(oneDayMs); + // ...even though the local calendar day is only 23 hours long. + const localDayLength = localDayLengthMs(DST_START_UTC, "America/New_York"); + expect(localDayLength).toBe(23 * 3_600_000); + }); + + it("keeps the elapsed duration invariant across a fall-back transition", () => { + const oneDayMs = 86_400_000; + const end = DST_END_UTC + oneDayMs; + + expect(end - DST_END_UTC).toBe(oneDayMs); + // The local calendar day is 25 hours long on this date. + const localDayLength = localDayLengthMs(DST_END_UTC, "America/New_York"); + expect(localDayLength).toBe(25 * 3_600_000); + }); + + it("produces the same duration in seconds no matter which timezone the user is in", () => { + // The on-chain duration is derived from the unit only — never from the + // user's local calendar — so every timezone yields the same seconds. + const durations = ["UTC", "America/New_York", "Europe/Berlin", "Asia/Tokyo"].map( + () => toDurationSeconds("30", "days"), + ); + + expect(new Set(durations.map(String)).size).toBe(1); + expect(durations[0]).toBe(BigInt(30 * 86_400)); + }); +}); + +describe("ScheduleStep — duration validation (issue #1797)", () => { + it("rejects an empty duration", () => { + expect(validateDuration("")).toBe("Duration is required"); + }); + + it("rejects a zero or negative duration", () => { + expect(validateDuration("0")).toBe("Duration must be a positive number"); + expect(validateDuration("-5")).toBe("Duration must be a positive number"); + }); + + it("renders the duration error contract", () => { + renderScheduleStep({ errors: { duration: "Duration must be a positive number" } }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Duration must be a positive number", + ); + }); + + it("renders the description tag error contract", () => { + renderScheduleStep({ errors: { descriptionTag: "Description tag is too long" } }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Description tag is too long", + ); + }); + + it("reports description tag changes to the parent", () => { + const { onUpdate } = renderScheduleStep(); + + fireEvent.change(screen.getByLabelText(/description tag/i), { + target: { value: "salary" }, + }); + expect(onUpdate).toHaveBeenCalledWith({ descriptionTag: "salary" }); + }); +}); + +describe("ScheduleStep — memo byte limit (issue #1797)", () => { + it("counts UTF-8 bytes, not characters", () => { + renderScheduleStep({ formData: { duration: "30", durationUnit: "days", memo: "€" } }); + + // "€" is three bytes in UTF-8. + expect(getByteCounter()).toHaveTextContent("3 / 28 bytes"); + }); + + it("does not warn while the memo is well below the limit", () => { + renderScheduleStep({ formData: { duration: "30", durationUnit: "days", memo: "short" } }); + + expect(getByteCounter()).toHaveTextContent("5 / 28 bytes"); + expect(screen.queryByText(/memo exceeds byte limit/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/approaching byte limit/i)).not.toBeInTheDocument(); + }); + + it("warns when the memo approaches the byte limit", () => { + // 23 bytes ≥ 80% of the 28-byte limit. + renderScheduleStep({ + formData: { duration: "30", durationUnit: "days", memo: "x".repeat(23) }, + }); + + expect(screen.getByText(/approaching byte limit/i)).toBeInTheDocument(); + expect(screen.queryByText(/memo exceeds byte limit/i)).not.toBeInTheDocument(); + }); + + it("accepts a memo of exactly 28 bytes", () => { + renderScheduleStep({ + formData: { duration: "30", durationUnit: "days", memo: "x".repeat(28) }, + }); + + expect(getByteCounter()).toHaveTextContent("28 / 28 bytes"); + expect(screen.queryByText(/memo exceeds byte limit/i)).not.toBeInTheDocument(); + }); + + it("rejects a memo of 29 bytes", () => { + renderScheduleStep({ + formData: { duration: "30", durationUnit: "days", memo: "x".repeat(29) }, + }); + + expect(getByteCounter()).toHaveTextContent("29 / 28 bytes"); + expect(screen.getByText(/memo exceeds byte limit/i)).toBeInTheDocument(); + }); + + it("rejects a multi-byte memo that only looks short", () => { + // 10 × "€" = 30 bytes, well over the limit despite being 10 characters. + renderScheduleStep({ + formData: { duration: "30", durationUnit: "days", memo: "€".repeat(10) }, + }); + + expect(getByteCounter()).toHaveTextContent("30 / 28 bytes"); + expect(screen.getByText(/memo exceeds byte limit/i)).toBeInTheDocument(); + }); + + it("allows more characters than the byte limit but flags them", () => { + renderScheduleStep({ + formData: { duration: "30", durationUnit: "days", memo: "x".repeat(MAX_MEMO_BYTES + 1) }, + }); + + // The textarea accepts the input so the user can see the byte counter, + // while the over-limit warning tells them the memo will be rejected. + expect(getMemoTextarea().value).toHaveLength(MAX_MEMO_BYTES + 1); + expect(screen.getByText(/memo exceeds byte limit/i)).toBeInTheDocument(); + }); + + it("reports memo changes to the parent", () => { + const { onUpdate } = renderScheduleStep(); + + fireEvent.change(getMemoTextarea(), { target: { value: "note" } }); + expect(onUpdate).toHaveBeenCalledWith({ memo: "note" }); + }); +}); + +/** + * Length of the local calendar day (in ms) that contains `instant` in the + * given IANA timezone. Used to prove that a DST transition really does make + * a local day 23 or 25 hours long while FlowFi's duration stays fixed. + */ +function localDayLengthMs(instant: number, timeZone: string): number { + const localDate = localDateParts(instant, timeZone); + const start = localMidnightUtc(localDate, timeZone); + const end = localMidnightUtc( + { year: localDate.year, month: localDate.month, day: localDate.day + 1 }, + timeZone, + ); + + return end - start; +} + +interface LocalDateParts { + year: number; + month: number; + day: number; +} + +function localDateParts(instant: number, timeZone: string): LocalDateParts { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(instant)); + const get = (type: string) => + Number(parts.find((p) => p.type === type)?.value ?? "0"); + + return { year: get("year"), month: get("month"), day: get("day") }; +} + +/** Offset (in ms) of `timeZone` from UTC at the given instant. */ +function timeZoneOffsetMs(instant: number, timeZone: string): number { + return localWallClockMs(instant, timeZone) - instant; +} + +/** The UTC instant at which the given local calendar day starts. */ +function localMidnightUtc(date: LocalDateParts, timeZone: string): number { + const guess = Date.UTC(date.year, date.month - 1, date.day); + const offset = timeZoneOffsetMs(guess, timeZone); + const candidate = guess - offset; + // The offset may have changed between the guess and the candidate + // (exactly around a DST switch) — re-check once. + const correctedOffset = timeZoneOffsetMs(candidate, timeZone); + return correctedOffset === offset ? candidate : guess - correctedOffset; +} + +/** + * The local wall-clock reading of `instant` in `timeZone`, expressed as a UTC + * timestamp. + */ +function localWallClockMs(instant: number, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(new Date(instant)); + const get = (type: string) => + Number(parts.find((p) => p.type === type)?.value ?? "0"); + + // Some ICU versions render midnight as hour "24" in hour12:false mode. + const hour = get("hour") === 24 ? 0 : get("hour"); + + return Date.UTC( + get("year"), + get("month") - 1, + get("day"), + hour, + get("minute"), + get("second"), + ); +} diff --git a/frontend/src/components/stream-creation/__tests__/TokenStep.test.tsx b/frontend/src/components/stream-creation/__tests__/TokenStep.test.tsx new file mode 100644 index 00000000..b98db875 --- /dev/null +++ b/frontend/src/components/stream-creation/__tests__/TokenStep.test.tsx @@ -0,0 +1,298 @@ +/** + * TokenStep regression tests (issue #1798). + * + * The token step renders the supported tokens and their avatars. Its + * dependencies are the token metadata lookups: + * + * - the token address registry (`TOKEN_ADDRESSES`) — a missing entry must + * degrade to the deterministic avatar fallback instead of crashing, + * - the token decimals lookup (`fetchTokenDecimals`) — a missing, malformed + * or unsupported decimals value must fall back to Stellar's 7 decimals, + * - the address resolver (`getTokenAddress`) — an unknown token must fail + * loudly with a `SorobanCallError`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +const { simulateTransaction, getAccount, scValToNative } = vi.hoisted(() => ({ + simulateTransaction: vi.fn(), + getAccount: vi.fn(), + scValToNative: vi.fn((value: unknown) => (value as { value?: unknown })?.value), +})); + +vi.mock("@stellar/stellar-sdk", () => { + class Address { + constructor(private value: string) {} + toScVal() { + return this.value; + } + } + class Contract { + constructor(private address: string) {} + call(method: string) { + return { method, address: this.address }; + } + } + class TransactionBuilder { + addOperation() { + return this; + } + setTimeout() { + return this; + } + build() { + return { toXDR: () => "xdr" }; + } + } + return { + Address, + Contract, + TransactionBuilder, + BASE_FEE: "100", + Keypair: { random: () => ({ publicKey: () => "GKEYPAIR" }) }, + scValToNative, + rpc: { + Server: vi.fn(() => ({ getAccount, simulateTransaction })), + Api: { + isSimulationError: (result: { error?: unknown }) => Boolean(result?.error), + }, + }, + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// The token address registry is overridable per test so the "missing +// metadata" path can be exercised deterministically. +const tokenAddresses = vi.hoisted(() => ({ + current: {} as Record, +})); + +vi.mock("@/lib/soroban", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get TOKEN_ADDRESSES() { + return tokenAddresses.current; + }, + }; +}); + +import { TokenStep } from "../TokenStep"; +import { getTokenAddress, SorobanCallError } from "@/lib/soroban"; +import { + fetchTokenDecimals, + getCachedTokenDecimals, + clearTokenDecimalsCache, + getDefaultTokenDecimals, + getTokenDecimalsSync, +} from "@/utils/amount"; + +const FULL_REGISTRY = { + USDC: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + XLM: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN", + EURC: "CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY", +} as const; + +describe("TokenStep — rendering and selection (issue #1798)", () => { + beforeEach(() => { + tokenAddresses.current = { ...FULL_REGISTRY }; + }); + + it("renders one radio per supported token inside a radiogroup", () => { + render(); + + const group = screen.getByRole("radiogroup"); + expect(group).toHaveAttribute("aria-labelledby", "token-group-label"); + expect(screen.getByText("Select Token")).toBeInTheDocument(); + + const radios = screen.getAllByRole("radio"); + expect(radios).toHaveLength(3); + expect(radios.map((r) => r.textContent)).toEqual([ + expect.stringContaining("USDC"), + expect.stringContaining("XLM"), + expect.stringContaining("EURC"), + ]); + }); + + it("marks only the selected token as checked", () => { + render(); + + const [usdc, xlm, eurtc] = screen.getAllByRole("radio"); + expect(usdc).toHaveAttribute("aria-checked", "false"); + expect(xlm).toHaveAttribute("aria-checked", "true"); + expect(eurtc).toHaveAttribute("aria-checked", "false"); + }); + + it("reports the selected symbol to the parent", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getAllByRole("radio")[2] as HTMLElement); + expect(onChange).toHaveBeenCalledWith("EURC"); + }); + + it("renders the error contract and wires it to the radiogroup", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("Please select a token"); + expect(screen.getByRole("radiogroup")).toHaveAttribute( + "aria-describedby", + "token-error", + ); + }); + + it("does not reference an error id when there is no error", () => { + render(); + + expect(screen.getByRole("radiogroup")).not.toHaveAttribute("aria-describedby"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); + +describe("TokenStep — missing token metadata (issue #1798)", () => { + it("falls back to the avatar initials when no logo is available", () => { + tokenAddresses.current = { ...FULL_REGISTRY }; + render(); + + // No is rendered (no logo source), so the deterministic fallback + // with the token initials is used instead. + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("US")).toBeInTheDocument(); + expect(screen.getByText("XL")).toBeInTheDocument(); + expect(screen.getByText("EU")).toBeInTheDocument(); + }); + + it("still renders the token when its contract address is missing", () => { + // A registry without XLM: the address is unknown, but the step must + // still render the option (the avatar falls back to the symbol seed). + tokenAddresses.current = { USDC: FULL_REGISTRY.USDC, EURC: FULL_REGISTRY.EURC }; + render(); + + expect(screen.getAllByRole("radio")[1] as HTMLElement).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByText("Stellar Lumens")).toBeInTheDocument(); + expect(screen.getByText("XL")).toBeInTheDocument(); + }); + + it("throws a SorobanCallError for an unsupported token symbol", () => { + tokenAddresses.current = { ...FULL_REGISTRY }; + + expect(() => getTokenAddress("DOGE")).toThrow(SorobanCallError); + try { + getTokenAddress("DOGE"); + throw new Error("expected getTokenAddress to throw"); + } catch (error) { + expect(error).toBeInstanceOf(SorobanCallError); + expect((error as SorobanCallError).message).toBe("Unsupported token: DOGE"); + expect((error as SorobanCallError).code).toBe("Unknown"); + } + }); + + it("resolves the address of a supported token case-insensitively", () => { + tokenAddresses.current = { ...FULL_REGISTRY }; + + const upper = getTokenAddress("USDC"); + expect(typeof upper).toBe("string"); + expect(upper.length).toBeGreaterThan(0); + expect(getTokenAddress("usdc")).toBe(upper); + }); +}); + +describe("TokenStep — token decimals metadata (issue #1798)", () => { + beforeEach(() => { + clearTokenDecimalsCache(); + simulateTransaction.mockReset(); + getAccount.mockReset(); + getAccount.mockResolvedValue({}); + scValToNative.mockReset(); + scValToNative.mockImplementation((value: unknown) => (value as { value?: unknown })?.value); + }); + + it("returns the decimals reported by the token contract", async () => { + simulateTransaction.mockResolvedValue({ result: { retval: { value: 6 } } }); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(6); + expect(getCachedTokenDecimals(FULL_REGISTRY.USDC)).toBe(6); + }); + + it("caches the decimals so the contract is queried once", async () => { + simulateTransaction.mockResolvedValue({ result: { retval: { value: 7 } } }); + + await fetchTokenDecimals(FULL_REGISTRY.XLM); + await fetchTokenDecimals(FULL_REGISTRY.XLM); + + expect(simulateTransaction).toHaveBeenCalledTimes(1); + }); + + it("falls back to 7 decimals when the metadata lookup fails", async () => { + simulateTransaction.mockResolvedValue({ + error: "HostError: Error(Storage, MissingValue): missing decimals", + }); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(7); + expect(getCachedTokenDecimals(FULL_REGISTRY.USDC)).toBe(7); + }); + + it("falls back to 7 decimals when the contract returns no value", async () => { + simulateTransaction.mockResolvedValue({ result: {} }); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(7); + }); + + it("falls back to 7 decimals when the decimals value is unsupported", async () => { + // A decimals value that is neither a number, bigint nor numeric string. + simulateTransaction.mockResolvedValue({ result: { retval: { value: 6 } } }); + scValToNative.mockReturnValueOnce({ unexpected: true }); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(7); + expect(getCachedTokenDecimals(FULL_REGISTRY.USDC)).toBe(7); + }); + + it("falls back to 7 decimals when the token lookup throws", async () => { + getAccount.mockRejectedValue(new Error("network unreachable")); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(7); + expect(getCachedTokenDecimals(FULL_REGISTRY.USDC)).toBe(7); + }); + + it("parses a numeric string decimals value", async () => { + simulateTransaction.mockResolvedValue({ result: { retval: { value: "6" } } }); + scValToNative.mockReturnValueOnce("6"); + + await expect(fetchTokenDecimals(FULL_REGISTRY.USDC)).resolves.toBe(6); + }); + + it("exposes the default decimals for known and unknown symbols", () => { + expect(getDefaultTokenDecimals("XLM")).toBe(7); + expect(getDefaultTokenDecimals("usdc")).toBe(7); + expect(getDefaultTokenDecimals("UNKNOWN")).toBe(7); + }); + + it("returns the default decimals synchronously and reports the fetched value", async () => { + simulateTransaction.mockResolvedValue({ result: { retval: { value: 6 } } }); + const callback = vi.fn(); + + // Synchronously the default is returned while the lookup is in flight. + expect(getTokenDecimalsSync(FULL_REGISTRY.USDC, callback)).toBe(7); + + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledWith(6); + }); + }); + + it("returns the cached decimals synchronously once known", async () => { + simulateTransaction.mockResolvedValue({ result: { retval: { value: 6 } } }); + await fetchTokenDecimals(FULL_REGISTRY.USDC); + simulateTransaction.mockClear(); + + expect(getTokenDecimalsSync(FULL_REGISTRY.USDC)).toBe(6); + expect(simulateTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useStreamForm.ts b/frontend/src/hooks/useStreamForm.ts index 834f2bf6..0ed337f5 100644 --- a/frontend/src/hooks/useStreamForm.ts +++ b/frontend/src/hooks/useStreamForm.ts @@ -48,8 +48,16 @@ export interface UseStreamFormReturn { resetForm: () => void; /** Validate a single step (wizard-mode); returns true if valid */ validateStep: (step: number) => boolean; - /** Validate the entire form at once; returns true if valid */ - validateAll: () => boolean; + /** + * Validate the entire form at once. + * + * Returns the freshly computed field errors (an empty object means the + * form is valid) and stores them in state so the inline error messages are + * rendered. Callers must read the *returned* errors rather than the state: + * the state update is asynchronous, so reading it in the same tick would + * always yield the previous (usually empty) errors. + */ + validateAll: () => StreamFormErrors; /** Current wallet balance for the selected token (display string) */ walletBalance: string | null; /** Whether the balance is currently loading */ @@ -431,12 +439,12 @@ export function useStreamForm( [formData, walletBalance], ); - const validateAll = useCallback((): boolean => { + const validateAll = useCallback((): StreamFormErrors => { const newErrors = validateStreamForm(formData, { walletBalance, }); setErrors(newErrors); - return Object.keys(newErrors).length === 0; + return newErrors; }, [formData, walletBalance]); const setMaxAmount = useCallback(() => { diff --git a/frontend/src/lib/soroban.polling.test.ts b/frontend/src/lib/soroban.polling.test.ts new file mode 100644 index 00000000..50c890f4 --- /dev/null +++ b/frontend/src/lib/soroban.polling.test.ts @@ -0,0 +1,238 @@ +/** + * Soroban transaction confirmation polling (issue #1794). + * + * After the wallet signs a transaction, the client polls the RPC until the + * transaction reaches a terminal state: + * + * - success → the transaction is confirmed and the tx hash is returned, + * - failure → the transaction failed on-chain and the error is surfaced, + * - timeout → the transaction never reaches a terminal state and the + * confirmation times out, + * - cancellation → the user rejected the transaction in the wallet. + * + * These are the states the create-stream UI reacts to, so they are pinned + * down here with a deterministic fake RPC. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { + getAccount, + simulateTransaction, + sendTransaction, + getTransaction, + signTransaction, + assessMultisigRouting, +} = vi.hoisted(() => ({ + getAccount: vi.fn(), + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + signTransaction: vi.fn(), + assessMultisigRouting: vi.fn(), +})); + +vi.mock("@stellar/stellar-sdk", () => { + class Address { + constructor(private value: string) {} + toScVal() { + return this.value; + } + } + class Contract { + constructor(private address: string) {} + call(method: string, ...args: unknown[]) { + return { method, args, address: this.address }; + } + } + class TransactionBuilder { + constructor() {} + addOperation() { + return this; + } + setTimeout() { + return this; + } + build() { + return { toXDR: () => "unsigned-xdr" }; + } + static fromXDR() { + return { signed: true }; + } + } + return { + Address, + Contract, + TransactionBuilder, + BASE_FEE: "100", + nativeToScVal: (value: unknown) => ({ value: String(value) }), + rpc: { + Server: vi.fn(() => ({ + getAccount, + simulateTransaction, + sendTransaction, + getTransaction, + })), + Api: { + isSimulationError: (result: { error?: unknown }) => Boolean(result?.error), + GetTransactionStatus: { SUCCESS: "SUCCESS", FAILED: "FAILED" }, + }, + assembleTransaction: () => ({ + build: () => ({ toXDR: () => "assembled-xdr" }), + }), + }, + }; +}); + +vi.mock("@stellar/freighter-api", () => ({ + signTransaction, +})); + +vi.mock("@/lib/stellar-multisig", () => ({ + assessMultisigRouting, +})); + +import { createStream, SorobanCallError } from "./soroban"; +import type { WalletSession } from "./wallet"; + +const PUBLIC_KEY = "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7"; +const RECIPIENT = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const TOKEN = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN"; + +const session: WalletSession = { + walletId: "freighter", + walletName: "Freighter", + publicKey: PUBLIC_KEY, + connectedAt: "2026-01-01T00:00:00.000Z", + network: "testnet", + mocked: false, +}; + +const params = { + recipient: RECIPIENT, + tokenAddress: TOKEN, + amount: 1_000_000_000n, + durationSeconds: 2_592_000n, +}; + +describe("createStream transaction confirmation polling (issue #1794)", () => { + beforeEach(() => { + vi.clearAllMocks(); + getAccount.mockResolvedValue({}); + simulateTransaction.mockResolvedValue({ result: { retval: {} } }); + sendTransaction.mockResolvedValue({ status: "PENDING", hash: "tx_hash_123" }); + signTransaction.mockResolvedValue({ signedTxXdr: "signed-xdr", error: null }); + assessMultisigRouting.mockResolvedValue({ needsProposal: false }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the tx hash once the transaction is confirmed", async () => { + vi.useFakeTimers(); + getTransaction + .mockResolvedValueOnce({ status: "NOT_FOUND" }) + .mockResolvedValueOnce({ status: "NOT_FOUND" }) + .mockResolvedValueOnce({ status: "SUCCESS" }); + + const pending = createStream(session, params); + // Each poll happens one second after the previous one. + await vi.advanceTimersByTimeAsync(3_000); + + await expect(pending).resolves.toEqual({ + success: true, + txHash: "tx_hash_123", + }); + + // The client polls until the transaction reaches a terminal state. + expect(getTransaction).toHaveBeenCalledTimes(3); + expect(getTransaction).toHaveBeenCalledWith("tx_hash_123"); + }); + + it("fails when the transaction failed on-chain", async () => { + vi.useFakeTimers(); + getTransaction.mockResolvedValue({ status: "FAILED" }); + + const pending = createStream(session, params); + // Attach the rejection handler before the poll rejects the promise. + const assertion = expect(pending).rejects.toMatchObject({ + name: "SorobanCallError", + message: "Transaction failed on-chain.", + code: "NetworkError", + }); + + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + }); + + it("times out when the transaction is never confirmed", async () => { + vi.useFakeTimers(); + getTransaction.mockResolvedValue({ status: "NOT_FOUND" }); + + const pending = createStream(session, params); + const assertion = expect(pending).rejects.toMatchObject({ + name: "SorobanCallError", + message: "Transaction confirmation timed out.", + code: "NetworkError", + }); + + // The confirmation loop polls 20 times, one second apart. + await vi.advanceTimersByTimeAsync(20_000); + + await assertion; + expect(getTransaction).toHaveBeenCalledTimes(20); + }); + + it("reports a wallet cancellation without polling", async () => { + signTransaction.mockResolvedValue({ + signedTxXdr: "", + error: "User rejected the request", + }); + + await expect( + createStream(session, params), + ).rejects.toMatchObject({ + name: "SorobanCallError", + message: "Transaction was rejected in wallet.", + code: "WalletRejected", + }); + + expect(getTransaction).not.toHaveBeenCalled(); + }); + + it("fails when the transaction could not be broadcast", async () => { + sendTransaction.mockResolvedValue({ + status: "ERROR", + errorResult: { toXDR: () => "base64-error" }, + }); + + await expect( + createStream(session, params), + ).rejects.toMatchObject({ + name: "SorobanCallError", + message: "Transaction failed: base64-error", + code: "NetworkError", + }); + }); + + it("fails when the simulation reports an error", async () => { + simulateTransaction.mockResolvedValue({ error: "simulation failed" }); + + await expect( + createStream(session, params), + ).rejects.toBeInstanceOf(SorobanCallError); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("routes a partial multisig signature to the co-signing flow", async () => { + assessMultisigRouting.mockResolvedValue({ needsProposal: true }); + + await expect( + createStream(session, params), + ).rejects.toMatchObject({ + name: "MultisigRequiredError", + signedXdr: "signed-xdr", + }); + expect(sendTransaction).not.toHaveBeenCalled(); + }); +});