From 7b032a87034de153eb055a8ed3c31e74bf0551ba Mon Sep 17 00:00:00 2001 From: Ross Savage Date: Fri, 30 Jan 2026 12:27:34 +0100 Subject: [PATCH 1/3] Add diagnostics page --- package-lock.json | 68 ++++++++-- package.json | 6 +- src/App.tsx | 64 +++++++-- src/components/CollapsingWalletHeader.tsx | 12 +- src/components/ReceivePaymentDialog.tsx | 2 +- src/components/SideMenu.tsx | 158 ++++++++++++++++++++++ src/contexts/PartnerContext.tsx | 56 ++++++++ src/pages/DiagnosticsPage.tsx | 116 ++++++++++++++++ src/pages/RestorePage.tsx | 2 +- src/pages/WalletPage.tsx | 16 ++- src/services/bip85.ts | 93 +++++++++++++ src/services/walletService.ts | 49 ++++++- 12 files changed, 603 insertions(+), 39 deletions(-) create mode 100644 src/components/SideMenu.tsx create mode 100644 src/contexts/PartnerContext.tsx create mode 100644 src/pages/DiagnosticsPage.tsx create mode 100644 src/services/bip85.ts diff --git a/package-lock.json b/package-lock.json index 31720c1..884ed3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "wasm-example-app", "version": "0.1.0", "dependencies": { - "@breeztech/breez-sdk-liquid": "^0.10.2", + "@breeztech/breez-sdk-liquid": "^0.11.12", "@headlessui/react": "^1.7.17", + "@noble/hashes": "^2.0.1", + "@scure/bip32": "^2.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", @@ -356,9 +358,9 @@ } }, "node_modules/@breeztech/breez-sdk-liquid": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-liquid/-/breez-sdk-liquid-0.10.2.tgz", - "integrity": "sha512-wRcpZTAZCNmIYHM34s/Akj3dECd81Y9PftlPwXsyyQWIF6uJJiUMIo5e7hv8V4QAcIRuRHs3Y2ckOH5OisrCIw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-liquid/-/breez-sdk-liquid-0.11.12.tgz", + "integrity": "sha512-iGvCs7xjOLljDakF3ZTCAACsSt01a1l/DKRzBHlbPX3QrTG6ylKT5D0udFDV5vmMsJqQwYthIUZ0F3WmgLMb3A==", "license": "MIT" }, "node_modules/@esbuild/android-arm": { @@ -1017,13 +1019,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", - "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1155,6 +1172,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@swc/core": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.13.tgz", @@ -1972,6 +2012,18 @@ "@noble/hashes": "^1.2.0" } }, + "node_modules/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", diff --git a/package.json b/package.json index 2ffc96f..5ff8b92 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "@breeztech/breez-sdk-liquid": "^0.10.2", + "@breeztech/breez-sdk-liquid": "^0.11.12", "@headlessui/react": "^1.7.17", + "@noble/hashes": "^2.0.1", + "@scure/bip32": "^2.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", @@ -37,4 +39,4 @@ "vite-plugin-top-level-await": "^1.3.1", "vite-plugin-wasm": "^3.2.2" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index ea6cde4..c16a2e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,24 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { GetInfoResponse, Payment, SdkEvent } from '@breeztech/breez-sdk-liquid/web'; import * as walletService from './services/walletService'; +import { deriveKleverMnemonic } from './services/bip85'; import LoadingSpinner from './components/LoadingSpinner'; import { ToastProvider, useToast } from './contexts/ToastContext'; +import { PartnerProvider, usePartner, storePartner, clearStoredPartner } from './contexts/PartnerContext'; // Import our page components import HomePage from './pages/HomePage'; import RestorePage from './pages/RestorePage'; import GeneratePage from './pages/GeneratePage'; import WalletPage from './pages/WalletPage'; +import DiagnosticsPage from './pages/DiagnosticsPage'; // Main App without toast functionality const AppContent: React.FC = () => { // Screen navigation state - const [currentScreen, setCurrentScreen] = useState<'home' | 'restore' | 'generate' | 'wallet'>('home'); + const [currentScreen, setCurrentScreen] = useState<'home' | 'restore' | 'generate' | 'wallet' | 'diagnostics' | 'rescanSwaps'>('home'); + + const { isKleverMode, partner, partnerMismatch } = usePartner(); const [isConnected, setIsConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -148,9 +153,19 @@ const AppContent: React.FC = () => { // Try to connect with saved mnemonic on app startup useEffect(() => { const checkForExistingWallet = async () => { - const savedMnemonic = walletService.getSavedMnemonic(); + const savedMnemonic = walletService.getMnemonic(); if (savedMnemonic) { + // If partner context changed, clear stored wallet and start fresh + if (partnerMismatch) { + console.log('Partner mismatch detected, clearing stored wallet...'); + walletService.clearMnemonic(); + clearStoredPartner(); + setCurrentScreen('home'); + setIsLoading(false); + return; + } + try { setIsLoading(true); await connectWallet(savedMnemonic, false); @@ -206,14 +221,22 @@ const AppContent: React.FC = () => { const connectWallet = async (mnemonic: string, restore: boolean) => { try { setIsLoading(true); - setIsRestoring(restore); // Mark that we're restoring data + setIsRestoring(restore); // Mark that we're restoring data setError(null); - // Initialize wallet with mnemonic - await walletService.initWallet(mnemonic); + let seed: number[] | undefined; + if (isKleverMode) { + // In Klever mode, derive mnemonic via BIP-85 and pass its string bytes as seed + const derived = deriveKleverMnemonic(mnemonic, 0); + seed = derived.mnemonicBytes; + } - // Save mnemonic for future use - walletService.saveMnemonic(mnemonic); + // Initialize wallet: usse the seed when set, else use the mnemonic + await walletService.initWallet(mnemonic, seed); + + // Save the original mnemonic (so re-derivation works on reload) + walletService.setMnemonic(mnemonic); + storePartner(partner); // Get wallet info and transactions const info = await walletService.getWalletInfo(); @@ -244,8 +267,9 @@ const AppContent: React.FC = () => { await walletService.disconnect(); } - // Clear the stored mnemonic + // Clear the stored mnemonic and partner walletService.clearMnemonic(); + clearStoredPartner(); // Reset state setIsConnected(false); @@ -269,6 +293,8 @@ const AppContent: React.FC = () => { const navigateToRestore = () => setCurrentScreen('restore'); const navigateToGenerate = () => setCurrentScreen('generate'); const navigateToHome = () => setCurrentScreen('home'); + const navigateToDiagnostics = () => setCurrentScreen('diagnostics'); + const navigateToWallet = () => setCurrentScreen('wallet'); const clearError = () => setError(null); // Determine which screen to render @@ -320,6 +346,14 @@ const AppContent: React.FC = () => { error={error} onClearError={clearError} onLogout={handleLogout} + onOpenDiagnostics={navigateToDiagnostics} + /> + ); + + case 'diagnostics': + return ( + ); @@ -334,13 +368,15 @@ const AppContent: React.FC = () => { // Wrap the App with ToastProvider function App() { return ( - -
-
- + + +
+
+ +
-
- + + ); } diff --git a/src/components/CollapsingWalletHeader.tsx b/src/components/CollapsingWalletHeader.tsx index 3e71482..4fde538 100644 --- a/src/components/CollapsingWalletHeader.tsx +++ b/src/components/CollapsingWalletHeader.tsx @@ -5,14 +5,14 @@ interface CollapsingWalletHeaderProps { walletInfo: GetInfoResponse | null; usdRate: number | null; scrollProgress: number; - onLogout: () => void; + onMenuOpen: () => void; } const CollapsingWalletHeader: React.FC = ({ walletInfo, scrollProgress, usdRate, - onLogout, + onMenuOpen, }) => { if (!walletInfo) return null; @@ -36,13 +36,13 @@ const CollapsingWalletHeader: React.FC = ({ return (
{/* Main Balance - always visible but scales down */} diff --git a/src/components/ReceivePaymentDialog.tsx b/src/components/ReceivePaymentDialog.tsx index b464f97..5b78de0 100644 --- a/src/components/ReceivePaymentDialog.tsx +++ b/src/components/ReceivePaymentDialog.tsx @@ -186,7 +186,7 @@ const ReceivePaymentDialog: React.FC = ({ isOpen, onC try { // Generate lightning invoice using walletService const prepareResponse = await walletService.prepareReceivePayment({ - paymentMethod: 'lightning', + paymentMethod: 'bolt11Invoice', amount: { type: 'bitcoin', payerAmountSat: amountSats, diff --git a/src/components/SideMenu.tsx b/src/components/SideMenu.tsx new file mode 100644 index 0000000..e7397fb --- /dev/null +++ b/src/components/SideMenu.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; + +interface SideMenuProps { + isOpen: boolean; + onClose: () => void; + onOpenDiagnostics: () => void; + onLogout: () => void; +} + +// Get position synchronously +const getContentRootPosition = () => { + const el = document.getElementById('content-root'); + if (!el) return { left: 0, width: window.innerWidth }; + const rect = el.getBoundingClientRect(); + return { left: rect.left, width: rect.width }; +}; + +const SideMenu: React.FC = ({ + isOpen, + onClose, + onOpenDiagnostics, + onLogout, +}) => { + const [position, setPosition] = useState(getContentRootPosition); + const [isVisible, setIsVisible] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + + // Calculate position based on content-root element + const calculatePosition = useCallback(() => { + setPosition(getContentRootPosition()); + }, []); + + // Update position on resize + useEffect(() => { + calculatePosition(); + window.addEventListener('resize', calculatePosition); + return () => { + window.removeEventListener('resize', calculatePosition); + }; + }, [calculatePosition]); + + // Handle open/close animation + useEffect(() => { + if (isOpen) { + calculatePosition(); + setIsVisible(true); + // Small delay to ensure DOM is ready before animating + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setIsAnimating(true); + }); + }); + } else { + setIsAnimating(false); + // Wait for animation to complete before hiding + const timer = setTimeout(() => { + setIsVisible(false); + }, 200); + return () => clearTimeout(timer); + } + }, [isOpen, calculatePosition]); + + // Close menu on escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + const handleDiagnosticsClick = () => { + onOpenDiagnostics(); + onClose(); + }; + + const handleLogoutClick = () => { + onLogout(); + onClose(); + }; + + if (!isVisible) return null; + + const menuContent = ( +
+ {/* Backdrop */} +
+ + {/* Menu Panel */} +
+ {/* Close button */} +
+ +
+ + {/* Menu Items */} + +
+
+ ); + + return createPortal(menuContent, document.body); +}; + +export default SideMenu; diff --git a/src/contexts/PartnerContext.tsx b/src/contexts/PartnerContext.tsx new file mode 100644 index 0000000..e1808e0 --- /dev/null +++ b/src/contexts/PartnerContext.tsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext } from 'react'; + +const PARTNER_STORAGE_KEY = 'walletPartner'; + +interface PartnerContextType { + partner: string | null; + isKleverMode: boolean; + partnerMismatch: boolean; +} + +const PartnerContext = createContext({ + partner: null, + isKleverMode: false, + partnerMismatch: false, +}); + +export const usePartner = () => useContext(PartnerContext); + +export function getStoredPartner(): string | null { + return localStorage.getItem(PARTNER_STORAGE_KEY); +} + +export function storePartner(partner: string | null): void { + // Store empty string for "no partner" so we can distinguish from "never stored" + localStorage.setItem(PARTNER_STORAGE_KEY, partner ?? ''); +} + +export function clearStoredPartner(): void { + localStorage.removeItem(PARTNER_STORAGE_KEY); +} + +function detectPartner(): string | null { + const params = new URLSearchParams(window.location.search); + return params.get('p') || null; +} + +const detectedPartner = detectPartner(); +const storedPartner = getStoredPartner(); +// Mismatch if there's a stored wallet and the partner context has changed in either direction +// (null -> partner, partner -> null, or partner A -> partner B) +// storedPartner is null when never set, '' when explicitly stored as no-partner +const hasStoredWallet = localStorage.getItem('walletMnemonic') !== null; +const normalizedStored = storedPartner || null; // treat '' same as null +const partnerMismatch = hasStoredWallet && storedPartner !== null && detectedPartner !== normalizedStored; + +export const PartnerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/pages/DiagnosticsPage.tsx b/src/pages/DiagnosticsPage.tsx new file mode 100644 index 0000000..e7c1d06 --- /dev/null +++ b/src/pages/DiagnosticsPage.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import * as walletService from '../services/walletService'; +import PageLayout from '../components/layout/PageLayout'; +import { useToast } from '../contexts/ToastContext'; + +interface DiagnosticsPageProps { + onBack: () => void; +} + +const DiagnosticsPage: React.FC = ({ onBack }) => { + const { showToast } = useToast(); + const [isRescanningOnchain, setIsRescanningOnchain] = useState(false); + + const handleRescanOnchainSwaps = async () => { + setIsRescanningOnchain(true); + try { + await walletService.rescanOnchainSwaps(); + showToast('success', 'Onchain swaps rescanned successfully'); + } catch (error) { + console.error('Failed to rescan onchain swaps:', error); + showToast('error', 'Failed to rescan onchain swaps'); + } finally { + setIsRescanningOnchain(false); + } + }; + + const handleDownloadLogs = () => { + try { + const logs = walletService.getSdkLogs(); + + if (!logs || logs.trim().length === 0) { + showToast('info', 'No logs available to download'); + return; + } + + // Create blob with logs + const blob = new Blob([logs], { type: 'text/plain' }); + + // Generate timestamped filename + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `breez-sdk-logs-${timestamp}.txt`; + + // Create download link and trigger download + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showToast('success', 'Logs downloaded successfully'); + } catch (error) { + console.error('Failed to download logs:', error); + showToast('error', 'Failed to download logs'); + } + }; + + return ( + +
+ {/* Rescan Swaps Section */} +
+

+ Rescan onchain swaps to check for refundables or update swap statuses. +

+ +
+ + {/* Logs Section */} +
+

+ Download SDK logs for troubleshooting and support. +

+ +
+
+
+ ); +}; + +export default DiagnosticsPage; diff --git a/src/pages/RestorePage.tsx b/src/pages/RestorePage.tsx index c8c4141..a8ed539 100644 --- a/src/pages/RestorePage.tsx +++ b/src/pages/RestorePage.tsx @@ -3,7 +3,7 @@ import PageLayout from '../components/layout/PageLayout'; import { PrimaryButton } from '../components/ui'; interface RestorePageProps { - onConnect: (mnemonic: string) => void; + onConnect: (mnemonic: string, derivationPath?: string) => void; onBack: () => void; onClearError: () => void; } diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 0824d21..be30224 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState, useCallback } from 'react'; import { GetInfoResponse, Payment } from '@breeztech/breez-sdk-liquid/web'; import * as walletService from '../services/walletService'; import CollapsingWalletHeader from '../components/CollapsingWalletHeader'; +import SideMenu from '../components/SideMenu'; import TransactionList from '../components/TransactionList'; import LoadingSpinner from '../components/LoadingSpinner'; import SendPaymentDialog from '../components/SendPaymentDialog'; @@ -17,6 +18,7 @@ interface WalletPageProps { error: string | null; onClearError: () => void; onLogout: () => void; + onOpenDiagnostics: () => void; } const WalletPage: React.FC = ({ @@ -25,12 +27,14 @@ const WalletPage: React.FC = ({ usdRate, refreshWalletData, isRestoring, - onLogout + onLogout, + onOpenDiagnostics }) => { const [scrollProgress, setScrollProgress] = useState(0); const [isSendDialogOpen, setIsSendDialogOpen] = useState(false); const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false); const [selectedPayment, setSelectedPayment] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); const transactionsContainerRef = useRef(null); const collapseThreshold = 100; // pixels of scroll before header is fully collapsed @@ -79,13 +83,21 @@ const WalletPage: React.FC = ({
)} + {/* Side Menu */} + setIsMenuOpen(false)} + onOpenDiagnostics={onOpenDiagnostics} + onLogout={onLogout} + /> + {/* Fixed position header that collapses on scroll */}
setIsMenuOpen(true)} />
diff --git a/src/services/bip85.ts b/src/services/bip85.ts new file mode 100644 index 0000000..a66ffae --- /dev/null +++ b/src/services/bip85.ts @@ -0,0 +1,93 @@ +import { HDKey } from '@scure/bip32'; +import { hmac } from '@noble/hashes/hmac.js'; +import { sha512 } from '@noble/hashes/sha2.js'; +import * as bip39 from 'bip39'; + +const HARDENED = 0x80000000; + +export interface KleverDeriveResult { + /** The derived mnemonic string */ + mnemonic: string; + /** The mnemonic string as UTF-8 bytes — Klever uses this as the seed for Xpriv::new_master */ + mnemonicBytes: number[]; +} + +/** + * Replicates Klever's BIP-85 derivation from kos-rs/signer.rs exactly. + * + * Klever's derive_from_mnemonic: + * 1. mnemonic → seed → root master key + * 2. path = [39', 0', word_count', index'] + * 3. data = derive(secp, root, path) → returns 64-byte HMAC output + * 4. entropy = data[0 .. word_count * 4 / 3] + * 5. new mnemonic = Mnemonic::from_entropy_in(English, entropy) + * 6. return mnemonic_str.as_bytes().to_vec() ← string bytes, NOT seed + * + * Klever's derive(secp, root, path): + * 1. bip85_root = root.derive_priv(secp, path) ← first derivation + * 2. derived = bip85_root.derive_priv(secp, path) ← second derivation (same path) + * 3. HMAC-SHA512(key="bip-entropy-from-k", msg=derived.private_key) + * 4. return 64-byte HMAC result + * + * Klever's get_xpub_as_string then uses these mnemonic bytes directly as + * the seed for Xpriv::new_master (NOT converting mnemonic→seed via PBKDF2). + * The Breez SDK ConnectRequest.seed should receive these bytes. + */ +export function deriveKleverMnemonic( + mnemonic: string, + index: number = 0, + passphrase: string = '', +): KleverDeriveResult { + if (index >= 0x80000000) { + throw new Error(`Invalid index: ${index}`); + } + + // Step 1: mnemonic → seed → root + const wordCount = mnemonic.trim().split(/\s+/).length; + const seed = bip39.mnemonicToSeedSync(mnemonic, passphrase); + const root = HDKey.fromMasterSeed(seed); + + // Step 2: build path [39', 0', word_count', index'] + const pathComponents = [ + 39 + HARDENED, + 0 + HARDENED, + wordCount + HARDENED, + index + HARDENED, + ]; + + // Step 3: derive(secp, root, path) — mirrors Klever's derive() helper + // bip85_root = root.derive_priv(secp, path) + let bip85Root: HDKey = root; + for (const c of pathComponents) { + bip85Root = bip85Root.deriveChild(c); + } + // derived = bip85_root.derive_priv(secp, path) + let derived: HDKey = bip85Root; + for (const c of pathComponents) { + derived = derived.deriveChild(c); + } + + if (!derived.privateKey) { + throw new Error('Failed to derive private key'); + } + + // HMAC-SHA512(key="bip-entropy-from-k", msg=derived.private_key) + const encoder = new TextEncoder(); + const data = hmac(sha512, encoder.encode('bip-entropy-from-k'), derived.privateKey); + + // Step 4: entropy = data[0 .. word_count * 4 / 3] + const entropyLen = (wordCount * 4) / 3; + const entropy = data.slice(0, entropyLen); + + // Step 5: Mnemonic::from_entropy_in(English, entropy) + const hex = Array.from(entropy).map(b => b.toString(16).padStart(2, '0')).join(''); + const derivedMnemonic = bip39.entropyToMnemonic(hex); + + // Step 6: mnemonic_str.as_bytes().to_vec() — Klever returns mnemonic string as raw bytes + const mnemonicBytes = Array.from(encoder.encode(derivedMnemonic)); + + return { + mnemonic: derivedMnemonic, + mnemonicBytes, + }; +} diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 8c3b5db..7dc2a78 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -3,8 +3,40 @@ import * as breezSdk from '@breeztech/breez-sdk-liquid/web'; // Private SDK instance - not exposed outside this module let sdk: breezSdk.BindingLiquidSdk | null = null; -export const initWallet = async (mnemonic: string): Promise => { +// Log storage configuration +const MAX_LOG_LINES = 100000; +let sdkLogs: string[] = []; +let logger: WebLogger | null = null; + +function appendLog(line: string) { + sdkLogs.push(line); + if (sdkLogs.length > MAX_LOG_LINES) { + sdkLogs.splice(0, sdkLogs.length - MAX_LOG_LINES); + } +} + +class WebLogger implements breezSdk.Logger { + log = (logEntry: breezSdk.LogEntry) => { + const ts = new Date().toISOString(); + const formatted = `${ts} [${logEntry.level}]: ${logEntry.line}`; + console.log(formatted); + appendLog(formatted); + } +} + +// Export function to retrieve logs +export function getSdkLogs(): string { + return sdkLogs.join('\n'); +} + +export const initWallet = async (mnemonic: string, seed?: number[]): Promise => { try { + // Initialize logger if not already done + if (!logger) { + logger = new WebLogger(); + breezSdk.setLogger(logger); + } + const config = breezSdk.defaultConfig('mainnet'); // Configure working directory and cache dir config.workingDir = './breez_data'; @@ -18,10 +50,11 @@ export const initWallet = async (mnemonic: string): Promise => { config.breezApiKey = breezApiKey; - // Connect to Breez network with user mnemonic + // Connect to Breez network with either seed or mnemonic sdk = await breezSdk.connect({ config, - mnemonic, + mnemonic: seed ? undefined : mnemonic, + seed, }); // Return void instead of the SDK instance @@ -162,12 +195,12 @@ export const disconnect = async (): Promise => { }; // Helper to save mnemonic to localStorage -export const saveMnemonic = (mnemonic: string): void => { +export const setMnemonic = (mnemonic: string): void => { localStorage.setItem('walletMnemonic', mnemonic); }; // Helper to retrieve mnemonic from localStorage -export const getSavedMnemonic = (): string | null => { +export const getMnemonic = (): string | null => { return localStorage.getItem('walletMnemonic'); }; @@ -175,3 +208,9 @@ export const getSavedMnemonic = (): string | null => { export const clearMnemonic = (): void => { localStorage.removeItem('walletMnemonic'); }; + +// Rescan all expired onchain swaps to check for refundable funds +export const rescanOnchainSwaps = async (): Promise => { + if (!sdk) throw new Error('SDK not initialized'); + return await sdk.rescanOnchainSwaps(); +}; From 2a1e8d579e20d2d97d474897d5e56b25f0662706 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Fri, 30 Jan 2026 15:36:42 +0300 Subject: [PATCH 2/3] fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index c16a2e6..9a3c47d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -231,7 +231,7 @@ const AppContent: React.FC = () => { seed = derived.mnemonicBytes; } - // Initialize wallet: usse the seed when set, else use the mnemonic + // Initialize wallet: use the seed when set, else use the mnemonic await walletService.initWallet(mnemonic, seed); // Save the original mnemonic (so re-derivation works on reload) From 8445fc4ca76887eab06c82610f6cd690f21d0e88 Mon Sep 17 00:00:00 2001 From: Ross Savage <551697+dangeross@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:20:17 +0000 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/RestorePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/RestorePage.tsx b/src/pages/RestorePage.tsx index a8ed539..c8c4141 100644 --- a/src/pages/RestorePage.tsx +++ b/src/pages/RestorePage.tsx @@ -3,7 +3,7 @@ import PageLayout from '../components/layout/PageLayout'; import { PrimaryButton } from '../components/ui'; interface RestorePageProps { - onConnect: (mnemonic: string, derivationPath?: string) => void; + onConnect: (mnemonic: string) => void; onBack: () => void; onClearError: () => void; }