- Resolve Android storage dir at runtime (Documents/Data) and reuse it for saves - List/read wallets across candidate dirs with dedupe by latest mtime - Validate wallet filenames to prevent invalid paths - Update UI hints for Desktop vs Android storage locations
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
import { useState } from 'react'
|
|
import CopyButton from './CopyButton'
|
|
import { api } from '../services/api.js'
|
|
|
|
const TABS = [
|
|
{ id: 'p2pk', label: 'P2PK', desc: 'Pay-to-PubKey' },
|
|
{ id: 'p2pkh', label: 'P2PKH', desc: 'Legacy' },
|
|
{ id: 'p2sh', label: 'P2SH', desc: 'Multisig' },
|
|
{ id: 'p2wpkh', label: 'P2WPKH', desc: 'Native SegWit' },
|
|
{ id: 'p2tr', label: 'P2TR', desc: 'Taproot' },
|
|
]
|
|
|
|
export default function SingleAddress({ initialTab = 'p2pkh' }) {
|
|
const [tab, setTab] = useState(initialTab)
|
|
const [network, setNetwork] = useState('mainnet')
|
|
const [compressed, setCompressed] = useState(true)
|
|
const [m, setM] = useState(2)
|
|
const [n, setN] = useState(3)
|
|
const [result, setResult] = useState(null)
|
|
const [loading, setLoading] = useState(false)
|
|
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)
|
|
|
|
const generate = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
setResult(null)
|
|
setSaveMsg(null)
|
|
try {
|
|
let args = { network }
|
|
if (tab === 'p2sh') args = { ...args, m, n, compressed }
|
|
else if (tab !== 'p2tr') args = { ...args, compressed }
|
|
|
|
const fn = api[tab]
|
|
const data = await fn(args)
|
|
setResult(data)
|
|
setFilename('')
|
|
setSavePassword('')
|
|
setSavePasswordConfirm('')
|
|
setShowSavePassword(false)
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const save = async () => {
|
|
if (!result) return
|
|
if (!filename.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 {
|
|
let data = result
|
|
if (savePassword.trim()) {
|
|
data = await api.singleEncrypt({ wallet: result, password: savePassword })
|
|
}
|
|
const name = filename.trim().endsWith('.json') ? filename.trim() : `${filename.trim()}.json`
|
|
const savedPath = await api.saveWallet(name, data, 'single')
|
|
setSaveMsg(savePassword.trim() ? `Saved (encrypted): ${savedPath}` : `Saved: ${savedPath}`)
|
|
} catch (e) {
|
|
setSaveMsg('Error: ' + e.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const currentTab = TABS.find(t => t.id === tab)
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-title">Single Addresses</div>
|
|
<div className="page-subtitle">Generate individual Bitcoin addresses and key pairs</div>
|
|
|
|
<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(''); setSavePasswordConfirm(''); setShowSavePassword(false) }}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-title">{currentTab.label} <span style={{ color: 'var(--text-xs)', fontWeight: 400 }}>— {currentTab.desc}</span></div>
|
|
|
|
<div className="form-grid" style={{ marginBottom: 14 }}>
|
|
<div className="form-group">
|
|
<label className="form-label">Network</label>
|
|
<select className="form-select" value={network} onChange={e => setNetwork(e.target.value)}>
|
|
<option value="mainnet">Mainnet</option>
|
|
<option value="testnet">Testnet</option>
|
|
<option value="regtest">Regtest</option>
|
|
</select>
|
|
</div>
|
|
|
|
{tab === 'p2sh' && (
|
|
<>
|
|
<div className="form-group">
|
|
<label className="form-label">Required signatures (m)</label>
|
|
<input className="form-input" type="number" min="1" max="16" value={m} onChange={e => setM(parseInt(e.target.value))} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Total keys (n)</label>
|
|
<input className="form-input" type="number" min="1" max="16" value={n} onChange={e => setN(parseInt(e.target.value))} />
|
|
<span className="form-hint">{m}-of-{n} multisig</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{tab !== 'p2tr' && (
|
|
<div className="checkbox-group" style={{ marginBottom: 14 }}>
|
|
<input type="checkbox" id="compressed" checked={compressed} onChange={e => setCompressed(e.target.checked)} />
|
|
<label htmlFor="compressed">Compressed public key (33 bytes)</label>
|
|
</div>
|
|
)}
|
|
|
|
<button className="btn btn-primary" onClick={generate} disabled={loading}>
|
|
{loading ? <><span className="spinner" /> Generating…</> : 'Generate'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
|
|
{result && (
|
|
<>
|
|
<ResultCard result={result} tab={tab} />
|
|
|
|
<div className="card">
|
|
<div className="card-title">Save</div>
|
|
<div className="form-grid save-form-grid" style={{ marginBottom: 12 }}>
|
|
<div className="form-group">
|
|
<label className="form-label">Filename</label>
|
|
<input
|
|
className="form-input"
|
|
placeholder="Required"
|
|
value={filename}
|
|
onChange={e => setFilename(e.target.value)}
|
|
/>
|
|
<span className="form-hint">.json · Desktop: ~/.wallet-generator/single/ · Android: app storage</span>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Encryption password (optional)</label>
|
|
<input
|
|
className="form-input"
|
|
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>
|
|
<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}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResultCard({ result, tab }) {
|
|
return (
|
|
<div className="card">
|
|
<div className="card-title">Result</div>
|
|
{tab === 'p2sh' ? (
|
|
<P2SHResult result={result} />
|
|
) : (
|
|
buildFields(result).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={value} /></span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function buildFields(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
|
|
}
|
|
|
|
function P2SHResult({ result }) {
|
|
return (
|
|
<>
|
|
{[
|
|
['Network', result.network],
|
|
['Script type', result.script_type],
|
|
['Configuration', `${result.m}-of-${result.n}`],
|
|
['Address', result.address],
|
|
['Redeem script', result.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={value} /></span>
|
|
</div>
|
|
))}
|
|
<hr className="divider" />
|
|
<div className="card-title" style={{ marginTop: 4 }}>Participants</div>
|
|
{result.participants.map((p, i) => (
|
|
<div className="participant-card" key={i}>
|
|
<div className="participant-title">Key {i + 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={value} /></span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
}
|