feat(wallet): add wallet viewer, secure file reads, and enforce save validation
- Introduce Wallet Viewer with HD/Single list, read, and decrypt support - Add single_decrypt backend command and IPC bridge methods - Require filename on save and add password confirm + visibility toggle"
This commit is contained in:
@@ -25,6 +25,29 @@ function ensureWalletDir(kind) {
|
||||
return walletDir
|
||||
}
|
||||
|
||||
function normalizeFilename(filename) {
|
||||
if (typeof filename !== 'string') {
|
||||
throw new Error('Filename must be a string.')
|
||||
}
|
||||
const trimmed = filename.trim()
|
||||
const safeName = path.basename(trimmed)
|
||||
if (!safeName || safeName !== trimmed) {
|
||||
throw new Error('Invalid filename.')
|
||||
}
|
||||
return safeName
|
||||
}
|
||||
|
||||
function resolveWalletFile(kind, filename) {
|
||||
const walletDir = ensureWalletDir(kind)
|
||||
const safeName = normalizeFilename(filename)
|
||||
const walletDirAbs = path.resolve(walletDir)
|
||||
const filePath = path.resolve(walletDir, safeName)
|
||||
if (!filePath.startsWith(walletDirAbs + path.sep)) {
|
||||
throw new Error('Invalid file path.')
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
// ── Resolve Python executable ──────────────────────────────────────────────────
|
||||
function findPython() {
|
||||
const repoRoot = path.join(__dirname, '..', '..')
|
||||
@@ -62,14 +85,48 @@ ipcMain.handle('p2sh', (_, args) => callPython('p2sh', args))
|
||||
ipcMain.handle('p2wpkh', (_, args) => callPython('p2wpkh', args))
|
||||
ipcMain.handle('p2tr', (_, args) => callPython('p2tr', args))
|
||||
ipcMain.handle('single-encrypt', (_, args) => callPython('single_encrypt', args))
|
||||
ipcMain.handle('single-decrypt', (_, args) => callPython('single_decrypt', args))
|
||||
|
||||
ipcMain.handle('save-wallet', (_, { filename, data, kind }) => {
|
||||
const walletDir = ensureWalletDir(kind)
|
||||
const filePath = path.join(walletDir, filename)
|
||||
const filePath = resolveWalletFile(kind, filename)
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
return filePath
|
||||
})
|
||||
|
||||
ipcMain.handle('list-wallets', () => {
|
||||
const out = { hd: [], single: [] }
|
||||
for (const kind of Object.keys(WALLET_DIRS)) {
|
||||
const walletDir = ensureWalletDir(kind)
|
||||
const files = fs.readdirSync(walletDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.json'))
|
||||
.map(entry => {
|
||||
const filePath = path.join(walletDir, entry.name)
|
||||
const stat = fs.statSync(filePath)
|
||||
return {
|
||||
name: entry.name,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
out[kind] = files
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
ipcMain.handle('read-wallet', (_, { kind, filename }) => {
|
||||
const filePath = resolveWalletFile(kind, filename)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('Wallet file not found.')
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid wallet JSON: ${err.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-wallet-dir', () => {
|
||||
fs.mkdirSync(WALLET_ROOT_DIR, { recursive: true })
|
||||
return WALLET_ROOT_DIR
|
||||
|
||||
@@ -10,6 +10,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
p2wpkh: (args) => ipcRenderer.invoke('p2wpkh', args),
|
||||
p2tr: (args) => ipcRenderer.invoke('p2tr', args),
|
||||
singleEncrypt:(args) => ipcRenderer.invoke('single-encrypt', args),
|
||||
singleDecrypt:(args) => ipcRenderer.invoke('single-decrypt', args),
|
||||
listWallets: () => ipcRenderer.invoke('list-wallets'),
|
||||
readWallet: (args) => ipcRenderer.invoke('read-wallet', args),
|
||||
saveWallet: (filename, data, kind) => ipcRenderer.invoke('save-wallet', { filename, data, kind }),
|
||||
getWalletDir: () => ipcRenderer.invoke('get-wallet-dir'),
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import HDWallet from './components/HDWallet'
|
||||
import SingleAddress from './components/SingleAddress'
|
||||
import WalletViewer from './components/WalletViewer'
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState('hd')
|
||||
@@ -9,6 +10,7 @@ export default function App() {
|
||||
const renderPage = () => {
|
||||
if (page === 'hd') return <HDWallet />
|
||||
if (page === 'single') return <SingleAddress />
|
||||
if (page === 'viewer') return <WalletViewer />
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ export default function HDWallet() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [seedVisible, setSeedVisible] = useState(false)
|
||||
const [saveFilename, setSaveFilename] = useState('')
|
||||
const [savePassword, setSavePassword] = useState('')
|
||||
const [savePasswordConfirm, setSavePasswordConfirm] = useState('')
|
||||
const [showSavePassword, setShowSavePassword] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState(null)
|
||||
|
||||
@@ -25,6 +28,10 @@ export default function HDWallet() {
|
||||
setError(null)
|
||||
setWallet(null)
|
||||
setSeedVisible(false)
|
||||
setSaveFilename('')
|
||||
setSavePassword('')
|
||||
setSavePasswordConfirm('')
|
||||
setShowSavePassword(false)
|
||||
try {
|
||||
const data = await window.electronAPI.hdGenerate({
|
||||
network: form.network,
|
||||
@@ -44,6 +51,21 @@ export default function HDWallet() {
|
||||
|
||||
const saveWallet = async () => {
|
||||
if (!wallet) return
|
||||
if (!saveFilename.trim()) {
|
||||
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)
|
||||
try {
|
||||
@@ -51,7 +73,9 @@ export default function HDWallet() {
|
||||
if (savePassword.trim()) {
|
||||
data = await window.electronAPI.hdEncrypt({ wallet, password: savePassword })
|
||||
}
|
||||
const filename = `wallet_${form.network}_${form.bip_type}_${Date.now()}.json`
|
||||
const filename = saveFilename.trim().endsWith('.json')
|
||||
? saveFilename.trim()
|
||||
: `${saveFilename.trim()}.json`
|
||||
const savedPath = await window.electronAPI.saveWallet(filename, data, 'hd')
|
||||
const encrypted = savePassword ? ' (encrypted)' : ''
|
||||
setSaveMsg(`Saved${encrypted}: ${savedPath}`)
|
||||
@@ -191,21 +215,50 @@ export default function HDWallet() {
|
||||
<div className="card">
|
||||
<div className="card-title">Save Wallet</div>
|
||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Filename</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Required"
|
||||
value={saveFilename}
|
||||
onChange={e => setSaveFilename(e.target.value)}
|
||||
/>
|
||||
<span className="form-hint">.json · saved to ~/.wallet-generator/hdwallet/</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Encryption password (optional)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password"
|
||||
type={showSavePassword ? 'text' : 'password'}
|
||||
placeholder="Leave blank to save unencrypted"
|
||||
value={savePassword}
|
||||
onChange={e => setSavePassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Confirm encryption password</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type={showSavePassword ? 'text' : 'password'}
|
||||
placeholder="Repeat encryption password"
|
||||
value={savePasswordConfirm}
|
||||
onChange={e => setSavePasswordConfirm(e.target.value)}
|
||||
/>
|
||||
<span className="form-hint">AES-256-CBC · Electrum-compatible</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
<div className="checkbox-group" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show-hd-save-password"
|
||||
checked={showSavePassword}
|
||||
onChange={e => setShowSavePassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="show-hd-save-password">Show encryption password</label>
|
||||
</div>
|
||||
<div className="btn-row save-actions">
|
||||
<button className="btn btn-secondary" onClick={saveWallet} disabled={saving}>
|
||||
{saving ? <><span className="spinner" style={{borderTopColor:'var(--text)'}} /> Saving…</> : 'Download JSON'}
|
||||
{saving ? <><span className="spinner" style={{borderTopColor:'var(--text)'}} /> Saving…</> : 'Save JSON'}
|
||||
</button>
|
||||
</div>
|
||||
{saveMsg && (
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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' }) {
|
||||
|
||||
<div className="tabs">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} className={`tab${tab === t.id ? ' active' : ''}`} onClick={() => { setTab(t.id); setResult(null); setError(null); setSaveMsg(null); setFilename(''); setSavePassword('') }}>
|
||||
<button key={t.id} className={`tab${tab === t.id ? ' active' : ''}`} onClick={() => { setTab(t.id); setResult(null); setError(null); setSaveMsg(null); setFilename(''); setSavePassword(''); setSavePasswordConfirm(''); setShowSavePassword(false) }}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -147,17 +161,38 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
|
||||
<label className="form-label">Encryption password (optional)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password"
|
||||
type={showSavePassword ? 'text' : 'password'}
|
||||
placeholder="Leave blank to save unencrypted"
|
||||
value={savePassword}
|
||||
onChange={e => setSavePassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Confirm encryption password</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type={showSavePassword ? 'text' : 'password'}
|
||||
placeholder="Repeat encryption password"
|
||||
value={savePasswordConfirm}
|
||||
onChange={e => setSavePasswordConfirm(e.target.value)}
|
||||
/>
|
||||
<span className="form-hint">Encrypts private keys in JSON</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={save} disabled={saving}>
|
||||
{saving ? <><span className="spinner" style={{borderTopColor:'var(--text)'}} /> Saving…</> : 'Save JSON'}
|
||||
</button>
|
||||
<div className="checkbox-group" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="show-single-save-password"
|
||||
checked={showSavePassword}
|
||||
onChange={e => setShowSavePassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="show-single-save-password">Show encryption password</label>
|
||||
</div>
|
||||
<div className="btn-row save-actions">
|
||||
<button className="btn btn-secondary" onClick={save} disabled={saving}>
|
||||
{saving ? <><span className="spinner" style={{borderTopColor:'var(--text)'}} /> Saving…</> : 'Save JSON'}
|
||||
</button>
|
||||
</div>
|
||||
{saveMsg && (
|
||||
<div className={`alert ${saveMsg.startsWith('Error') ? 'alert-error' : 'alert-success'}`} style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
{saveMsg}
|
||||
|
||||
342
frontend/src/components/WalletViewer.jsx
Normal file
342
frontend/src/components/WalletViewer.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="page-title">Wallet Viewer</div>
|
||||
<div className="page-subtitle">Read and decrypt HD and Single wallet JSON files</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Wallet Files</span>
|
||||
<button className="btn btn-secondary" onClick={refreshWallets} disabled={loadingFiles}>
|
||||
{loadingFiles ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<FileColumn
|
||||
title="HD Wallets"
|
||||
files={files.hd}
|
||||
kind="hd"
|
||||
selected={selected}
|
||||
onOpen={openWallet}
|
||||
/>
|
||||
<FileColumn
|
||||
title="Single Wallets"
|
||||
files={files.single}
|
||||
kind="single"
|
||||
selected={selected}
|
||||
onOpen={openWallet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{selected && (
|
||||
<div className="card">
|
||||
<div className="card-title">Selected Wallet</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">File</span>
|
||||
<span className="kv-value">{selected.filename}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">Source</span>
|
||||
<span className="kv-value">{selected.kind === 'hd' ? 'HD folder' : 'Single folder'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">Encrypted</span>
|
||||
<span className="kv-value">{wallet?.use_encryption ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
{loadingWallet && <div className="alert alert-info" style={{ marginTop: 12, marginBottom: 0 }}>Loading wallet…</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wallet?.use_encryption && !decryptedWallet && (
|
||||
<div className="card">
|
||||
<div className="card-title">Decrypt Wallet</div>
|
||||
<div className="form-group" style={{ marginBottom: 14 }}>
|
||||
<label className="form-label">Password</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password"
|
||||
placeholder="Wallet encryption password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && decryptWallet()}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={decryptWallet} disabled={loadingDecrypt || !password.trim()}>
|
||||
{loadingDecrypt ? 'Decrypting…' : 'Decrypt'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleWallet && !loadingWallet && (
|
||||
walletKind === 'hd'
|
||||
? <HDWalletView wallet={visibleWallet} />
|
||||
: <SingleWalletView wallet={visibleWallet} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileColumn({ title, files, kind, selected, onOpen }) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ marginBottom: 4 }}>{title}</label>
|
||||
{files.length === 0 && (
|
||||
<span className="form-hint">No wallet files found.</span>
|
||||
)}
|
||||
{files.map(file => {
|
||||
const isActive = selected?.kind === kind && selected?.filename === file.name
|
||||
return (
|
||||
<button
|
||||
key={file.name}
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onOpen(kind, file.name)}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
borderColor: isActive ? 'var(--accent)' : undefined,
|
||||
color: isActive ? 'var(--accent)' : undefined,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-xs)' }}>
|
||||
{formatBytes(file.size)} · {formatDate(file.mtimeMs)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HDWalletView({ wallet }) {
|
||||
const [seedVisible, setSeedVisible] = useState(false)
|
||||
const ks = wallet?.keystore
|
||||
|
||||
if (!ks) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Seed Phrase</span>
|
||||
<div className="btn-row">
|
||||
<CopyButton text={ks.seed} />
|
||||
<button className="btn btn-ghost btn-icon" onClick={() => setSeedVisible(v => !v)}>
|
||||
{seedVisible ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seed-words">
|
||||
{String(ks.seed || '').split(' ').map((word, idx) => (
|
||||
<span key={idx} className={`seed-word${seedVisible ? '' : ' hidden'}`}>
|
||||
<span>{idx + 1}.</span>{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Keystore</div>
|
||||
{[
|
||||
['Derivation', ks.derivation],
|
||||
['Root fingerprint', ks.root_fingerprint],
|
||||
['xpub', ks.xpub],
|
||||
['xprv', ks.xprv],
|
||||
].map(([label, value]) => (
|
||||
<div className="kv-row" key={label}>
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className="kv-value">{value}</span>
|
||||
<span className="kv-actions"><CopyButton text={String(value || '')} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Receiving Addresses</div>
|
||||
<table className="addr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Path</th>
|
||||
<th>Address</th>
|
||||
<th>WIF</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(wallet.addresses?.receiving || []).map(addr => (
|
||||
<tr key={addr.index}>
|
||||
<td className="addr-index">{addr.index}</td>
|
||||
<td className="addr-path">{addr.path}</td>
|
||||
<td className="addr-mono">{addr.address}</td>
|
||||
<td className="addr-mono" style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{addr.private_key_wif}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
<CopyButton text={String(addr.address || '')} />
|
||||
<CopyButton text={String(addr.private_key_wif || '')} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleWalletView({ wallet }) {
|
||||
const isP2SH = Array.isArray(wallet?.participants)
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Wallet Data</div>
|
||||
{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]) => (
|
||||
<div className="kv-row" key={label}>
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className="kv-value">{value}</span>
|
||||
<span className="kv-actions"><CopyButton text={String(value || '')} /></span>
|
||||
</div>
|
||||
))}
|
||||
<hr className="divider" />
|
||||
<div className="card-title" style={{ marginTop: 4 }}>Participants</div>
|
||||
{wallet.participants.map((p, idx) => (
|
||||
<div className="participant-card" key={idx}>
|
||||
<div className="participant-title">Key {idx + 1}</div>
|
||||
{[
|
||||
['Public key', p.public_key_hex],
|
||||
['Private key (WIF)', p.private_key_wif],
|
||||
['Private key (hex)', p.private_key_hex],
|
||||
].map(([label, value]) => (
|
||||
<div className="kv-row" key={label} style={{ paddingTop: 4, paddingBottom: 4 }}>
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className="kv-value">{value}</span>
|
||||
<span className="kv-actions"><CopyButton text={String(value || '')} /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
buildSingleRows(wallet).map(([label, value]) => (
|
||||
<div className="kv-row" key={label}>
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className="kv-value">{value}</span>
|
||||
<span className="kv-actions"><CopyButton text={String(value || '')} /></span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -255,6 +255,10 @@ html, body, #root {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Key/value rows ───────────────────────────────────────────────────────── */
|
||||
.kv-row {
|
||||
display: flex;
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user