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(); + }); +});