Skip to content
Merged
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
141 changes: 141 additions & 0 deletions frontend/src/lib/freighter.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
153 changes: 126 additions & 27 deletions frontend/src/lib/freighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,7 +74,7 @@ export async function isFreighterInstalled(): Promise<boolean> {
}

/**
* Check if Freighter wallet is available and allowed
* Check if Freighter wallet is available and allowed.
*/
export async function isFreighterAvailable(): Promise<boolean> {
return isFreighterInstalled();
Expand All @@ -32,13 +83,25 @@ export async function isFreighterAvailable(): Promise<boolean> {
/**
* 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<string> {
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();
Expand All @@ -52,25 +115,44 @@ export async function getFreighterPublicKey(): Promise<string> {
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<FreighterSignResponse> {
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;
Expand All @@ -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,
);
}
}
Loading