Skip to content

Commit 9be3a2d

Browse files
authored
Merge pull request #1010 from Chucks1093/feat/issue-915-916-917-918
feat(backend): enhance error recovery for Transaction Signer
2 parents 4b66058 + 79a2a65 commit 9be3a2d

2 files changed

Lines changed: 267 additions & 27 deletions

File tree

frontend/src/lib/freighter.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { TransactionSignerError } from "./freighter";
3+
4+
// Mock external modules before importing the module under test
5+
vi.mock("stellar-sdk", () => ({
6+
Horizon: {
7+
Server: vi.fn().mockImplementation(() => ({
8+
submitTransaction: vi.fn(),
9+
})),
10+
},
11+
TransactionBuilder: {
12+
fromXDR: vi.fn(),
13+
},
14+
}));
15+
16+
vi.mock("@stellar/freighter-api", () => ({
17+
isAllowed: vi.fn(),
18+
getPublicKey: vi.fn(),
19+
signTransaction: vi.fn(),
20+
}));
21+
22+
import * as freighterApi from "@stellar/freighter-api";
23+
import * as StellarSdk from "stellar-sdk";
24+
import {
25+
isFreighterAvailable,
26+
getFreighterPublicKey,
27+
signWithFreighter,
28+
submitTransaction,
29+
} from "./freighter";
30+
31+
const mockIsAllowed = vi.mocked(freighterApi.isAllowed);
32+
const mockGetPublicKey = vi.mocked(freighterApi.getPublicKey);
33+
const mockSignTransaction = vi.mocked(freighterApi.signTransaction);
34+
const mockFromXDR = vi.mocked(StellarSdk.TransactionBuilder.fromXDR);
35+
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
});
39+
40+
describe("isFreighterAvailable", () => {
41+
it("returns true when Freighter is allowed", async () => {
42+
mockIsAllowed.mockResolvedValue(true);
43+
expect(await isFreighterAvailable()).toBe(true);
44+
});
45+
46+
it("returns false when Freighter throws", async () => {
47+
mockIsAllowed.mockRejectedValue(new Error("extension error"));
48+
expect(await isFreighterAvailable()).toBe(false);
49+
});
50+
});
51+
52+
describe("getFreighterPublicKey", () => {
53+
it("throws WALLET_UNAVAILABLE when Freighter is not allowed", async () => {
54+
mockIsAllowed.mockResolvedValue(false);
55+
const err = await getFreighterPublicKey().catch((e) => e);
56+
expect(err).toBeInstanceOf(TransactionSignerError);
57+
expect(err.code).toBe("WALLET_UNAVAILABLE");
58+
});
59+
60+
it("returns the public key when available", async () => {
61+
mockIsAllowed.mockResolvedValue(true);
62+
mockGetPublicKey.mockResolvedValue("GABC123");
63+
expect(await getFreighterPublicKey()).toBe("GABC123");
64+
});
65+
66+
it("throws PUBLIC_KEY_FETCH_FAILED on getPublicKey error", async () => {
67+
mockIsAllowed.mockResolvedValue(true);
68+
mockGetPublicKey.mockRejectedValue(new Error("fetch failed"));
69+
const err = await getFreighterPublicKey().catch((e) => e);
70+
expect(err).toBeInstanceOf(TransactionSignerError);
71+
expect(err.code).toBe("PUBLIC_KEY_FETCH_FAILED");
72+
});
73+
});
74+
75+
describe("signWithFreighter", () => {
76+
const XDR = "AAAA...validXDR";
77+
const PASSPHRASE = "Test SDF Network ; September 2015";
78+
79+
it("throws INVALID_XDR when transactionXDR is empty", async () => {
80+
const err = await signWithFreighter("", PASSPHRASE).catch((e) => e);
81+
expect(err).toBeInstanceOf(TransactionSignerError);
82+
expect(err.code).toBe("INVALID_XDR");
83+
});
84+
85+
it("throws WALLET_UNAVAILABLE when Freighter is not allowed", async () => {
86+
mockIsAllowed.mockResolvedValue(false);
87+
const err = await signWithFreighter(XDR, PASSPHRASE).catch((e) => e);
88+
expect(err).toBeInstanceOf(TransactionSignerError);
89+
expect(err.code).toBe("WALLET_UNAVAILABLE");
90+
});
91+
92+
it("throws USER_REJECTED when user declines in Freighter", async () => {
93+
mockIsAllowed.mockResolvedValue(true);
94+
mockSignTransaction.mockRejectedValue(new Error("User declined signing"));
95+
const err = await signWithFreighter(XDR, PASSPHRASE).catch((e) => e);
96+
expect(err).toBeInstanceOf(TransactionSignerError);
97+
expect(err.code).toBe("USER_REJECTED");
98+
});
99+
100+
it("returns signedXDR and publicKey on success", async () => {
101+
mockIsAllowed.mockResolvedValue(true);
102+
mockSignTransaction.mockResolvedValue("SIGNED_XDR");
103+
mockGetPublicKey.mockResolvedValue("GABC123");
104+
const result = await signWithFreighter(XDR, PASSPHRASE);
105+
expect(result).toEqual({ signedXDR: "SIGNED_XDR", publicKey: "GABC123" });
106+
});
107+
});
108+
109+
describe("submitTransaction", () => {
110+
it("throws INVALID_XDR when signedXDR is empty", async () => {
111+
const err = await submitTransaction("", "https://horizon.stellar.org", "passphrase").catch((e) => e);
112+
expect(err).toBeInstanceOf(TransactionSignerError);
113+
expect(err.code).toBe("INVALID_XDR");
114+
});
115+
116+
it("throws INVALID_XDR when XDR cannot be parsed", async () => {
117+
mockFromXDR.mockImplementation(() => { throw new Error("bad XDR"); });
118+
const err = await submitTransaction("BAD_XDR", "https://horizon.stellar.org", "passphrase").catch((e) => e);
119+
expect(err).toBeInstanceOf(TransactionSignerError);
120+
expect(err.code).toBe("INVALID_XDR");
121+
});
122+
123+
it("returns hash on successful submission", async () => {
124+
const mockSubmit = vi.fn().mockResolvedValue({ hash: "abc123" });
125+
vi.mocked(StellarSdk.Horizon.Server).mockImplementation(() => ({ submitTransaction: mockSubmit }) as never);
126+
mockFromXDR.mockReturnValue({} as never);
127+
128+
const result = await submitTransaction("VALID_XDR", "https://horizon.stellar.org", "passphrase");
129+
expect(result).toEqual({ hash: "abc123" });
130+
});
131+
132+
it("throws SUBMISSION_FAILED when Horizon returns no hash", async () => {
133+
const mockSubmit = vi.fn().mockResolvedValue({ hash: undefined });
134+
vi.mocked(StellarSdk.Horizon.Server).mockImplementation(() => ({ submitTransaction: mockSubmit }) as never);
135+
mockFromXDR.mockReturnValue({} as never);
136+
137+
const err = await submitTransaction("VALID_XDR", "https://horizon.stellar.org", "passphrase").catch((e) => e);
138+
expect(err).toBeInstanceOf(TransactionSignerError);
139+
expect(err.code).toBe("SUBMISSION_FAILED");
140+
});
141+
});

