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
45 changes: 34 additions & 11 deletions src/components/InstallFreighterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ interface InstallFreighterModalProps {
* than imported so this modal stays free of wallet-provider dependencies.
*/
socialLogin?: ReactNode;
/**
* When true, the modal explains that Freighter is locked (password required)
* rather than asking the user to install it.
*/
isLocked?: boolean;
}

function detectBrowser(): { name: string; supported: boolean } {
Expand All @@ -28,6 +33,7 @@ export default function InstallFreighterModal({
onClose,
onRetry,
socialLogin,
isLocked = false,
}: InstallFreighterModalProps) {
const browser = useMemo(
() => (isOpen ? detectBrowser() : { name: "", supported: false }),
Expand All @@ -53,13 +59,19 @@ export default function InstallFreighterModal({
</div>

<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-50">
{socialLogin ? "Connect a wallet" : "Freighter Wallet Required"}
{socialLogin
? "Connect a wallet"
: isLocked
? "Freighter Wallet Locked"
: "Freighter Wallet Required"}
</h2>

<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{socialLogin
? "Install the Freighter extension, or sign in with a social account and we will create a Stellar wallet for you."
: "This app requires the Freighter browser extension to interact with the Stellar network."}
: isLocked
? "Open the Freighter extension and unlock it with your password, then try again."
: "This app requires the Freighter browser extension to interact with the Stellar network."}
</p>

{!browser.supported && browser.name && (
Expand All @@ -70,21 +82,32 @@ export default function InstallFreighterModal({
)}

<div className="mt-6 space-y-3">
<a
href="https://www.freighter.app/"
target="_blank"
rel="noopener noreferrer"
className="block w-full rounded-full bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Install Freighter
</a>
{isLocked ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
Freighter is locked. Open the extension in your browser toolbar, enter your
password, and then click the button below.
</div>
) : (
<a
href="https://www.freighter.app/"
target="_blank"
rel="noopener noreferrer"
className="block w-full rounded-full bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Install Freighter
</a>
)}

<button
onClick={handleRetry}
disabled={checking}
className="w-full rounded-full border border-zinc-300 px-4 py-3 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
{checking ? "Checking..." : "I installed it — check again"}
{checking
? "Checking..."
: isLocked
? "I unlocked it — check again"
: "I installed it — check again"}
</button>

{socialLogin}
Expand Down
31 changes: 23 additions & 8 deletions src/components/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
type SocialLoginProvider,
type SocialWalletSession,
} from "@/lib/socialWallet";
import { isFreighterLockedError } from "@/utils/freighterErrors";
import InstallFreighterModal from "./InstallFreighterModal";
import SocialLoginButtons from "./SocialLoginButtons";

Expand Down Expand Up @@ -57,7 +58,7 @@
const WalletActionsContext = createContext<WalletActions | undefined>(undefined);
walletNetworkWarning: string | null;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;

Check failure on line 61 in src/components/WalletContext.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

Expression expected.
isLoading: boolean;
/** Which wallet backs the current session — null when disconnected. */
walletKind: WalletKind | null;
Expand All @@ -66,7 +67,7 @@
/** Whether social login is available (a Web3Auth client id is configured). */
isSocialLoginAvailable: boolean;
connectWithSocial: (provider: SocialLoginProvider) => Promise<void>;
}

Check failure on line 70 in src/components/WalletContext.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

Declaration or statement expected.

const MOCK_PUBLIC_KEY = IS_MOCK_MODE ? StellarSdk.Keypair.random().publicKey() : null;

Expand All @@ -77,6 +78,7 @@
const [isWalletConnected, setIsWalletConnected] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showInstallPrompt, setShowInstallPrompt] = useState(false);
const [isFreighterLocked, setIsFreighterLocked] = useState(false);
const [walletNetworkWarning, setWalletNetworkWarning] = useState<string | null>(null);
const [walletKind, setWalletKind] = useState<WalletKind | null>(null);
const [socialProfile, setSocialProfile] = useState<SocialWalletSession | null>(null);
Expand Down Expand Up @@ -258,7 +260,7 @@
} catch {
return false;
}
}, []);

Check failure on line 263 in src/components/WalletContext.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

',' expected.

