From 6dd9c1888c2b87c1c1faee2553e130755a142189 Mon Sep 17 00:00:00 2001 From: nasalehj Date: Mon, 19 Oct 2026 13:33:53 +0000 Subject: [PATCH] test(frontend): cover wallet rejection and RPC failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify Freighter rejections and Soroban RPC/network failures into user-facing messages, and stop reporting them as wrong answers: submit errors now surface through the error alert instead of an "Incorrect" feedback toast, and the submit/hint spinners always clear. Add end-to-end component tests that drive the real hook and component through rejected signatures, failed account lookups, rejected transactions, hint failures, and rejected connection prompts, asserting the error text and that the controls return to an interactive state. Closes #762 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- frontend/hooks/usePuzzleContract.js | 14 +- frontend/lib/wallet-errors.js | 64 +++++ .../tests/wallet-error-paths.e2e.test.jsx | 264 ++++++++++++++++++ 3 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 frontend/lib/wallet-errors.js create mode 100644 frontend/tests/wallet-error-paths.e2e.test.jsx diff --git a/frontend/hooks/usePuzzleContract.js b/frontend/hooks/usePuzzleContract.js index 43d532a3..f69c16e2 100644 --- a/frontend/hooks/usePuzzleContract.js +++ b/frontend/hooks/usePuzzleContract.js @@ -14,6 +14,7 @@ import { signTransaction } from "@stellar/freighter-api"; import { useWalletConnection, } from "./useWalletConnection"; +import { classifyWalletError } from "@/lib/wallet-errors"; /** * Default RPC endpoint for the Stellar Soroban testnet. @@ -293,10 +294,13 @@ export function usePuzzleContract(opts = {}) { setFeedback(fb); return { ...fb, submitted: true }; } catch (err) { - const message = err.message || "Transaction failed"; + // A rejected signature or an unreachable RPC is not a wrong answer. + // Surface it through `error` (and the alert in PuzzleComponent) so + // the UI does not render an "Incorrect" toast for it. + const message = classifyWalletError(err, "Transaction failed"); setError(message); - setFeedback({ correct: false, message }); - return { correct: false, message, submitted: false }; + setFeedback(null); + return { correct: false, message, submitted: false, error: true }; } finally { setSubmitting(false); } @@ -330,9 +334,9 @@ export function usePuzzleContract(opts = {}) { setLastHint(hint); return hint; } catch (err) { - const message = err.message || "Failed to fetch hint"; + const message = classifyWalletError(err, "Failed to fetch hint"); setError(message); - throw err; + throw new Error(message); } finally { setHintLoading(false); } diff --git a/frontend/lib/wallet-errors.js b/frontend/lib/wallet-errors.js new file mode 100644 index 00000000..fb1d9cc2 --- /dev/null +++ b/frontend/lib/wallet-errors.js @@ -0,0 +1,64 @@ +/** + * User-facing messages for wallet and RPC failures. + * + * A raw Freighter or Soroban RPC error is not suitable for the game UI: it + * often contains a stack trace, a status code, or no message at all. These + * helpers classify the common failure modes so the UI can show an accurate + * message instead of labelling a rejected signature as a wrong answer. + */ + +export const WALLET_REJECTION_MESSAGE = + "Transaction was rejected in Freighter. No changes were submitted."; + +export const NETWORK_ERROR_MESSAGE = + "Network error: could not reach the Stellar network. Check your connection and try again."; + +// Freighter reports a cancelled prompt with code 4001. Some versions only +// include a message, so both shapes are matched. +const REJECTION_PATTERN = + /\b(reject|rejected|cancel|cancelled|canceled|denied|declined)\b|4001/i; + +// Transport-level failures from fetch/axios or Soroban RPC (including a +// transaction that never reaches a terminal state). +const NETWORK_PATTERN = + /network|failed to fetch|fetch failed|econnrefused|econnreset|enotfound|etimedout|timed out|timeout|socket hang up|did not finalise in time|\brpc\b|\b50[234]\b/i; + +/** + * Returns a safe message for a wallet/RPC error. + * + * The "unsupported network" message is preserved because it tells the user + * exactly how to recover, while other network failures get a generic + * connection message. + * + * @param {unknown} error + * @param {string} fallback + * @returns {string} + */ +export function classifyWalletError(error, fallback = "Transaction failed") { + const code = error?.code ?? error?.response?.status; + const raw = + typeof error === "string" + ? error + : error?.message || error?.error?.message || ""; + + if (/unsupported network/i.test(raw)) { + return raw; + } + + if (code === 4001 || REJECTION_PATTERN.test(raw)) { + return WALLET_REJECTION_MESSAGE; + } + + if ( + NETWORK_PATTERN.test(raw) || + code === 502 || + code === 503 || + code === 504 + ) { + return NETWORK_ERROR_MESSAGE; + } + + return raw || fallback; +} + +export default classifyWalletError; diff --git a/frontend/tests/wallet-error-paths.e2e.test.jsx b/frontend/tests/wallet-error-paths.e2e.test.jsx new file mode 100644 index 00000000..80134a8d --- /dev/null +++ b/frontend/tests/wallet-error-paths.e2e.test.jsx @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +/** + * Wallet rejection and network failure paths — issue #762. + * + * These tests drive the real `usePuzzleContract` hook and `PuzzleComponent` + * with a mocked Freighter extension and a mocked Soroban RPC transport. They + * are end-to-end from the component's perspective: the user types an answer, + * submits, and the UI must show the correct error and leave the submit/hint + * controls interactive (no stuck spinner). + */ + +vi.hoisted(() => { + // Must run before the hook module is imported: `EXPECTED_NETWORK` is + // computed once from this value at module load. + process.env.NEXT_PUBLIC_STELLAR_NETWORK = "testnet"; +}); + +const STELLAR_PK = "GD6N3NIEU6GEN325OV4RL4QIFTM6WQKDC3HZGSZ7POWYNBNIAA5C6FOG"; +const CONTRACT_ID = + "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"; + +const mocks = vi.hoisted(() => ({ + isConnected: vi.fn(), + getPublicKey: vi.fn(), + requestAccess: vi.fn(), + getNetwork: vi.fn(), + signTransaction: vi.fn(), + getAccount: vi.fn(), + prepareTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), +})); + +vi.mock("@stellar/freighter-api", () => ({ + isConnected: (...args) => mocks.isConnected(...args), + getPublicKey: (...args) => mocks.getPublicKey(...args), + requestAccess: (...args) => mocks.requestAccess(...args), + getNetwork: (...args) => mocks.getNetwork(...args), + signTransaction: (...args) => mocks.signTransaction(...args), +})); + +// A self-contained test double for the parts of the SDK the hook touches. +// The real SDK is ESM and its XDR code rejects jsdom-realm typed arrays, so +// the transport is faked explicitly rather than partially patched. +vi.mock("@stellar/stellar-sdk", () => { + class FakeTransactionBuilder { + constructor(source, options) { + this.source = source; + this.options = options; + this.operations = []; + } + addOperation(operation) { + this.operations.push(operation); + return this; + } + setTimeout() { + return this; + } + build() { + return this; + } + toXDR() { + return "fake-prepared-xdr"; + } + static fromXDR(xdr) { + if (typeof xdr !== "string") { + throw new TypeError( + `fromXdr: expected a string or Uint8Array, got ${typeof xdr}`, + ); + } + return { xdr }; + } + } + + class FakeServer { + getAccount(...args) { + return mocks.getAccount(...args); + } + prepareTransaction(...args) { + return mocks.prepareTransaction(...args); + } + sendTransaction(...args) { + return mocks.sendTransaction(...args); + } + getTransaction(...args) { + return mocks.getTransaction(...args); + } + } + + class FakeContract { + constructor(contractId) { + this.contractId = contractId; + } + call(method, ...args) { + return { contractId: this.contractId, method, args }; + } + } + + return { + Networks: { + TESTNET: "Test SDF Network ; September 2015", + PUBLIC: "Public Global Stellar Network ; September 2015", + }, + TransactionBuilder: FakeTransactionBuilder, + Address: { fromString: (pk) => ({ toScVal: () => ({ address: pk }) }) }, + nativeToScVal: (value) => ({ native: value }), + scValToNative: (value) => (value && "native" in value ? value.native : true), + xdr: { ScVal: { scvBytes: (bytes) => ({ bytes }) } }, + SorobanRpc: { Server: FakeServer, Contract: FakeContract }, + }; +}); + +import PuzzleComponent from "@/components/PuzzleComponent"; + +const puzzle = { + id: "puzzle-762", + type: "text", + title: "Wallet error paths", + question: "What is the answer?", + hint: "Local fallback hint", + level: "Easy", + puzzleNumber: 1, +}; + +function renderPuzzle() { + return render( + , + ); +} + +async function submitAnswer(answer = "orbit") { + fireEvent.change(screen.getByLabelText("Your answer"), { + target: { value: answer }, + }); + fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + } + + mocks.isConnected.mockResolvedValue(false); + mocks.getPublicKey.mockResolvedValue(STELLAR_PK); + mocks.requestAccess.mockResolvedValue(STELLAR_PK); + mocks.getNetwork.mockResolvedValue("Test SDF Network ; September 2015"); + mocks.getAccount.mockResolvedValue({ accountId: STELLAR_PK }); + mocks.prepareTransaction.mockImplementation(async (tx) => tx); + mocks.signTransaction.mockImplementation(async (xdr) => xdr); + mocks.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "abc" }); + mocks.getTransaction.mockResolvedValue({ + status: "SUCCESS", + returnValue: { native: true }, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("wallet rejection and network error paths (e2e)", () => { + it("shows a rejection message instead of an incorrect-answer toast and clears the spinner", async () => { + mocks.signTransaction.mockRejectedValue( + Object.assign(new Error("User rejected the request"), { code: 4001 }), + ); + + renderPuzzle(); + await submitAnswer(); + + expect( + await screen.findByText(/Transaction was rejected in Freighter/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/^Incorrect$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Verifying on-chain/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /submit answer/i })).toBeEnabled(); + }); + + it("shows a network error and re-enables submit when the RPC account lookup fails", async () => { + mocks.getAccount.mockRejectedValue(new TypeError("Failed to fetch")); + + renderPuzzle(); + await submitAnswer(); + + expect( + await screen.findByText( + /Network error: could not reach the Stellar network/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Verifying on-chain/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /submit answer/i })).toBeEnabled(); + }); + + it("shows a network error when the RPC rejects the submitted transaction", async () => { + mocks.sendTransaction.mockResolvedValue({ + status: "ERROR", + error: { message: "RPC unavailable" }, + }); + + renderPuzzle(); + await submitAnswer(); + + expect( + await screen.findByText( + /Network error: could not reach the Stellar network/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Verifying on-chain/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /submit answer/i })).toBeEnabled(); + }); + + it("stops the hint spinner and falls back to the local hint when the RPC fails", async () => { + mocks.getAccount.mockRejectedValue(new Error("Network Error")); + + renderPuzzle(); + fireEvent.click(screen.getByRole("button", { name: /need a hint/i })); + + expect(await screen.findByText("Local fallback hint")).toBeInTheDocument(); + expect(screen.queryByText(/Loading hint/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /hide hint/i })).toBeDisabled(); + }); + + it("reports a rejected wallet connection prompt without leaving the connect CTA in a loading state", async () => { + mocks.requestAccess.mockRejectedValue( + Object.assign(new Error("User rejected the request"), { code: 4001 }), + ); + + renderPuzzle(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); + + await waitFor(() => { + expect( + screen.getByText("Wallet connection was rejected."), + ).toBeInTheDocument(); + }); + expect(screen.queryByText(/Verifying on-chain/i)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /connect wallet/i }), + ).toBeEnabled(); + }); +});