From 8e2ffd448437a7397f6f3a39c028eec19b61f09c Mon Sep 17 00:00:00 2001 From: waterWang Date: Sat, 1 Aug 2026 00:02:35 +0800 Subject: [PATCH] fix: add wallet ready state to prevent signing with null keypair after page refresh (Closes #172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet appeared connected after page refresh (publicKey restored from localStorage) but keypair was always null since Keypair is not JSON-serializable, causing all transaction signing to fail. Changes: - Add state to WalletContext — true for Freighter (always ready when connected), true for secret-key only when keypair is in memory - WalletPage: show reconnect prompt when secret-key wallet is connected but not ready (keypair null after refresh), with public key displayed for reference and Reconnect/Disconnect buttons - TopNav: show 'Reconnect Required' badge instead of full wallet UI when wallet is not ready - Navbar: show Connect button instead of wallet chip when not ready --- frontend/src/components/landing/Navbar.tsx | 4 +- frontend/src/components/layout/TopNav.tsx | 45 +++++++++------ frontend/src/context/WalletContext.tsx | 6 ++ frontend/src/hooks/useWallet.ts | 3 +- frontend/src/pages/WalletPage.module.css | 47 +++++++++++++++ frontend/src/pages/WalletPage.tsx | 66 +++++++++++++++++++++- 6 files changed, 150 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/landing/Navbar.tsx b/frontend/src/components/landing/Navbar.tsx index bbca11f..3377ce4 100644 --- a/frontend/src/components/landing/Navbar.tsx +++ b/frontend/src/components/landing/Navbar.tsx @@ -18,7 +18,7 @@ const navItems = [ ] const Navbar: React.FC = () => { - const { publicKey, connected, connectionMethod, disconnect } = useWallet() + const { publicKey, connected, ready, connectionMethod, disconnect } = useWallet() const navigate = useNavigate() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileSearchOpen, setMobileSearchOpen] = useState(false) @@ -147,7 +147,7 @@ const Navbar: React.FC = () => { - {connected && publicKey ? ( + {connected && publicKey && ready ? (
diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index 4101e21..eb20275 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -17,7 +17,7 @@ const TopNav: React.FC = ({ isMobile, isDrawerOpen = false, }) => { - const { publicKey, connected, connectionMethod, disconnect } = useWallet() + const { publicKey, connected, ready, connectionMethod, disconnect } = useWallet() const getTitle = () => { const path = window.location.pathname @@ -75,23 +75,34 @@ const TopNav: React.FC = ({
{connected && publicKey ? ( - <> - - {truncateKey(publicKey)} - - {connectionMethod && ( - - {connectionMethod === 'freighter' ? 'Freighter' : 'Secret Key'} + ready ? ( + <> + + {truncateKey(publicKey)} - )} - - + {connectionMethod && ( + + {connectionMethod === 'freighter' ? 'Freighter' : 'Secret Key'} + + )} + + + ) : ( + <> + + {truncateKey(publicKey)} + + + Reconnect Required + + + ) ) : ( Not Connected diff --git a/frontend/src/context/WalletContext.tsx b/frontend/src/context/WalletContext.tsx index 417666d..45e488f 100644 --- a/frontend/src/context/WalletContext.tsx +++ b/frontend/src/context/WalletContext.tsx @@ -18,6 +18,7 @@ interface WalletContextType { publicKey: string | null keypair: Keypair | null connected: boolean + ready: boolean connectionMethod: ConnectionMethod | null freighterAvailable: boolean connect: (secretKey: string) => Promise @@ -40,6 +41,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr const [freighterAvailable, setFreighterAvailable] = useState(false) const connected = !!publicKey + // ready means the wallet can actually sign transactions: + // - Freighter: always ready when connected (Freighter handles signing) + // - Secret key: only ready when keypair is in memory (not serializable) + const ready = connected && (connectionMethod === 'freighter' || !!keypair) useEffect(() => { checkFreighterAvailable().then(setFreighterAvailable) @@ -109,6 +114,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr publicKey, keypair, connected, + ready, connectionMethod, freighterAvailable, connect, diff --git a/frontend/src/hooks/useWallet.ts b/frontend/src/hooks/useWallet.ts index 5b78f24..7a9841f 100644 --- a/frontend/src/hooks/useWallet.ts +++ b/frontend/src/hooks/useWallet.ts @@ -1,12 +1,13 @@ import { useWallet as useWalletContext } from '../context/WalletContext'; export const useWallet = () => { - const { publicKey, keypair, connected, connectionMethod, freighterAvailable, connect, connectFreighter, disconnect } = useWalletContext(); + const { publicKey, keypair, connected, ready, connectionMethod, freighterAvailable, connect, connectFreighter, disconnect } = useWalletContext(); return { address: publicKey, publicKey, keypair, connected, + ready, connectionMethod, freighterAvailable, connect, diff --git a/frontend/src/pages/WalletPage.module.css b/frontend/src/pages/WalletPage.module.css index 5ed1351..1567580 100644 --- a/frontend/src/pages/WalletPage.module.css +++ b/frontend/src/pages/WalletPage.module.css @@ -354,3 +354,50 @@ align-items: flex-start; } } + +/* Reconnect card (secret key lost after refresh) */ +.reconnectCard { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 32px; + max-width: 440px; +} + +.reconnectInfo { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; + margin: 0 0 16px; +} + +.reconnectPubkey { + font-size: 0.8125rem; + color: var(--text-secondary); + margin: 0 0 16px; + word-break: break-all; +} + +.reconnectPubkey code { + font-family: monospace; + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.75rem; +} + +.reconnectActions { + display: flex; + gap: 12px; + align-items: center; + margin-top: 16px; +} + +.reconnectActions .connectButton { + flex: 1; + margin-top: 0; +} + +.reconnectActions .disconnectButton { + margin-top: 0; +} diff --git a/frontend/src/pages/WalletPage.tsx b/frontend/src/pages/WalletPage.tsx index e991345..4dac29b 100644 --- a/frontend/src/pages/WalletPage.tsx +++ b/frontend/src/pages/WalletPage.tsx @@ -11,7 +11,7 @@ import styles from './WalletPage.module.css' const STELLAR_EXPLORER = 'https://stellar.expert/explorer/testnet' function WalletPage() { - const { publicKey, connected, connectionMethod, freighterAvailable, connect, connectFreighter, disconnect } = useWallet() + const { publicKey, connected, ready, connectionMethod, freighterAvailable, connect, connectFreighter, disconnect } = useWallet() const { balance, loading: balanceLoading, error: balanceError } = useWalletBalance(publicKey) const { transactions, loading: txLoading, error: txError } = useTransactionHistory(publicKey) const [copied, setCopied] = React.useState(false) @@ -165,6 +165,70 @@ function WalletPage() { ) } + // Reconnect prompt: wallet was previously connected via secret key but + // keypair is lost after page refresh (Keypair is not JSON-serializable). + if (!ready && connectionMethod === 'secret-key') { + return ( +
+
+

+ + Wallet +

+

Re-enter your secret key to resume.

+
+ +
+

+ Your wallet was previously connected via{' '} + Secret Key. Since the key cannot be stored in the + browser, please re-enter it to continue. +

+ {publicKey && ( +

+ Public Key: {publicKey} +

+ )} +
+ + setSecretInput(e.target.value)} + aria-describedby="reconnect-error" + /> + {connectError && ( + + )} +
+ + +
+
+
+
+ ) + } + return (