+
+ setShowSavePassword(e.target.checked)}
+ />
+
+
+
{saveMsg && (
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 9da988e..dc15b51 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -1,6 +1,7 @@
const PAGES = [
{ id: 'hd', label: 'HD Wallet' },
{ id: 'single', label: 'Single Addresses' },
+ { id: 'viewer', label: 'Wallet Viewer' },
]
export default function Sidebar({ active, onSelect }) {
diff --git a/frontend/src/components/SingleAddress.jsx b/frontend/src/components/SingleAddress.jsx
index e89ac18..fd39bf1 100644
--- a/frontend/src/components/SingleAddress.jsx
+++ b/frontend/src/components/SingleAddress.jsx
@@ -20,6 +20,8 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
const [error, setError] = useState(null)
const [filename, setFilename] = useState('')
const [savePassword, setSavePassword] = useState('')
+ const [savePasswordConfirm, setSavePasswordConfirm] = useState('')
+ const [showSavePassword, setShowSavePassword] = useState(false)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState(null)
@@ -38,6 +40,8 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
setResult(data)
setFilename('')
setSavePassword('')
+ setSavePasswordConfirm('')
+ setShowSavePassword(false)
} catch (e) {
setError(e.message)
} finally {
@@ -51,6 +55,16 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
setSaveMsg('Error: Filename is required.')
return
}
+ if (savePassword.trim() || savePasswordConfirm.trim()) {
+ if (!savePassword.trim() || !savePasswordConfirm.trim()) {
+ setSaveMsg('Error: Enter encryption password in both fields.')
+ return
+ }
+ if (savePassword !== savePasswordConfirm) {
+ setSaveMsg('Error: Encryption passwords do not match.')
+ return
+ }
+ }
setSaving(true)
setSaveMsg(null)
@@ -78,7 +92,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
{TABS.map(t => (
-
+
+
+ setSavePasswordConfirm(e.target.value)}
+ />
Encrypts private keys in JSON
{saveMsg}
diff --git a/frontend/src/components/WalletViewer.jsx b/frontend/src/components/WalletViewer.jsx
new file mode 100644
index 0000000..578ae28
--- /dev/null
+++ b/frontend/src/components/WalletViewer.jsx
@@ -0,0 +1,342 @@
+import { useEffect, useMemo, useState } from 'react'
+import CopyButton from './CopyButton'
+
+function formatBytes(bytes) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function formatDate(ms) {
+ return new Date(ms).toLocaleString()
+}
+
+function isHDWallet(wallet) {
+ return Boolean(wallet?.keystore && wallet?.addresses)
+}
+
+function buildSingleRows(result) {
+ const rows = [
+ ['Network', result.network],
+ ['Script type', result.script_type],
+ ]
+ if (result.address) rows.push(['Address', result.address])
+ if (result.public_key_hex) rows.push(['Public key (hex)', result.public_key_hex])
+ if (result.internal_pubkey_x_hex) rows.push(['Internal pubkey (x)', result.internal_pubkey_x_hex])
+ if (result.private_key_wif) rows.push(['Private key (WIF)', result.private_key_wif])
+ if (result.private_key_hex) rows.push(['Private key (hex)', result.private_key_hex])
+ return rows
+}
+
+export default function WalletViewer() {
+ const [files, setFiles] = useState({ hd: [], single: [] })
+ const [loadingFiles, setLoadingFiles] = useState(false)
+ const [loadingWallet, setLoadingWallet] = useState(false)
+ const [loadingDecrypt, setLoadingDecrypt] = useState(false)
+ const [selected, setSelected] = useState(null)
+ const [wallet, setWallet] = useState(null)
+ const [decryptedWallet, setDecryptedWallet] = useState(null)
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+
+ const visibleWallet = decryptedWallet || wallet
+ const walletKind = useMemo(() => (isHDWallet(visibleWallet) ? 'hd' : 'single'), [visibleWallet])
+
+ const refreshWallets = async () => {
+ setLoadingFiles(true)
+ setError(null)
+ try {
+ const data = await window.electronAPI.listWallets()
+ setFiles({ hd: data.hd || [], single: data.single || [] })
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoadingFiles(false)
+ }
+ }
+
+ useEffect(() => {
+ refreshWallets()
+ }, [])
+
+ const openWallet = async (kind, filename) => {
+ setLoadingWallet(true)
+ setLoadingDecrypt(false)
+ setError(null)
+ setPassword('')
+ setDecryptedWallet(null)
+ setWallet(null)
+ setSelected({ kind, filename })
+ try {
+ const data = await window.electronAPI.readWallet({ kind, filename })
+ setWallet(data)
+ if (!data?.use_encryption) {
+ setDecryptedWallet(data)
+ }
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoadingWallet(false)
+ }
+ }
+
+ const decryptWallet = async () => {
+ if (!wallet?.use_encryption || !selected) return
+ setLoadingDecrypt(true)
+ setError(null)
+ try {
+ const fn = selected.kind === 'hd' ? window.electronAPI.hdDecrypt : window.electronAPI.singleDecrypt
+ const data = await fn({ wallet, password })
+ setDecryptedWallet(data)
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoadingDecrypt(false)
+ }
+ }
+
+ return (
+
+
Wallet Viewer
+
Read and decrypt HD and Single wallet JSON files
+
+
+
+ Wallet Files
+
+ {loadingFiles ? 'Refreshing…' : 'Refresh'}
+
+
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ {selected && (
+
+
Selected Wallet
+
+ File
+ {selected.filename}
+
+
+ Source
+ {selected.kind === 'hd' ? 'HD folder' : 'Single folder'}
+
+
+ Encrypted
+ {wallet?.use_encryption ? 'yes' : 'no'}
+
+ {loadingWallet &&
Loading wallet…
}
+
+ )}
+
+ {wallet?.use_encryption && !decryptedWallet && (
+
+
Decrypt Wallet
+
+
+ setPassword(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && decryptWallet()}
+ />
+
+
+ {loadingDecrypt ? 'Decrypting…' : 'Decrypt'}
+
+
+ )}
+
+ {visibleWallet && !loadingWallet && (
+ walletKind === 'hd'
+ ?
+ :
+ )}
+
+ )
+}
+
+function FileColumn({ title, files, kind, selected, onOpen }) {
+ return (
+
+
+ {files.length === 0 && (
+ No wallet files found.
+ )}
+ {files.map(file => {
+ const isActive = selected?.kind === kind && selected?.filename === file.name
+ return (
+ onOpen(kind, file.name)}
+ style={{
+ width: '100%',
+ justifyContent: 'space-between',
+ borderColor: isActive ? 'var(--accent)' : undefined,
+ color: isActive ? 'var(--accent)' : undefined,
+ marginBottom: 6,
+ }}
+ >
+ {file.name}
+
+ {formatBytes(file.size)} · {formatDate(file.mtimeMs)}
+
+
+ )
+ })}
+
+ )
+}
+
+function HDWalletView({ wallet }) {
+ const [seedVisible, setSeedVisible] = useState(false)
+ const ks = wallet?.keystore
+
+ if (!ks) return null
+
+ return (
+ <>
+
+
+
Seed Phrase
+
+
+ setSeedVisible(v => !v)}>
+ {seedVisible ? 'Hide' : 'Show'}
+
+
+
+
+ {String(ks.seed || '').split(' ').map((word, idx) => (
+
+ {idx + 1}.{word}
+
+ ))}
+
+
+
+
+
Keystore
+ {[
+ ['Derivation', ks.derivation],
+ ['Root fingerprint', ks.root_fingerprint],
+ ['xpub', ks.xpub],
+ ['xprv', ks.xprv],
+ ].map(([label, value]) => (
+
+ {label}
+ {value}
+
+
+ ))}
+
+
+
+
Receiving Addresses
+
+
+
+ | # |
+ Path |
+ Address |
+ WIF |
+ |
+
+
+
+ {(wallet.addresses?.receiving || []).map(addr => (
+
+ | {addr.index} |
+ {addr.path} |
+ {addr.address} |
+
+ {addr.private_key_wif}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ >
+ )
+}
+
+function SingleWalletView({ wallet }) {
+ const isP2SH = Array.isArray(wallet?.participants)
+
+ return (
+
+
Wallet Data
+ {isP2SH ? (
+ <>
+ {[
+ ['Network', wallet.network],
+ ['Script type', wallet.script_type],
+ ['Configuration', `${wallet.m}-of-${wallet.n}`],
+ ['Address', wallet.address],
+ ['Redeem script', wallet.redeem_script_hex],
+ ].map(([label, value]) => (
+
+ {label}
+ {value}
+
+
+ ))}
+
+
Participants
+ {wallet.participants.map((p, idx) => (
+
+
Key {idx + 1}
+ {[
+ ['Public key', p.public_key_hex],
+ ['Private key (WIF)', p.private_key_wif],
+ ['Private key (hex)', p.private_key_hex],
+ ].map(([label, value]) => (
+
+ {label}
+ {value}
+
+
+ ))}
+
+ ))}
+ >
+ ) : (
+ buildSingleRows(wallet).map(([label, value]) => (
+
+ {label}
+ {value}
+
+
+ ))
+ )}
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index dc1cc6a..bf8ca8b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -255,6 +255,10 @@ html, body, #root {
flex-wrap: wrap;
}
+.save-actions {
+ justify-content: flex-end;
+}
+
/* ── Key/value rows ───────────────────────────────────────────────────────── */
.kv-row {
display: flex;
diff --git a/src/cli.py b/src/cli.py
index 34fb25d..be11715 100644
--- a/src/cli.py
+++ b/src/cli.py
@@ -14,7 +14,7 @@ try:
from src.p2sh import generate_p2sh_multisig
from src.p2wpkh import generate_segwit_address
from src.p2tr import generate_p2tr_address
- from src.single_wallet import encrypt_single_wallet
+ from src.single_wallet import encrypt_single_wallet, decrypt_single_wallet
except ImportError:
from hd_wallet import generate_hd_wallet, encrypt_wallet, decrypt_wallet
from p2pk import generate_p2pk
@@ -22,7 +22,7 @@ except ImportError:
from p2sh import generate_p2sh_multisig
from p2wpkh import generate_segwit_address
from p2tr import generate_p2tr_address
- from single_wallet import encrypt_single_wallet
+ from single_wallet import encrypt_single_wallet, decrypt_single_wallet
COMMANDS = {
'hd_generate': lambda a: generate_hd_wallet(**a),
@@ -34,6 +34,7 @@ COMMANDS = {
'p2wpkh': lambda a: generate_segwit_address(**a),
'p2tr': lambda a: generate_p2tr_address(**a),
'single_encrypt': lambda a: encrypt_single_wallet(a['wallet'], a['password']),
+ 'single_decrypt': lambda a: decrypt_single_wallet(a['wallet'], a['password']),
}
if __name__ == '__main__':
diff --git a/src/single_wallet.py b/src/single_wallet.py
index 5e6ee29..9891dc4 100644
--- a/src/single_wallet.py
+++ b/src/single_wallet.py
@@ -2,9 +2,9 @@ import copy
from typing import Any, Dict
try:
- from src.crypto import pw_encode
+ from src.crypto import pw_encode, pw_decode
except ImportError:
- from crypto import pw_encode
+ from crypto import pw_encode, pw_decode
SENSITIVE_FIELDS = {'private_key_hex', 'private_key_wif'}
@@ -24,6 +24,20 @@ def _encrypt_sensitive(data: Any, password: str) -> Any:
return data
+def _decrypt_sensitive(data: Any, password: str) -> Any:
+ if isinstance(data, dict):
+ out = {}
+ for key, value in data.items():
+ if key in SENSITIVE_FIELDS and isinstance(value, str):
+ out[key] = pw_decode(value, password)
+ else:
+ out[key] = _decrypt_sensitive(value, password)
+ return out
+ if isinstance(data, list):
+ return [_decrypt_sensitive(item, password) for item in data]
+ return data
+
+
def encrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, Any]:
if not password:
raise ValueError("Password is required for encryption.")
@@ -32,3 +46,16 @@ def encrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, An
if isinstance(encrypted, dict):
encrypted['use_encryption'] = True
return encrypted
+
+
+def decrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, Any]:
+ if not wallet.get('use_encryption'):
+ return copy.deepcopy(wallet)
+
+ if not password:
+ raise ValueError("Password is required for decryption.")
+
+ decrypted = _decrypt_sensitive(copy.deepcopy(wallet), password)
+ if isinstance(decrypted, dict):
+ decrypted['use_encryption'] = False
+ return decrypted