feat(android): add Capacitor APK build with pure-JS crypto layer
Introduces a platform abstraction so the React app runs on both
Electron (desktop) and Android (WebView via Capacitor) without any
Python backend:
- frontend/src/services/crypto-service.js: pure-JS reimplementation of
all address types (P2PKH, P2WPKH, P2TR, P2PK, P2SH multisig) and HD
wallet generation (BIP-32/39/44/49/84/86) using @noble/curves,
@noble/hashes, @scure/bip32, @scure/bip39, bech32, bs58.
AES-256-CBC encryption via Web Crypto API, Electrum-compatible key
derivation (SHA256(SHA256(password))).
- frontend/src/services/api.js: detects Electron (window.electronAPI)
vs Android (Capacitor Filesystem) at runtime; components are
unchanged in behaviour.
- frontend/src/components/{HDWallet,SingleAddress,WalletViewer}.jsx:
import api instead of window.electronAPI directly.
- frontend/package.json: @noble/*, @scure/*, bech32, bs58,
@capacitor/core, @capacitor/filesystem, @capacitor/cli,
@capacitor/android; cap:sync script.
- frontend/capacitor.config.json: appId com.walletgen.app, webDir dist.
- contrib/android/Dockerfile + build.sh: JDK 17, Android SDK 34,
cap add/sync, ./gradlew assembleDebug; outputs wallet-gen-debug.apk
This commit is contained in:
55
contrib/android/Dockerfile
Normal file
55
contrib/android/Dockerfile
Normal file
@@ -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
|
||||
26
contrib/android/build.sh
Executable file
26
contrib/android/build.sh
Executable file
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
150
frontend/src/services/api.js
Normal file
150
frontend/src/services/api.js
Normal file
@@ -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'
|
||||
},
|
||||
}
|
||||
423
frontend/src/services/crypto-service.js
Normal file
423
frontend/src/services/crypto-service.js
Normal file
@@ -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 <pub1>…<pubN> 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
|
||||
}
|
||||
Reference in New Issue
Block a user