diff --git a/src/components/InstallFreighterModal.tsx b/src/components/InstallFreighterModal.tsx index d6d8c972..36b99d05 100644 --- a/src/components/InstallFreighterModal.tsx +++ b/src/components/InstallFreighterModal.tsx @@ -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 } { @@ -28,6 +33,7 @@ export default function InstallFreighterModal({ onClose, onRetry, socialLogin, + isLocked = false, }: InstallFreighterModalProps) { const browser = useMemo( () => (isOpen ? detectBrowser() : { name: "", supported: false }), @@ -53,13 +59,19 @@ export default function InstallFreighterModal({

- {socialLogin ? "Connect a wallet" : "Freighter Wallet Required"} + {socialLogin + ? "Connect a wallet" + : isLocked + ? "Freighter Wallet Locked" + : "Freighter Wallet Required"}

{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."}

{!browser.supported && browser.name && ( @@ -70,21 +82,32 @@ export default function InstallFreighterModal({ )}
- - Install Freighter - + {isLocked ? ( +
+ Freighter is locked. Open the extension in your browser toolbar, enter your + password, and then click the button below. +
+ ) : ( + + Install Freighter + + )} {socialLogin} diff --git a/src/components/WalletContext.tsx b/src/components/WalletContext.tsx index 5bde1dde..19a577ce 100644 --- a/src/components/WalletContext.tsx +++ b/src/components/WalletContext.tsx @@ -25,6 +25,7 @@ import { type SocialLoginProvider, type SocialWalletSession, } from "@/lib/socialWallet"; +import { isFreighterLockedError } from "@/utils/freighterErrors"; import InstallFreighterModal from "./InstallFreighterModal"; import SocialLoginButtons from "./SocialLoginButtons"; @@ -77,6 +78,7 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { const [isWalletConnected, setIsWalletConnected] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showInstallPrompt, setShowInstallPrompt] = useState(false); + const [isFreighterLocked, setIsFreighterLocked] = useState(false); const [walletNetworkWarning, setWalletNetworkWarning] = useState(null); const [walletKind, setWalletKind] = useState(null); const [socialProfile, setSocialProfile] = useState(null); @@ -296,6 +298,12 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { 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.`; @@ -316,12 +324,17 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { 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); } @@ -362,11 +375,9 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { }; const handleRetryInstall = async () => { - const installed = await isFreighterInstalled(); - if (installed) { - setShowInstallPrompt(false); - connectWallet(); - } + setShowInstallPrompt(false); + setIsFreighterLocked(false); + await connectWallet(); }; const disconnectWallet = () => { @@ -432,8 +443,12 @@ export const WalletProvider = ({ children }: { children: ReactNode }) => { {children} setShowInstallPrompt(false)} + onClose={() => { + setShowInstallPrompt(false); + setIsFreighterLocked(false); + }} onRetry={handleRetryInstall} + isLocked={isFreighterLocked} socialLogin={ isSocialLoginConfigured() ? ( { 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( + + + , + ); + + 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 }); diff --git a/src/utils/freighterErrors.ts b/src/utils/freighterErrors.ts index 139be811..ec79e656 100644 --- a/src/utils/freighterErrors.ts +++ b/src/utils/freighterErrors.ts @@ -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"); @@ -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)); +}