diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile new file mode 100644 index 0000000..5198d19 --- /dev/null +++ b/contrib/android/Dockerfile @@ -0,0 +1,55 @@ +FROM node:22.14.0-bookworm + +# ── System deps: JDK 17 + tooling ───────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + default-jdk \ + wget unzip \ + && rm -rf /var/lib/apt/lists/* + +# ── Android SDK command-line tools ──────────────────────────────────────────── +# https://developer.android.com/studio#command-line-tools-only +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}" + +RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \ + wget -q \ + "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \ + -O /tmp/cmdtools.zip && \ + unzip -q /tmp/cmdtools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools" && \ + mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" \ + "${ANDROID_SDK_ROOT}/cmdline-tools/latest" && \ + rm /tmp/cmdtools.zip + +# Accept licences and install required SDK packages +RUN yes | sdkmanager --licenses > /dev/null 2>&1 && \ + sdkmanager \ + "platforms;android-34" \ + "build-tools;34.0.0" \ + "platform-tools" + +WORKDIR /build + +# Copy repo +COPY . . + +# ── JS dependencies ─────────────────────────────────────────────────────────── +RUN cd frontend && npm ci + +# ── Vite production build (generates dist/) ─────────────────────────────────── +RUN cd frontend && npx vite build + +# ── Capacitor: generate android/ project and copy web assets ───────────────── +# cap init reads capacitor.config.json; cap add android generates the project; +# cap sync copies dist/ into android/app/src/main/assets/public/ +RUN cd frontend && \ + npx cap add android && \ + npx cap sync android + +# ── Build debug APK (self-signed with auto-generated debug keystore) ────────── +RUN cd frontend/android && \ + chmod +x gradlew && \ + ./gradlew assembleDebug --no-daemon + +# ── Export APK ──────────────────────────────────────────────────────────────── +CMD cp frontend/android/app/build/outputs/apk/debug/app-debug.apk \ + /out/wallet-gen-debug.apk diff --git a/contrib/android/build.sh b/contrib/android/build.sh new file mode 100755 index 0000000..75eb6df --- /dev/null +++ b/contrib/android/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build a debug Android APK using Docker. +# Usage: ./contrib/android/build.sh +# +# Output: release/wallet-gen-debug.apk +# +# To build a release APK instead, export ANDROID_KEYSTORE, ANDROID_KEY_ALIAS, +# ANDROID_STORE_PASSWORD, ANDROID_KEY_PASSWORD and change assembleDebug to +# assembleRelease in contrib/android/Dockerfile, then run zipalign + apksigner. +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd) +OUT_DIR="$REPO_ROOT/release" + +mkdir -p "$OUT_DIR" + +docker build -t wallet-gen-android-builder \ + -f "$REPO_ROOT/contrib/android/Dockerfile" \ + "$REPO_ROOT" + +docker run --rm \ + -v "$OUT_DIR:/out" \ + wallet-gen-android-builder + +echo "APK saved to: $OUT_DIR/wallet-gen-debug.apk" +ls -lh "$OUT_DIR/wallet-gen-debug.apk" diff --git a/frontend/package.json b/frontend/package.json index d934dc5..b8d673d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "dev": "concurrently --kill-others -n Vite,Electron \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development ELECTRON_RUN_AS_NODE= electron .\"", "build": "vite build", "preview": "vite preview", - "dist": "vite build && electron-builder" + "dist": "vite build && electron-builder", + "cap:sync": "vite build && cap sync android" }, "build": { "appId": "com.walletgen.app", @@ -31,10 +32,20 @@ } }, "dependencies": { + "@capacitor/core": "^7.0.0", + "@capacitor/filesystem": "^7.0.0", + "@noble/curves": "^1.8.0", + "@noble/hashes": "^1.7.0", + "@scure/bip32": "^1.6.0", + "@scure/bip39": "^1.5.0", + "bech32": "^2.0.0", + "bs58": "^6.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, "devDependencies": { + "@capacitor/android": "^7.0.0", + "@capacitor/cli": "^7.0.0", "@eslint/js": "^9.39.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/components/HDWallet.jsx b/frontend/src/components/HDWallet.jsx index 0b42974..331407d 100644 --- a/frontend/src/components/HDWallet.jsx +++ b/frontend/src/components/HDWallet.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' import CopyButton from './CopyButton' +import { api } from '../services/api.js' export default function HDWallet() { const [form, setForm] = useState({ @@ -34,7 +35,7 @@ export default function HDWallet() { setSavePasswordConfirm('') setShowSavePassword(false) try { - const data = await window.electronAPI.hdGenerate({ + const data = await api.hdGenerate({ network: form.network, bip_type: form.bip_type, mnemonic: form.mnemonic.trim() || null, @@ -73,12 +74,12 @@ export default function HDWallet() { try { let data = wallet if (savePassword.trim()) { - data = await window.electronAPI.hdEncrypt({ wallet, password: savePassword }) + data = await api.hdEncrypt({ wallet, password: savePassword }) } const filename = saveFilename.trim().endsWith('.json') ? saveFilename.trim() : `${saveFilename.trim()}.json` - const savedPath = await window.electronAPI.saveWallet(filename, data, 'hd') + const savedPath = await api.saveWallet(filename, data, 'hd') const encrypted = savePassword ? ' (encrypted)' : '' setSaveMsg(`Saved${encrypted}: ${savedPath}`) } catch (e) { diff --git a/frontend/src/components/SingleAddress.jsx b/frontend/src/components/SingleAddress.jsx index 63fe803..c92f651 100644 --- a/frontend/src/components/SingleAddress.jsx +++ b/frontend/src/components/SingleAddress.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' import CopyButton from './CopyButton' +import { api } from '../services/api.js' const TABS = [ { id: 'p2pk', label: 'P2PK', desc: 'Pay-to-PubKey' }, @@ -35,7 +36,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { if (tab === 'p2sh') args = { ...args, m, n, compressed } else if (tab !== 'p2tr') args = { ...args, compressed } - const fn = window.electronAPI[tab] + const fn = api[tab] const data = await fn(args) setResult(data) setFilename('') @@ -71,10 +72,10 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { try { let data = result if (savePassword.trim()) { - data = await window.electronAPI.singleEncrypt({ wallet: result, password: savePassword }) + data = await api.singleEncrypt({ wallet: result, password: savePassword }) } const name = filename.trim().endsWith('.json') ? filename.trim() : `${filename.trim()}.json` - const savedPath = await window.electronAPI.saveWallet(name, data, 'single') + const savedPath = await api.saveWallet(name, data, 'single') setSaveMsg(savePassword.trim() ? `Saved (encrypted): ${savedPath}` : `Saved: ${savedPath}`) } catch (e) { setSaveMsg('Error: ' + e.message) diff --git a/frontend/src/components/WalletViewer.jsx b/frontend/src/components/WalletViewer.jsx index 75f23a8..40aca9d 100644 --- a/frontend/src/components/WalletViewer.jsx +++ b/frontend/src/components/WalletViewer.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import CopyButton from './CopyButton' +import { api } from '../services/api.js' function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B` @@ -46,7 +47,7 @@ export default function WalletViewer() { setLoadingFiles(true) setError(null) try { - const data = await window.electronAPI.listWallets() + const data = await api.listWallets() setFiles({ hd: data.hd || [], single: data.single || [] }) } catch (e) { setError(e.message) @@ -68,7 +69,7 @@ export default function WalletViewer() { setWallet(null) setSelected({ kind, filename }) try { - const data = await window.electronAPI.readWallet({ kind, filename }) + const data = await api.readWallet({ kind, filename }) setWallet(data) if (!data?.use_encryption) { setDecryptedWallet(data) @@ -85,7 +86,7 @@ export default function WalletViewer() { setLoadingDecrypt(true) setError(null) try { - const fn = selected.kind === 'hd' ? window.electronAPI.hdDecrypt : window.electronAPI.singleDecrypt + const fn = selected.kind === 'hd' ? api.hdDecrypt : api.singleDecrypt const data = await fn({ wallet, password }) setDecryptedWallet(data) } catch (e) { diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..0fe592f --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,150 @@ +/** + * Platform API — single import for all components. + * + * On Electron: delegates to window.electronAPI (IPC, preload.cjs). + * On Android (Capacitor) or browser: uses crypto-service.js + Capacitor Filesystem. + */ + +import { + generateHdWallet, encryptHdWallet, decryptHdWallet, + generateP2pk, generateP2pkh, generateP2sh, generateP2wpkh, generateP2tr, + encryptSingleWallet, decryptSingleWallet, +} from './crypto-service.js' + +// Electron exposes window.electronAPI via preload.cjs before React loads. +const isElectron = typeof window !== 'undefined' && window.electronAPI != null + +// ── Android storage (Capacitor Filesystem) ───────────────────────────────── + +const WALLET_DIRS = { + hd: 'wallet-generator/hdwallet', + single: 'wallet-generator/single', +} + +let _fs = null +async function fs() { + if (!_fs) { + const { Filesystem, Directory } = await import('@capacitor/filesystem') + _fs = { Filesystem, Directory } + } + return _fs +} + +async function androidSaveWallet(filename, data, kind) { + const { Filesystem, Directory } = await fs() + const path = `${WALLET_DIRS[kind]}/${filename}` + await Filesystem.mkdir({ path: WALLET_DIRS[kind], directory: Directory.Documents, recursive: true }) + await Filesystem.writeFile({ + path, + data: JSON.stringify(data, null, 2), + directory: Directory.Documents, + encoding: 'utf8', + }) + return path +} + +async function androidListWallets() { + const { Filesystem, Directory } = await fs() + const out = { hd: [], single: [] } + for (const [kind, dir] of Object.entries(WALLET_DIRS)) { + try { + await Filesystem.mkdir({ path: dir, directory: Directory.Documents, recursive: true }) + const { files } = await Filesystem.readdir({ path: dir, directory: Directory.Documents }) + out[kind] = files + .filter(f => (f.type === 'file' || !f.type) && f.name.toLowerCase().endsWith('.json')) + .map(f => ({ + name: f.name, + size: f.size || 0, + mtimeMs: f.mtime ? (typeof f.mtime === 'number' ? f.mtime : Date.parse(f.mtime)) : 0, + })) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + } catch { + out[kind] = [] + } + } + return out +} + +async function androidReadWallet(kind, filename) { + const { Filesystem, Directory } = await fs() + const { data } = await Filesystem.readFile({ + path: `${WALLET_DIRS[kind]}/${filename}`, + directory: Directory.Documents, + encoding: 'utf8', + }) + return JSON.parse(data) +} + +// ── Exported API ───────────────────────────────────────────────────────────── + +export const api = { + hdGenerate: async (args) => { + if (isElectron) return window.electronAPI.hdGenerate(args) + return generateHdWallet(args) + }, + + hdEncrypt: async ({ wallet, password }) => { + if (isElectron) return window.electronAPI.hdEncrypt({ wallet, password }) + return encryptHdWallet(wallet, password) + }, + + hdDecrypt: async ({ wallet, password }) => { + if (isElectron) return window.electronAPI.hdDecrypt({ wallet, password }) + return decryptHdWallet(wallet, password) + }, + + p2pk: async (args) => { + if (isElectron) return window.electronAPI.p2pk(args) + return generateP2pk(args.network, args.compressed) + }, + + p2pkh: async (args) => { + if (isElectron) return window.electronAPI.p2pkh(args) + return generateP2pkh(args.network, args.compressed) + }, + + p2sh: async (args) => { + if (isElectron) return window.electronAPI.p2sh(args) + return generateP2sh(args.network, args.m, args.n, args.compressed) + }, + + p2wpkh: async (args) => { + if (isElectron) return window.electronAPI.p2wpkh(args) + return generateP2wpkh(args.network, args.compressed) + }, + + p2tr: async (args) => { + if (isElectron) return window.electronAPI.p2tr(args) + return generateP2tr(args.network) + }, + + singleEncrypt: async ({ wallet, password }) => { + if (isElectron) return window.electronAPI.singleEncrypt({ wallet, password }) + return encryptSingleWallet(wallet, password) + }, + + singleDecrypt: async ({ wallet, password }) => { + if (isElectron) return window.electronAPI.singleDecrypt({ wallet, password }) + return decryptSingleWallet(wallet, password) + }, + + saveWallet: async (filename, data, kind) => { + if (isElectron) return window.electronAPI.saveWallet(filename, data, kind) + return androidSaveWallet(filename, data, kind) + }, + + listWallets: async () => { + if (isElectron) return window.electronAPI.listWallets() + return androidListWallets() + }, + + readWallet: async ({ kind, filename }) => { + if (isElectron) return window.electronAPI.readWallet({ kind, filename }) + return androidReadWallet(kind, filename) + }, + + getWalletDir: async () => { + if (isElectron) return window.electronAPI.getWalletDir() + return 'Documents/wallet-generator' + }, +} diff --git a/frontend/src/services/crypto-service.js b/frontend/src/services/crypto-service.js new file mode 100644 index 0000000..f439df2 --- /dev/null +++ b/frontend/src/services/crypto-service.js @@ -0,0 +1,423 @@ +/** + * Pure-JS Bitcoin crypto — runs in any modern WebView (Electron or Android). + * + * Encryption: AES-256-CBC via Web Crypto API (PKCS7 padding, random IV, key = + * SHA256(SHA256(password_utf8)) — Electrum-compatible). + * Address generation mirrors src/p2pkh.py, p2wpkh.py, p2tr.py, p2pk.py, p2sh.py, + * hd_wallet.py exactly. + */ + +import { secp256k1 } from '@noble/curves/secp256k1' +import { sha256 } from '@noble/hashes/sha256' +import { ripemd160 } from '@noble/hashes/ripemd160' +import { HDKey } from '@scure/bip32' +import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39' +import { wordlist } from '@scure/bip39/wordlists/english' +import bs58 from 'bs58' +import { bech32, bech32m } from 'bech32' + +// ── Utilities ──────────────────────────────────────────────────────────────── + +const bytesToHex = b => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('') +const hexToBytes = h => Uint8Array.from(h.match(/.{2}/g).map(b => parseInt(b, 16))) + +function bigIntToBytes32(n) { + const bytes = new Uint8Array(32) + let v = n + for (let i = 31; i >= 0; i--) { + bytes[i] = Number(v & 0xffn) + v >>= 8n + } + return bytes +} + +function hash160(data) { + return ripemd160(sha256(data)) +} + +function base58check(payload) { + const checksum = sha256(sha256(payload)).slice(0, 4) + const full = new Uint8Array(payload.length + 4) + full.set(payload) + full.set(checksum, payload.length) + return bs58.encode(full) +} + +function toWif(privKey, wifVersion, compressed) { + const ext = new Uint8Array(compressed ? 34 : 33) + ext[0] = wifVersion + ext.set(privKey, 1) + if (compressed) ext[33] = 0x01 + return base58check(ext) +} + +// ── AES-256-CBC (Web Crypto — Electrum key derivation) ────────────────────── + +function deriveKeyBytes(password) { + const passBytes = new TextEncoder().encode(password) + return sha256(sha256(passBytes)) +} + +export async function pwEncode(plaintext, password) { + const keyBytes = deriveKeyBytes(password) + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['encrypt']) + const iv = crypto.getRandomValues(new Uint8Array(16)) + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-CBC', iv }, + key, + new TextEncoder().encode(plaintext), + ) + const combined = new Uint8Array(16 + ciphertext.byteLength) + combined.set(iv) + combined.set(new Uint8Array(ciphertext), 16) + // btoa handles up to 64 KB; for larger payloads use TextDecoder workaround + return btoa(String.fromCharCode(...combined)) +} + +export async function pwDecode(b64, password) { + try { + const combined = Uint8Array.from(atob(b64), c => c.charCodeAt(0)) + if (combined.length < 32) throw new Error('too short') + const iv = combined.slice(0, 16) + const ciphertext = combined.slice(16) + const keyBytes = deriveKeyBytes(password) + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['decrypt']) + const plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, ciphertext) + return new TextDecoder().decode(plaintext) + } catch { + throw new Error('Incorrect password or corrupted data.') + } +} + +// ── Tagged hash (BIP-340) ───────────────────────────────────────────────────── + +function taggedHash(tag, msg) { + const tagHash = sha256(new TextEncoder().encode(tag)) + const data = new Uint8Array(tagHash.length * 2 + msg.length) + data.set(tagHash, 0) + data.set(tagHash, tagHash.length) + data.set(msg, tagHash.length * 2) + return sha256(data) +} + +// Apply BIP-341 TapTweak to a ProjectivePoint P, return tweaked x-only (32 bytes) +function tapTweakPoint(P, xonly) { + const tweakHash = taggedHash('TapTweak', xonly) + const t = BigInt('0x' + bytesToHex(tweakHash)) % secp256k1.CURVE.n + const Q = P.add(secp256k1.ProjectivePoint.BASE.multiply(t)) + return bigIntToBytes32(Q.toAffine().x) +} + +// ── Network configs ─────────────────────────────────────────────────────────── + +const NET = { + p2pkh: { + mainnet: { addrV: 0x00, wifV: 0x80 }, + testnet: { addrV: 0x6f, wifV: 0xef }, + regtest: { addrV: 0x6f, wifV: 0xef }, + }, + p2wpkh: { + mainnet: { hrp: 'bc', wifV: 0x80 }, + testnet: { hrp: 'tb', wifV: 0xef }, + regtest: { hrp: 'bcrt', wifV: 0xef }, + }, + p2tr: { + mainnet: { hrp: 'bc', wifV: 0x80 }, + testnet: { hrp: 'tb', wifV: 0xef }, + regtest: { hrp: 'bcrt', wifV: 0xef }, + }, + p2sh: { + mainnet: { p2shV: 0x05, wifV: 0x80 }, + testnet: { p2shV: 0xc4, wifV: 0xef }, + regtest: { p2shV: 0xc4, wifV: 0xef }, + }, +} + +// ── Single-address generators ───────────────────────────────────────────────── + +export function generateP2pkh(network = 'mainnet', compressed = true) { + const cfg = NET.p2pkh[network] + if (!cfg) throw new Error(`Unsupported network: ${network}`) + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const pubKey = secp256k1.getPublicKey(privKey, compressed) + const h160 = hash160(pubKey) + return { + network, + script_type: 'p2pkh', + private_key_hex: bytesToHex(privKey), + private_key_wif: toWif(privKey, cfg.wifV, compressed), + public_key_hex: bytesToHex(pubKey), + address: base58check(new Uint8Array([cfg.addrV, ...h160])), + } +} + +export function generateP2wpkh(network = 'mainnet', compressed = true) { + const cfg = NET.p2wpkh[network] + if (!cfg) throw new Error(`Unsupported network: ${network}`) + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const pubKey = secp256k1.getPublicKey(privKey, compressed) + const h160 = hash160(pubKey) + const address = bech32.encode(cfg.hrp, [0, ...bech32.toWords(h160)]) + return { + network, + script_type: 'p2wpkh', + private_key_hex: bytesToHex(privKey), + private_key_wif: toWif(privKey, cfg.wifV, compressed), + public_key_hex: bytesToHex(pubKey), + address, + } +} + +export function generateP2tr(network = 'mainnet') { + const cfg = NET.p2tr[network] + if (!cfg) throw new Error(`Unsupported network: ${network}`) + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const P = secp256k1.ProjectivePoint.fromPrivateKey(privKey) + const xonly = bigIntToBytes32(P.toAffine().x) // internal pubkey x + const tweaked = tapTweakPoint(P, xonly) // tweaked pubkey x + const address = bech32m.encode(cfg.hrp, [1, ...bech32m.toWords(tweaked)]) + return { + network, + script_type: 'p2tr', + private_key_hex: bytesToHex(privKey), + private_key_wif: toWif(privKey, cfg.wifV, true), + internal_pubkey_x_hex: bytesToHex(xonly), + address, + } +} + +export function generateP2pk(network = 'mainnet', compressed = false) { + const cfg = NET.p2pkh[network] // same WIF prefix as P2PKH + if (!cfg) throw new Error(`Unsupported network: ${network}`) + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const pubKey = secp256k1.getPublicKey(privKey, compressed) + return { + network, + script_type: 'p2pk', + private_key_hex: bytesToHex(privKey), + private_key_wif: toWif(privKey, cfg.wifV, compressed), + public_key_hex: bytesToHex(pubKey), + } +} + +export function generateP2sh(network = 'mainnet', m = 2, n = 3, compressed = true) { + const cfg = NET.p2sh[network] + if (!cfg) throw new Error(`Unsupported network: ${network}`) + if (!(1 <= m && m <= n && n <= 16)) throw new Error('Invalid m/n parameters (1 <= m <= n <= 16).') + + const participants = [] + const pubkeysList = [] + for (let i = 0; i < n; i++) { + const privKey = crypto.getRandomValues(new Uint8Array(32)) + const pubKey = secp256k1.getPublicKey(privKey, compressed) + participants.push({ + private_key_hex: bytesToHex(privKey), + private_key_wif: toWif(privKey, cfg.wifV, compressed), + public_key_hex: bytesToHex(pubKey), + }) + pubkeysList.push(pubKey) + } + + // BIP67: sort pubkeys lexicographically + pubkeysList.sort((a, b) => { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return a.length - b.length + }) + + // redeemScript: OP_m OP_n OP_CHECKMULTISIG + const parts = [0x50 + m] + for (const pk of pubkeysList) { + parts.push(pk.length, ...pk) + } + parts.push(0x50 + n, 0xae) + const redeemScript = new Uint8Array(parts) + const scriptHash = hash160(redeemScript) + return { + network, + script_type: 'p2sh-multisig', + m, + n, + redeem_script_hex: bytesToHex(redeemScript), + participants, + address: base58check(new Uint8Array([cfg.p2shV, ...scriptHash])), + } +} + +// ── HD wallet ───────────────────────────────────────────────────────────────── + +const HD_PURPOSES = { bip44: 44, bip49: 49, bip84: 84, bip86: 86 } +const HD_ENTROPY = { 12: 128, 15: 160, 18: 192, 21: 224, 24: 256 } + +// BIP32 version bytes (mainnet xpub/xprv, testnet tpub/tprv) +const HD_VERSIONS = { + mainnet: { public: 0x0488b21e, private: 0x0488ade4 }, + testnet: { public: 0x043587cf, private: 0x04358394 }, +} + +const HD_NET = { + mainnet: { coinType: 0, addrV: 0x00, p2shV: 0x05, hrp: 'bc', wifV: 0x80 }, + testnet: { coinType: 1, addrV: 0x6f, p2shV: 0xc4, hrp: 'tb', wifV: 0xef }, +} + +function hdAddress(pubKey, bipType, netCfg) { + if (bipType === 'bip44') { + const h160 = hash160(pubKey) + return base58check(new Uint8Array([netCfg.addrV, ...h160])) + } + if (bipType === 'bip49') { + const h160 = hash160(pubKey) + const redeemScript = new Uint8Array([0x00, 0x14, ...h160]) + const scriptHash = hash160(redeemScript) + return base58check(new Uint8Array([netCfg.p2shV, ...scriptHash])) + } + if (bipType === 'bip84') { + const h160 = hash160(pubKey) + return bech32.encode(netCfg.hrp, [0, ...bech32.toWords(h160)]) + } + if (bipType === 'bip86') { + // x-only from compressed pubkey; use even-y lift for TapTweak (BIP-341) + const xonly = pubKey.slice(1) + const P = secp256k1.ProjectivePoint.fromHex('02' + bytesToHex(xonly)) + const tweaked = tapTweakPoint(P, xonly) + return bech32m.encode(netCfg.hrp, [1, ...bech32m.toWords(tweaked)]) + } + throw new Error(`Unknown bip_type: ${bipType}`) +} + +export function generateHdWallet({ + network = 'mainnet', + bip_type = 'bip84', + mnemonic = null, + passphrase = '', + account = 0, + num_addresses = 5, + words_num = 12, +} = {}) { + const netCfg = HD_NET[network] + if (!netCfg) throw new Error(`Unsupported network: '${network}'. Choose 'mainnet' or 'testnet'.`) + if (!HD_PURPOSES[bip_type]) throw new Error(`Unsupported BIP type: '${bip_type}'.`) + + const purpose = HD_PURPOSES[bip_type] + const coinType = netCfg.coinType + const accountPath = `m/${purpose}'/${coinType}'/${account}'` + const entropyBits = HD_ENTROPY[words_num] ?? 128 + const mnemonicStr = mnemonic ?? generateMnemonic(wordlist, entropyBits) + + const seed = mnemonicToSeedSync(mnemonicStr, passphrase) + const root = HDKey.fromMasterSeed(seed, HD_VERSIONS[network]) + const rootFp = root.fingerprint.toString(16).padStart(8, '0') + const accountKey = root.derive(accountPath) + const changeKey = accountKey.deriveChild(0) // external chain (receiving) + + const receiving = [] + for (let i = 0; i < num_addresses; i++) { + const child = changeKey.deriveChild(i) + receiving.push({ + index: i, + path: `${accountPath}/0/${i}`, + address: hdAddress(child.publicKey, bip_type, netCfg), + public_key: bytesToHex(child.publicKey), + private_key_wif: toWif(child.privateKey, netCfg.wifV, true), + private_key_hex: bytesToHex(child.privateKey), + }) + } + + return { + keystore: { + type: 'bip32', + xpub: accountKey.publicExtendedKey, + xprv: accountKey.privateExtendedKey, + seed: mnemonicStr, + passphrase, + derivation: accountPath, + root_fingerprint: rootFp, + }, + wallet_type: 'standard', + use_encryption: false, + seed_type: 'bip39', + seed_version: 17, + addresses: { receiving, change: [] }, + } +} + +// ── HD wallet encrypt / decrypt ─────────────────────────────────────────────── + +export async function encryptHdWallet(wallet, password) { + if (!password) throw new Error('Password is required for encryption.') + const w = JSON.parse(JSON.stringify(wallet)) + const ks = w.keystore + ks.seed = await pwEncode(ks.seed, password) + ks.xprv = await pwEncode(ks.xprv, password) + ks.passphrase = await pwEncode(ks.passphrase, password) + for (const addr of w.addresses.receiving) { + addr.private_key_hex = await pwEncode(addr.private_key_hex, password) + addr.private_key_wif = await pwEncode(addr.private_key_wif, password) + } + w.use_encryption = true + return w +} + +export async function decryptHdWallet(wallet, password) { + if (!wallet.use_encryption) return JSON.parse(JSON.stringify(wallet)) + if (!password) throw new Error('Password is required for decryption.') + const w = JSON.parse(JSON.stringify(wallet)) + const ks = w.keystore + ks.seed = await pwDecode(ks.seed, password) + ks.xprv = await pwDecode(ks.xprv, password) + ks.passphrase = await pwDecode(ks.passphrase, password) + for (const addr of w.addresses.receiving) { + addr.private_key_hex = await pwDecode(addr.private_key_hex, password) + addr.private_key_wif = await pwDecode(addr.private_key_wif, password) + } + w.use_encryption = false + return w +} + +// ── Single wallet encrypt / decrypt (mirrors src/single_wallet.py) ──────────── + +const SENSITIVE = new Set(['private_key_hex', 'private_key_wif']) + +async function encryptRecursive(obj, password) { + if (Array.isArray(obj)) return Promise.all(obj.map(v => encryptRecursive(v, password))) + if (obj !== null && typeof obj === 'object') { + const out = {} + for (const [k, v] of Object.entries(obj)) { + out[k] = (SENSITIVE.has(k) && typeof v === 'string') + ? await pwEncode(v, password) + : await encryptRecursive(v, password) + } + return out + } + return obj +} + +async function decryptRecursive(obj, password) { + if (Array.isArray(obj)) return Promise.all(obj.map(v => decryptRecursive(v, password))) + if (obj !== null && typeof obj === 'object') { + const out = {} + for (const [k, v] of Object.entries(obj)) { + out[k] = (SENSITIVE.has(k) && typeof v === 'string') + ? await pwDecode(v, password) + : await decryptRecursive(v, password) + } + return out + } + return obj +} + +export async function encryptSingleWallet(wallet, password) { + if (!password) throw new Error('Password is required for encryption.') + const encrypted = await encryptRecursive(JSON.parse(JSON.stringify(wallet)), password) + encrypted.use_encryption = true + return encrypted +} + +export async function decryptSingleWallet(wallet, password) { + if (!wallet.use_encryption) return JSON.parse(JSON.stringify(wallet)) + if (!password) throw new Error('Password is required for decryption.') + const decrypted = await decryptRecursive(JSON.parse(JSON.stringify(wallet)), password) + decrypted.use_encryption = false + return decrypted +}