Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/__tests__/utils/freighterErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
83 changes: 73 additions & 10 deletions src/utils/freighterErrors.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
}