diff --git a/frontend/src/lib/freighter.test.ts b/frontend/src/lib/freighter.test.ts new file mode 100644 index 0000000..3cf6685 --- /dev/null +++ b/frontend/src/lib/freighter.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TransactionSignerError } from "./freighter"; + +// Mock external modules before importing the module under test +vi.mock("stellar-sdk", () => ({ + Horizon: { + Server: vi.fn().mockImplementation(() => ({ + submitTransaction: vi.fn(), + })), + }, + TransactionBuilder: { + fromXDR: vi.fn(), + }, +})); + +vi.mock("@stellar/freighter-api", () => ({ + isAllowed: vi.fn(), + getPublicKey: vi.fn(), + signTransaction: vi.fn(), +})); + +import * as freighterApi from "@stellar/freighter-api"; +import * as StellarSdk from "stellar-sdk"; +import { + isFreighterAvailable, + getFreighterPublicKey, + signWithFreighter, + submitTransaction, +} from "./freighter"; + +const mockIsAllowed = vi.mocked(freighterApi.isAllowed); +const mockGetPublicKey = vi.mocked(freighterApi.getPublicKey); +const mockSignTransaction = vi.mocked(freighterApi.signTransaction); +const mockFromXDR = vi.mocked(StellarSdk.TransactionBuilder.fromXDR); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isFreighterAvailable", () => { + it("returns true when Freighter is allowed", async () => { + mockIsAllowed.mockResolvedValue(true); + expect(await isFreighterAvailable()).toBe(true); + }); + + it("returns false when Freighter throws", async () => { + mockIsAllowed.mockRejectedValue(new Error("extension error")); + expect(await isFreighterAvailable()).toBe(false); + }); +}); + +describe("getFreighterPublicKey", () => { + it("throws WALLET_UNAVAILABLE when Freighter is not allowed", async () => { + mockIsAllowed.mockResolvedValue(false); + const err = await getFreighterPublicKey().catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("WALLET_UNAVAILABLE"); + }); + + it("returns the public key when available", async () => { + mockIsAllowed.mockResolvedValue(true); + mockGetPublicKey.mockResolvedValue("GABC123"); + expect(await getFreighterPublicKey()).toBe("GABC123"); + }); + + it("throws PUBLIC_KEY_FETCH_FAILED on getPublicKey error", async () => { + mockIsAllowed.mockResolvedValue(true); + mockGetPublicKey.mockRejectedValue(new Error("fetch failed")); + const err = await getFreighterPublicKey().catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("PUBLIC_KEY_FETCH_FAILED"); + }); +}); + +describe("signWithFreighter", () => { + const XDR = "AAAA...validXDR"; + const PASSPHRASE = "Test SDF Network ; September 2015"; + + it("throws INVALID_XDR when transactionXDR is empty", async () => { + const err = await signWithFreighter("", PASSPHRASE).catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("INVALID_XDR"); + }); + + it("throws WALLET_UNAVAILABLE when Freighter is not allowed", async () => { + mockIsAllowed.mockResolvedValue(false); + const err = await signWithFreighter(XDR, PASSPHRASE).catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("WALLET_UNAVAILABLE"); + }); + + it("throws USER_REJECTED when user declines in Freighter", async () => { + mockIsAllowed.mockResolvedValue(true); + mockSignTransaction.mockRejectedValue(new Error("User declined signing")); + const err = await signWithFreighter(XDR, PASSPHRASE).catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("USER_REJECTED"); + }); + + it("returns signedXDR and publicKey on success", async () => { + mockIsAllowed.mockResolvedValue(true); + mockSignTransaction.mockResolvedValue("SIGNED_XDR"); + mockGetPublicKey.mockResolvedValue("GABC123"); + const result = await signWithFreighter(XDR, PASSPHRASE); + expect(result).toEqual({ signedXDR: "SIGNED_XDR", publicKey: "GABC123" }); + }); +}); + +describe("submitTransaction", () => { + it("throws INVALID_XDR when signedXDR is empty", async () => { + const err = await submitTransaction("", "https://horizon.stellar.org", "passphrase").catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("INVALID_XDR"); + }); + + it("throws INVALID_XDR when XDR cannot be parsed", async () => { + mockFromXDR.mockImplementation(() => { throw new Error("bad XDR"); }); + const err = await submitTransaction("BAD_XDR", "https://horizon.stellar.org", "passphrase").catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("INVALID_XDR"); + }); + + it("returns hash on successful submission", async () => { + const mockSubmit = vi.fn().mockResolvedValue({ hash: "abc123" }); + vi.mocked(StellarSdk.Horizon.Server).mockImplementation(() => ({ submitTransaction: mockSubmit }) as never); + mockFromXDR.mockReturnValue({} as never); + + const result = await submitTransaction("VALID_XDR", "https://horizon.stellar.org", "passphrase"); + expect(result).toEqual({ hash: "abc123" }); + }); + + it("throws SUBMISSION_FAILED when Horizon returns no hash", async () => { + const mockSubmit = vi.fn().mockResolvedValue({ hash: undefined }); + vi.mocked(StellarSdk.Horizon.Server).mockImplementation(() => ({ submitTransaction: mockSubmit }) as never); + mockFromXDR.mockReturnValue({} as never); + + const err = await submitTransaction("VALID_XDR", "https://horizon.stellar.org", "passphrase").catch((e) => e); + expect(err).toBeInstanceOf(TransactionSignerError); + expect(err.code).toBe("SUBMISSION_FAILED"); + }); +}); diff --git a/frontend/src/lib/freighter.ts b/frontend/src/lib/freighter.ts index a2977ef..a76830e 100644 --- a/frontend/src/lib/freighter.ts +++ b/frontend/src/lib/freighter.ts @@ -6,6 +6,57 @@ export interface FreighterSignResponse { publicKey: string; } +export type SignerErrorCode = + | "WALLET_UNAVAILABLE" + | "USER_REJECTED" + | "INVALID_XDR" + | "NETWORK_MISMATCH" + | "SUBMISSION_FAILED" + | "PUBLIC_KEY_FETCH_FAILED" + | "UNKNOWN"; + +export class TransactionSignerError extends Error { + constructor( + message: string, + public readonly code: SignerErrorCode, + public readonly cause?: unknown, + ) { + super(message); + this.name = "TransactionSignerError"; + } +} + +function classifyFreighterError(err: unknown): TransactionSignerError { + const msg = err instanceof Error ? err.message : String(err); + + if (/user declined|user rejected|cancelled/i.test(msg)) { + return new TransactionSignerError( + "User rejected the signing request in Freighter.", + "USER_REJECTED", + err, + ); + } + if (/network|passphrase/i.test(msg)) { + return new TransactionSignerError( + "Network passphrase mismatch between app and Freighter wallet.", + "NETWORK_MISMATCH", + err, + ); + } + if (/xdr|transaction/i.test(msg)) { + return new TransactionSignerError( + `Invalid transaction XDR: ${msg}`, + "INVALID_XDR", + err, + ); + } + return new TransactionSignerError( + `Freighter error: ${msg}`, + "UNKNOWN", + err, + ); +} + /** * Check if Freighter wallet is installed (not just allowed). * We check for installation separately from permission so the button @@ -23,7 +74,7 @@ export async function isFreighterInstalled(): Promise { } /** - * Check if Freighter wallet is available and allowed + * Check if Freighter wallet is available and allowed. */ export async function isFreighterAvailable(): Promise { return isFreighterInstalled(); @@ -32,13 +83,25 @@ export async function isFreighterAvailable(): Promise { /** * Get the public key from Freighter wallet. * Calls setAllowed() first which triggers the Freighter permission popup. + * Throws a typed TransactionSignerError on any failure. */ export async function getFreighterPublicKey(): Promise { + const available = await isFreighterAvailable(); + if (!available) { + throw new TransactionSignerError( + "Freighter wallet is not installed or has not granted access.", + "WALLET_UNAVAILABLE", + ); + } + try { // setAllowed() triggers the Freighter popup asking user to approve the site const allowed = await freighter.setAllowed(); if (!allowed) { - throw new Error("User denied Freighter access"); + throw new TransactionSignerError( + "User denied Freighter access.", + "USER_REJECTED", + ); } const result = await freighter.getPublicKey(); @@ -52,25 +115,44 @@ export async function getFreighterPublicKey(): Promise { if (!obj.publicKey) throw new Error("No public key returned from Freighter"); return obj.publicKey; } catch (err) { - throw new Error( - `Freighter: ${err instanceof Error ? err.message : String(err)}` + if (err instanceof TransactionSignerError) throw err; + throw new TransactionSignerError( + `Failed to retrieve public key from Freighter wallet: ${err instanceof Error ? err.message : String(err)}`, + "PUBLIC_KEY_FETCH_FAILED", + err, ); } } /** - * Sign a transaction with Freighter wallet + * Sign a transaction XDR with Freighter, surfacing typed errors so callers + * can handle user-rejected vs network-mismatch vs unknown failures distinctly. */ export async function signWithFreighter( transactionXDR: string, - networkPassphrase: string + networkPassphrase: string, ): Promise { + if (!transactionXDR) { + throw new TransactionSignerError( + "transactionXDR must not be empty.", + "INVALID_XDR", + ); + } + + const available = await isFreighterAvailable(); + if (!available) { + throw new TransactionSignerError( + "Freighter wallet is not installed or has not granted access.", + "WALLET_UNAVAILABLE", + ); + } + try { const result = await freighter.signTransaction(transactionXDR, { networkPassphrase, }); - // Handle both string return (old) and object return (new) + // Handle both string return (old API) and object return (new API) let signedXDR: string; if (typeof result === "string") { signedXDR = result; @@ -82,44 +164,61 @@ export async function signWithFreighter( if (!signedXDR) throw new Error("No signed XDR returned from Freighter"); - const pkResult = await freighter.getPublicKey(); - const publicKey = typeof pkResult === "string" ? pkResult : (pkResult as { publicKey?: string })?.publicKey ?? ""; - + const publicKey = await getFreighterPublicKey(); return { signedXDR, publicKey }; } catch (err) { - throw new Error( - `Freighter sign failed: ${err instanceof Error ? err.message : String(err)}` - ); + if (err instanceof TransactionSignerError) throw err; + throw classifyFreighterError(err); } } /** - * Submit a signed transaction to Stellar network + * Submit a signed transaction to the Stellar network with structured error + * reporting on Horizon failures. */ export async function submitTransaction( signedXDR: string, horizonUrl: string, - networkPassphrase: string + networkPassphrase: string, ): Promise<{ hash: string }> { + if (!signedXDR) { + throw new TransactionSignerError( + "signedXDR must not be empty.", + "INVALID_XDR", + ); + } + + let signedTx: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction; try { - const server = new StellarSdk.Horizon.Server(horizonUrl); - const signedTx = StellarSdk.TransactionBuilder.fromXDR( - signedXDR, - networkPassphrase + signedTx = StellarSdk.TransactionBuilder.fromXDR(signedXDR, networkPassphrase); + } catch (err) { + throw new TransactionSignerError( + `Cannot parse signed XDR: ${err instanceof Error ? err.message : String(err)}`, + "INVALID_XDR", + err, ); + } + try { + const server = new StellarSdk.Horizon.Server(horizonUrl); const result = await server.submitTransaction(signedTx); - + if (!result.hash) { - throw new Error("No transaction hash returned"); + throw new TransactionSignerError( + "Horizon returned a response without a transaction hash.", + "SUBMISSION_FAILED", + ); } - return { - hash: result.hash, - }; - } catch (error) { - throw new Error( - `Failed to submit transaction: ${error instanceof Error ? error.message : "Unknown error"}` + return { hash: result.hash }; + } catch (err) { + if (err instanceof TransactionSignerError) throw err; + + const msg = err instanceof Error ? err.message : String(err); + throw new TransactionSignerError( + `Transaction submission failed: ${msg}`, + "SUBMISSION_FAILED", + err, ); } }