diff --git a/src/__tests__/utils/freighterErrors.test.ts b/src/__tests__/utils/freighterErrors.test.ts new file mode 100644 index 00000000..2f06590c --- /dev/null +++ b/src/__tests__/utils/freighterErrors.test.ts @@ -0,0 +1,92 @@ +import { + isUserRejection, + isExtensionNotInstalled, + parseFreighterError, + wrapFreighterError, + UserCancelledError, + FreighterExtensionNotInstalledError, + USER_CANCELLED_MESSAGE, + FREIGHTER_NOT_INSTALLED_MESSAGE, + GENERIC_FREIGHTER_ERROR_MESSAGE, +} from "@/utils/freighterErrors"; + +describe("freighterErrors", () => { + describe("isUserRejection", () => { + it("detects user-rejected signature errors across common formats", () => { + expect(isUserRejection(new Error("User declined the request"))).toBe(true); + expect(isUserRejection("User rejected the transaction")).toBe(true); + expect(isUserRejection({ message: "Request cancelled by user" })).toBe(true); + expect(isUserRejection({ error: { message: "denied by user" } })).toBe(true); + expect(isUserRejection("User canceled")).toBe(true); + }); + + it("returns false for unrelated errors and non-errors", () => { + expect(isUserRejection(new Error("Network error"))).toBe(false); + expect(isUserRejection("Freighter is not installed")).toBe(false); + expect(isUserRejection(null)).toBe(false); + expect(isUserRejection(undefined)).toBe(false); + expect(isUserRejection({})).toBe(false); + }); + }); + + describe("isExtensionNotInstalled", () => { + it("detects extension-not-installed errors across common formats", () => { + expect(isExtensionNotInstalled(new Error("Freighter is not installed"))).toBe(true); + expect(isExtensionNotInstalled("window.freighter is not defined")).toBe(true); + expect(isExtensionNotInstalled({ message: "No Freighter extension detected" })).toBe(true); + }); + + it("does not misclassify unrelated errors", () => { + expect(isExtensionNotInstalled(new Error("User rejected the request"))).toBe(false); + expect(isExtensionNotInstalled("Request timed out")).toBe(false); + }); + }); + + describe("parseFreighterError", () => { + it("maps a user rejection to the cancellation message", () => { + expect(parseFreighterError(new Error("User declined the request"))).toBe( + USER_CANCELLED_MESSAGE, + ); + }); + + it("maps a missing extension to the install message", () => { + expect(parseFreighterError("Freighter is not installed")).toBe( + FREIGHTER_NOT_INSTALLED_MESSAGE, + ); + }); + + it("returns the raw message for readable unrecognized errors", () => { + expect(parseFreighterError(new Error("RPC unavailable"))).toBe("RPC unavailable"); + expect(parseFreighterError("Something went wrong")).toBe("Something went wrong"); + }); + + it("falls back to a generic message for unrecognized error objects instead of crashing", () => { + expect(parseFreighterError({})).toBe(GENERIC_FREIGHTER_ERROR_MESSAGE); + expect(parseFreighterError({ code: 4001 })).toBe(GENERIC_FREIGHTER_ERROR_MESSAGE); + expect(parseFreighterError({ message: 42 })).toBe(GENERIC_FREIGHTER_ERROR_MESSAGE); + expect(parseFreighterError(null)).toBe(GENERIC_FREIGHTER_ERROR_MESSAGE); + expect(parseFreighterError(undefined)).toBe(GENERIC_FREIGHTER_ERROR_MESSAGE); + }); + }); + + describe("wrapFreighterError", () => { + it("throws UserCancelledError for a user rejection", () => { + expect(() => wrapFreighterError(new Error("User rejected the request"))).toThrow( + UserCancelledError, + ); + expect(() => wrapFreighterError("Request cancelled")).toThrow(UserCancelledError); + }); + + it("throws FreighterExtensionNotInstalledError when the extension is missing", () => { + expect(() => wrapFreighterError("Freighter is not installed")).toThrow( + FreighterExtensionNotInstalledError, + ); + }); + + it("re-throws the original unrecognized error unchanged", () => { + const original = new Error("RPC unavailable"); + expect(() => wrapFreighterError(original)).toThrow(original); + expect(() => wrapFreighterError(original)).toThrow("RPC unavailable"); + }); + }); +}); diff --git a/src/utils/freighterErrors.ts b/src/utils/freighterErrors.ts index 139be811..99d21b45 100644 --- a/src/utils/freighterErrors.ts +++ b/src/utils/freighterErrors.ts @@ -1,16 +1,32 @@ /** - * Detects whether an error from Freighter's signTransaction represents - * the user rejecting/cancelling the signature request, rather than a - * genuine technical failure. + * Detects and maps raw Freighter wallet errors into user-facing messages, + * similar in spirit to contractErrors.ts. + * + * Use parseFreighterError() to convert any thrown value from a Freighter + * interaction into a human-readable message. Callers that need to act on the + * error type can use wrapFreighterError(), which re-throws typed errors + * (UserCancelledError / FreighterExtensionNotInstalledError). */ +export const USER_CANCELLED_MESSAGE = "Transaction cancelled"; +export const FREIGHTER_NOT_INSTALLED_MESSAGE = + "Freighter extension is not installed. Please install it and try again."; +export const GENERIC_FREIGHTER_ERROR_MESSAGE = "Wallet request failed. Please try again."; + export class UserCancelledError extends Error { constructor() { - super("Transaction cancelled"); + super(USER_CANCELLED_MESSAGE); this.name = "UserCancelledError"; } } +export class FreighterExtensionNotInstalledError extends Error { + constructor() { + super(FREIGHTER_NOT_INSTALLED_MESSAGE); + this.name = "FreighterExtensionNotInstalledError"; + } +} + const CANCEL_PATTERNS = [ "user declined", "user rejected", @@ -23,16 +39,63 @@ const CANCEL_PATTERNS = [ "User Rejected", ]; +const NOT_INSTALLED_PATTERNS = [ + "not installed", + "is not defined", + "window.freighter", + "no freighter", + "freighter is not available", + "freighter is required", + "freighter not detected", +]; + +/** Extracts a readable message from any thrown value, or "" when there is none. */ +function errorMessage(error: unknown): string { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message ?? ""; + if (error && typeof error === "object") { + const candidate = error as { message?: unknown; error?: { message?: unknown } }; + if (typeof candidate.message === "string") return candidate.message; + if (candidate.error && typeof candidate.error.message === "string") { + return candidate.error.message; + } + return ""; + } + return ""; +} + +/** True when the user rejected/cancelled the signature request. */ export function isUserRejection(error: unknown): boolean { - if (!error) return false; - const msg = typeof error === "string" ? error : ((error as Error).message ?? ""); - const lower = msg.toLowerCase(); + const lower = errorMessage(error).toLowerCase(); return CANCEL_PATTERNS.some((p) => lower.includes(p.toLowerCase())); } +/** True when Freighter reports the extension is missing/unavailable. */ +export function isExtensionNotInstalled(error: unknown): boolean { + const lower = errorMessage(error).toLowerCase(); + return NOT_INSTALLED_PATTERNS.some((p) => lower.includes(p.toLowerCase())); +} + +/** + * Maps any thrown value from a Freighter interaction to a user-facing message. + * Recognized cases (user rejection / missing extension) map to specific + * messages; anything unrecognized falls back to a generic message rather than + * crashing on unexpected shapes. + */ +export function parseFreighterError(error: unknown): string { + if (isUserRejection(error)) return USER_CANCELLED_MESSAGE; + if (isExtensionNotInstalled(error)) return FREIGHTER_NOT_INSTALLED_MESSAGE; + return errorMessage(error) || GENERIC_FREIGHTER_ERROR_MESSAGE; +} + +/** + * Re-throws a Freighter error as a typed error when it represents a known + * case (user rejection / extension not installed), otherwise re-throws the + * original value unchanged. Always throws, so it is safe to call as the last + * statement of a catch block. + */ export function wrapFreighterError(error: unknown): never { - if (isUserRejection(error)) { - throw new UserCancelledError(); - } + if (isUserRejection(error)) throw new UserCancelledError(); + if (isExtensionNotInstalled(error)) throw new FreighterExtensionNotInstalledError(); throw error; }