frontend/src/lib/freighter.ts

Lines changed: 126 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ export interface FreighterSignResponse {
66
publicKey: string;
77
}
88

9+
export type SignerErrorCode =
10+
| "WALLET_UNAVAILABLE"
11+
| "USER_REJECTED"
12+
| "INVALID_XDR"
13+
| "NETWORK_MISMATCH"
14+
| "SUBMISSION_FAILED"
15+
| "PUBLIC_KEY_FETCH_FAILED"
16+
| "UNKNOWN";
17+
18+
export class TransactionSignerError extends Error {
19+
constructor(
20+
message: string,
21+
public readonly code: SignerErrorCode,
22+
public readonly cause?: unknown,
23+
) {
24+
super(message);
25+
this.name = "TransactionSignerError";
26+
}
27+
}
28+
29+
function classifyFreighterError(err: unknown): TransactionSignerError {
30+
const msg = err instanceof Error ? err.message : String(err);
31+
32+
if (/user declined|user rejected|cancelled/i.test(msg)) {
33+
return new TransactionSignerError(
34+
"User rejected the signing request in Freighter.",
35+
"USER_REJECTED",
36+
err,
37+
);
38+
}
39+
if (/network|passphrase/i.test(msg)) {
40+
return new TransactionSignerError(
41+
"Network passphrase mismatch between app and Freighter wallet.",
42+
"NETWORK_MISMATCH",
43+
err,
44+
);
45+
}
46+
if (/xdr|transaction/i.test(msg)) {
47+
return new TransactionSignerError(
48+
`Invalid transaction XDR: ${msg}`,
49+
"INVALID_XDR",
50+
err,
51+
);
52+
}
53+
return new TransactionSignerError(
54+
`Freighter error: ${msg}`,
55+
"UNKNOWN",
56+
err,
57+
);
58+
}
59+
960
/**
1061
* Check if Freighter wallet is installed (not just allowed).
1162
* We check for installation separately from permission so the button
@@ -23,7 +74,7 @@ export async function isFreighterInstalled(): Promise<boolean> {
2374
}
2475

2576
/**
26-
* Check if Freighter wallet is available and allowed
77+
* Check if Freighter wallet is available and allowed.
2778
*/
2879
export async function isFreighterAvailable(): Promise<boolean> {
2980
return isFreighterInstalled();
@@ -32,13 +83,25 @@ export async function isFreighterAvailable(): Promise<boolean> {
3283
/**
3384
* Get the public key from Freighter wallet.
3485
* Calls setAllowed() first which triggers the Freighter permission popup.
86+
* Throws a typed TransactionSignerError on any failure.
3587
*/
3688
export async function getFreighterPublicKey(): Promise<string> {
89+
const available = await isFreighterAvailable();
90+
if (!available) {
91+
throw new TransactionSignerError(
92+
"Freighter wallet is not installed or has not granted access.",
93+
"WALLET_UNAVAILABLE",
94+
);
95+
}
96+
3797
try {
3898
// setAllowed() triggers the Freighter popup asking user to approve the site
3999
const allowed = await freighter.setAllowed();
40100
if (!allowed) {
41-
throw new Error("User denied Freighter access");
101+
throw new TransactionSignerError(
102+
"User denied Freighter access.",
103+
"USER_REJECTED",
104+
);
42105
}
43106

44107
const result = await freighter.getPublicKey();
@@ -52,25 +115,44 @@ export async function getFreighterPublicKey(): Promise<string> {
52115
if (!obj.publicKey) throw new Error("No public key returned from Freighter");
53116
return obj.publicKey;
54117
} catch (err) {
55-
throw new Error(
56-
`Freighter: ${err instanceof Error ? err.message : String(err)}`
118+
if (err instanceof TransactionSignerError) throw err;
119+
throw new TransactionSignerError(
120+
`Failed to retrieve public key from Freighter wallet: ${err instanceof Error ? err.message : String(err)}`,
121+
"PUBLIC_KEY_FETCH_FAILED",
122+
err,
57123
);
58124
}
59125
}
60126

61127
/**
62-
* Sign a transaction with Freighter wallet
128+
* Sign a transaction XDR with Freighter, surfacing typed errors so callers
129+
* can handle user-rejected vs network-mismatch vs unknown failures distinctly.
63130
*/
64131
export async function signWithFreighter(
65132
transactionXDR: string,
66-
networkPassphrase: string
133+
networkPassphrase: string,
67134
): Promise<FreighterSignResponse> {
135+
if (!transactionXDR) {
136+
throw new TransactionSignerError(
137+
"transactionXDR must not be empty.",
138+
"INVALID_XDR",
139+
);
140+
}
141+
142+
const available = await isFreighterAvailable();
143+
if (!available) {
144+
throw new TransactionSignerError(
145+
"Freighter wallet is not installed or has not granted access.",
146+
"WALLET_UNAVAILABLE",
147+
);
148+
}
149+
68150
try {
69151
const result = await freighter.signTransaction(transactionXDR, {
70152
networkPassphrase,
71153
});
72154

73-
// Handle both string return (old) and object return (new)
155+
// Handle both string return (old API) and object return (new API)
74156
let signedXDR: string;
75157
if (typeof result === "string") {
76158
signedXDR = result;
@@ -82,44 +164,61 @@ export async function signWithFreighter(
82164

83165
if (!signedXDR) throw new Error("No signed XDR returned from Freighter");
84166

85-
const pkResult = await freighter.getPublicKey();
86-
const publicKey = typeof pkResult === "string" ? pkResult : (pkResult as { publicKey?: string })?.publicKey ?? "";
87-
167+
const publicKey = await getFreighterPublicKey();
88168
return { signedXDR, publicKey };
89169
} catch (err) {
90-
throw new Error(
91-
`Freighter sign failed: ${err instanceof Error ? err.message : String(err)}`
92-
);
170+
if (err instanceof TransactionSignerError) throw err;
171+
throw classifyFreighterError(err);
93172
}
94173
}
95174

96175
/**
97-
* Submit a signed transaction to Stellar network
176+
* Submit a signed transaction to the Stellar network with structured error
177+
* reporting on Horizon failures.
98178
*/
99179
export async function submitTransaction(
100180
signedXDR: string,
101181
horizonUrl: string,
102-
networkPassphrase: string
182+
networkPassphrase: string,
103183
): Promise<{ hash: string }> {
184+
if (!signedXDR) {
185+
throw new TransactionSignerError(
186+
"signedXDR must not be empty.",
187+
"INVALID_XDR",
188+
);
189+
}
190+
191+
let signedTx: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction;
104192
try {
105-
const server = new StellarSdk.Horizon.Server(horizonUrl);
106-
const signedTx = StellarSdk.TransactionBuilder.fromXDR(
107-
signedXDR,
108-
networkPassphrase
193+
signedTx = StellarSdk.TransactionBuilder.fromXDR(signedXDR, networkPassphrase);
194+
} catch (err) {
195+
throw new TransactionSignerError(
196+
`Cannot parse signed XDR: ${err instanceof Error ? err.message : String(err)}`,
197+
"INVALID_XDR",
198+
err,
109199
);
200+
}
110201

202+
try {
203+
const server = new StellarSdk.Horizon.Server(horizonUrl);
111204
const result = await server.submitTransaction(signedTx);
112-
205+
113206
if (!result.hash) {
114-
throw new Error("No transaction hash returned");
207+
throw new TransactionSignerError(
208+
"Horizon returned a response without a transaction hash.",
209+
"SUBMISSION_FAILED",
210+
);
115211
}
116212

117-
return {
118-
hash: result.hash,
119-
};
120-
} catch (error) {
121-
throw new Error(
122-
`Failed to submit transaction: ${error instanceof Error ? error.message : "Unknown error"}`
213+
return { hash: result.hash };
214+
} catch (err) {
215+
if (err instanceof TransactionSignerError) throw err;
216+
217+
const msg = err instanceof Error ? err.message : String(err);
218+
throw new TransactionSignerError(
219+
`Transaction submission failed: ${msg}`,
220+
"SUBMISSION_FAILED",
221+
err,
123222
);
124223
}
125224
}

0 commit comments

Comments
 (0)