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
97 changes: 40 additions & 57 deletions frontend/src/component/ConnectWalletModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
import { useEffect, useState } from "react";
import { X, Check, AlertCircle, ExternalLink } from "lucide-react";

type ModalStep = "idle" | "connecting" | "success" | "error";
import {
type WalletErrorInfo,
classifyWalletError,
withWalletTimeout,
} from "@/lib/wallet-errors";

type ErrorType =
| "not_installed"
| "locked"
| "user_rejected"
| "wrong_network"
| "connection_failed";
type ModalStep = "idle" | "connecting" | "success" | "error";

interface ConnectWalletModalProps {
isOpen: boolean;
Expand All @@ -33,8 +32,7 @@ export default function ConnectWalletModal({
}: ConnectWalletModalProps) {
const [step, setStep] = useState<ModalStep>("idle");
const [wallets, setWallets] = useState<WalletOption[]>([]);
const [error, setError] = useState("");
const [errorType, setErrorType] = useState<ErrorType | null>(null);
const [errorInfo, setErrorInfo] = useState<WalletErrorInfo | null>(null);
const [connectedAddress, setConnectedAddress] = useState("");
const [expandedFaq, setExpandedFaq] = useState(false);
const [selectedWalletId, setSelectedWalletId] = useState<string | null>(null);
Expand Down Expand Up @@ -88,8 +86,7 @@ export default function ConnectWalletModal({

const resetModal = () => {
setStep("idle");
setError("");
setErrorType(null);
setErrorInfo(null);
setConnectedAddress("");
setSelectedWalletId(null);
};
Expand All @@ -102,15 +99,18 @@ export default function ConnectWalletModal({
const handleWalletSelect = async (walletId: string) => {
setStep("connecting");
setSelectedWalletId(walletId);
setError("");
setErrorType(null);
setErrorInfo(null);

try {
const { StellarWalletsKit } =
await import("@creit-tech/stellar-wallets-kit/sdk");

StellarWalletsKit.setWallet(walletId);
const { address } = await StellarWalletsKit.fetchAddress();
// An extension that never answers used to leave this modal on
// "connecting" with no way out except closing it.
const { address } = await withWalletTimeout(
StellarWalletsKit.fetchAddress(),
);

setConnectedAddress(address);
setStep("success");
Expand All @@ -120,29 +120,13 @@ export default function ConnectWalletModal({
onSuccess(address, walletId);
}, 1200);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();

// Categorize errors
if (msg.includes("cancel") || msg.includes("reject") || msg.includes("user closed") || msg.includes("denied")) {
// User rejected - reset to idle to let them try again
resetModal();
return;
}

if (msg.includes("not installed") || msg.includes("not available")) {
setErrorType("not_installed");
setError("Wallet extension is not installed");
} else if (msg.includes("locked")) {
setErrorType("locked");
setError("Wallet is locked. Please unlock it and try again.");
} else if (msg.includes("network") || msg.includes("testnet") || msg.includes("public")) {
setErrorType("wrong_network");
setError("Please switch to the Stellar Public network in your wallet");
} else {
setErrorType("connection_failed");
setError(err instanceof Error ? err.message : "Connection failed. Please try again.");
}
const walletName =
wallets.find((w) => w.id === walletId)?.name ?? "Your wallet";

// A declined request used to call resetModal() and return, dropping the
// user back at the wallet list with no explanation at all. It is now a
// first-class state like any other failure.
setErrorInfo(classifyWalletError(err, walletName));
setStep("error");
}
};
Expand Down Expand Up @@ -294,39 +278,38 @@ export default function ConnectWalletModal({
)}

{/* Error */}
{step === "error" && (
<div className="space-y-6">
{step === "error" && errorInfo && (
<div className="space-y-6" role="alert" data-testid="wallet-error">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10">
<AlertCircle className="h-6 w-6 text-red-400" />
</div>
</div>
<h3 className="text-lg font-semibold text-white">
{errorType === "not_installed"
? "Wallet Not Installed"
: errorType === "locked"
? "Wallet Locked"
: errorType === "wrong_network"
? "Wrong Network"
: "Connection Failed"}
<h3
className="text-lg font-semibold text-white"
data-testid="wallet-error-title"
>
{errorInfo.title}
</h3>
<p className="mt-2 text-sm text-[#9aa4bc]">{error}</p>

{errorType === "locked" && (
<p className="mt-3 text-xs text-[#4FD1C5]">
Unlock your wallet extension and click retry
</p>
)}
<p
className="mt-2 text-sm text-[#9aa4bc]"
data-testid="wallet-error-message"
>
{errorInfo.message}
</p>

{errorType === "wrong_network" && (
<p className="mt-3 text-xs text-[#4FD1C5]">
Open your wallet extension and switch to the Stellar Public network
{errorInfo.hint && (
<p
className="mt-3 text-xs text-[#4FD1C5]"
data-testid="wallet-error-hint"
>
{errorInfo.hint}
</p>
)}
</div>
<div className="flex gap-3">
{errorType === "not_installed" ? (
{!errorInfo.canRetry ? (
<>
<button
onClick={resetModal}
Expand Down
155 changes: 155 additions & 0 deletions frontend/src/lib/wallet-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";

import {
WALLET_CONNECT_TIMEOUT_MS,
WalletTimeoutError,
classifyWalletError,
withWalletTimeout,
} from "./wallet-errors";

const classify = (message: string, wallet = "Freighter") =>
classifyWalletError(new Error(message), wallet);

describe("classifyWalletError", () => {
it("reports a declined request as a choice, not a fault", () => {
for (const message of [
"User rejected the request",
"Request denied by user",
"User cancelled",
"user closed the popup",
"Request declined",
]) {
const info = classify(message);
expect(info.type).toBe("user_rejected");
expect(info.canRetry).toBe(true);
}
});

it("names the wallet in the message so the user knows where to look", () => {
expect(classify("User rejected", "Albedo").message).toContain("Albedo");
});

it("detects a missing extension and does not offer a retry", () => {
const info = classify("Freighter is not installed");
expect(info.type).toBe("not_installed");
// Retrying cannot help until the extension exists.
expect(info.canRetry).toBe(false);
expect(info.hint).toMatch(/install/i);
});

it("detects a locked wallet", () => {
expect(classify("Wallet is locked").type).toBe("locked");
expect(classify("Please unlock your wallet").type).toBe("locked");
});

it("detects a timeout from a message", () => {
expect(classify("Request timed out").type).toBe("timeout");
expect(classify("connection timeout").type).toBe("timeout");
});

it("detects a WalletTimeoutError instance", () => {
const info = classifyWalletError(new WalletTimeoutError(30_000), "Freighter");
expect(info.type).toBe("timeout");
expect(info.canRetry).toBe(true);
});

it("distinguishes a connectivity failure from the wrong Stellar network", () => {
// "network request failed" contains the word "network" and used to be
// reported as "switch to the Stellar Public network", which is useless
// advice for someone whose connection is down.
for (const message of [
"Failed to fetch",
"Network request failed",
"NetworkError when attempting to fetch resource",
"net::ERR_INTERNET_DISCONNECTED",
"You appear to be offline",
]) {
expect(classify(message).type).toBe("network");
}
});

it("detects the wrong Stellar network from a chain-specific message", () => {
for (const message of [
"Wallet is on testnet",
"Please switch to the public network",
"Wrong network selected",
"Stellar network mismatch",
]) {
expect(classify(message).type).toBe("wrong_network");
}
});

it("prefers rejection over any other rule", () => {
// A decline is deliberate; labelling it a network fault would be wrong.
expect(classify("User rejected: network request failed").type).toBe(
"user_rejected",
);
});

it("falls back to connection_failed and keeps the original text", () => {
const info = classify("Something entirely unexpected happened");
expect(info.type).toBe("connection_failed");
// An unclassified error is exactly when the raw message is the only clue.
expect(info.message).toBe("Something entirely unexpected happened");
expect(info.canRetry).toBe(true);
});

it("survives a non-Error value", () => {
expect(classifyWalletError("plain string failure").type).toBe(
"connection_failed",
);
expect(classifyWalletError(null).type).toBe("connection_failed");
expect(classifyWalletError(undefined).type).toBe("connection_failed");
});

it("matches case-insensitively", () => {
expect(classify("WALLET IS LOCKED").type).toBe("locked");
expect(classify("User REJECTED").type).toBe("user_rejected");
});

it("gives every branch a title and a message", () => {
for (const message of [
"User rejected",
"not installed",
"locked",
"timed out",
"failed to fetch",
"testnet",
"mystery",
]) {
const info = classify(message);
expect(info.title.length).toBeGreaterThan(0);
expect(info.message.length).toBeGreaterThan(0);
}
});
});

describe("withWalletTimeout", () => {
it("passes a result through when it arrives in time", async () => {
await expect(withWalletTimeout(Promise.resolve("ok"), 1000)).resolves.toBe("ok");
});

it("passes a rejection through unchanged", async () => {
await expect(
withWalletTimeout(Promise.reject(new Error("boom")), 1000),
).rejects.toThrow("boom");
});

it("rejects with WalletTimeoutError when the wallet never answers", async () => {
vi.useFakeTimers();
try {
const pending = new Promise(() => undefined);
const raced = withWalletTimeout(pending, 30_000);
const assertion = expect(raced).rejects.toBeInstanceOf(WalletTimeoutError);

await vi.advanceTimersByTimeAsync(30_000);
await assertion;
} finally {
vi.useRealTimers();
}
});

it("has a sane default timeout", () => {
expect(WALLET_CONNECT_TIMEOUT_MS).toBeGreaterThan(0);
});
});
Loading