useEffect(() => {
// Always re-verify with Freighter rather than blindly trusting localStorage (#97)
Expand Down Expand Up @@ -296,6 +298,12 @@
return;
}
const key = await getAddress();
if (key.error && isFreighterLockedError(key.error)) {
setIsFreighterLocked(true);
setShowInstallPrompt(true);
setIsLoading(false);
return;
}
const network = await getNetwork();
if ((network.networkPassphrase || "") !== appNetworkPassphrase) {
const warning = `Switch Freighter to ${appNetworkLabel} to continue. Current wallet network does not match the app network.`;
Expand All @@ -316,12 +324,17 @@
setWalletKind("freighter");
localStorage.setItem("stellar_wallet_public_key", key.address);
showSuccess("Wallet connected successfully.");
} catch {
} catch (error) {
setPublicKey(null);
setIsWalletConnected(false);
setWalletKind(null);
setWalletNetworkWarning(null);
showError("Failed to connect wallet. Please try again.");
if (isFreighterLockedError(error)) {
setIsFreighterLocked(true);
setShowInstallPrompt(true);
} else {
showError("Failed to connect wallet. Please try again.");
}
} finally {
setIsLoading(false);
}
Expand Down Expand Up @@ -362,11 +375,9 @@
};

const handleRetryInstall = async () => {
const installed = await isFreighterInstalled();
if (installed) {
setShowInstallPrompt(false);
connectWallet();
}
setShowInstallPrompt(false);
setIsFreighterLocked(false);
await connectWallet();
};

const disconnectWallet = () => {
Expand Down Expand Up @@ -396,7 +407,7 @@
showWarning(
"Disconnected. To fully revoke Freighter access, open the extension and remove this site from Connected Sites.",
);
}, [showWarning]);

Check failure on line 410 in src/components/WalletContext.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

',' expected.

const state = useMemo<WalletState>(
() => ({ publicKey, isWalletConnected, isLoading }),
Expand Down Expand Up @@ -425,15 +436,19 @@
);

return (
<WalletStateContext.Provider value={state}>

Check failure on line 439 in src/components/WalletContext.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

JSX expressions must have one parent element.
<WalletActionsContext.Provider value={actions}>{children}</WalletActionsContext.Provider>
</WalletStateContext.Provider>
<WalletContext.Provider value={contextValue}>
{children}
<InstallFreighterModal
isOpen={showInstallPrompt}
onClose={() => setShowInstallPrompt(false)}
onClose={() => {
setShowInstallPrompt(false);
setIsFreighterLocked(false);
}}
onRetry={handleRetryInstall}
isLocked={isFreighterLocked}
socialLogin={
isSocialLoginConfigured() ? (
<SocialLoginButtons
Expand Down
21 changes: 21 additions & 0 deletions src/components/__tests__/WalletContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,27 @@ describe("WalletContext", () => {
expect(screen.getByTestId("isLoading")).toHaveTextContent("false");
});

it("connectWallet - freighter locked", async () => {
mockIsConnected.mockResolvedValue({ isConnected: true });
mockIsAllowed.mockResolvedValue({ isAllowed: true });
mockGetAddress.mockResolvedValue({ error: { message: "Freighter is locked" } });

renderWithProviders(
<WalletProvider>
<TestComponent />
</WalletProvider>,
);

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

expect(screen.getByText("Freighter Wallet Locked")).toBeInTheDocument();
expect(screen.queryByText("Freighter Wallet Required")).not.toBeInTheDocument();
expect(screen.getByText(/unlock it with your password/)).toBeInTheDocument();
expect(screen.getByTestId("isLoading")).toHaveTextContent("false");
});

it("disconnectWallet", async () => {
// Start with a connected wallet
mockIsConnected.mockResolvedValue({ isConnected: true });
Expand Down
27 changes: 21 additions & 6 deletions src/utils/freighterErrors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
/**
* Detects whether an error from Freighter's signTransaction represents
* the user rejecting/cancelling the signature request, rather than a
* genuine technical failure.
*/

export class UserCancelledError extends Error {
constructor() {
super("Transaction cancelled");
Expand Down Expand Up @@ -36,3 +30,24 @@ export function wrapFreighterError(error: unknown): never {
}
throw error;
}

export class FreighterLockedError extends Error {
constructor() {
super("Freighter is locked");
this.name = "FreighterLockedError";
}
}

const LOCKED_PATTERNS = [
"freighter is locked",
"wallet is locked",
"unlock your wallet",
"extension is locked",
];

export function isFreighterLockedError(error: unknown): boolean {
if (!error) return false;
const msg = typeof error === "string" ? error : ((error as Error).message ?? "");
const lower = msg.toLowerCase();
return LOCKED_PATTERNS.some((p) => lower.includes(p));
}
Loading