Compare commits

4 Commits

Author SHA1 Message Date
5a390841f4 feat(dev): add Android live-reload workflow with SDK autodetection
- Add root dev:android script delegating to frontend
- Add Android dev scripts and wrapper to resolve ANDROID_SDK_ROOT/ANDROID_HOME (Linux/WSL)
- Keep Vite running when Android run fails and document troubleshooting in frontend README
- Ignore generated frontend/android project in git
2026-03-10 16:02:59 +01:00
079b650d60 fix(android-storage): add writable directory fallback for wallet JSON files
- 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
2026-03-10 15:35:46 +01:00
7b2ff5445b fix(android): make Docker APK build pass with Capacitor config, synced lockfile, and JDK 21
- Track frontend/capacitor.config.json with appId/appName/webDir for Capacitor Android setup
- Unignore frontend/capacitor.config.json so config is committed with the project
- Update Android Docker builder to use JDK 21 (required by current Capacitor/Gradle toolchain)
- Switch Docker CMD to JSON-array form for safer signal handling
- Refresh frontend package-lock.json to match Capacitor and crypto dependencies required by npm ci
2026-03-10 14:37:58 +01:00
e785179ee4 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
2026-03-10 14:09:58 +01:00
14 changed files with 1895 additions and 27 deletions

2
.gitignore vendored
View File

@@ -15,10 +15,12 @@ __pycache__/
!package-lock.json
!frontend/package.json
!frontend/package-lock.json
!frontend/capacitor.config.json
# Frontend build output and dependencies
node_modules/
frontend/node_modules/
frontend/dist/
frontend/android/
frontend/release/
release/

View File

@@ -0,0 +1,59 @@
FROM node:22.14.0-bookworm
# ── System deps + JDK 21 (required by current Capacitor Android toolchain) ───
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget unzip \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/opt/java/openjdk
RUN mkdir -p "${JAVA_HOME}" && \
wget -qO- "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" \
| tar -xz -C "${JAVA_HOME}" --strip-components=1
# ── Android SDK command-line tools ────────────────────────────────────────────
# https://developer.android.com/studio#command-line-tools-only
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH="${JAVA_HOME}/bin:${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
View 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"

View File

@@ -1,16 +1,69 @@
# React + Vite
# Frontend (Electron + Android)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Prerequisites
Currently, two official plugins are available:
- Node.js and npm
- Android SDK + `adb` + an emulator or connected device (for Android dev mode)
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Install dependencies
## React Compiler
From repository root:
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
```bash
cd frontend
npm ci
```
## Expanding the ESLint configuration
## Development
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
From repository root:
```bash
npm run dev
```
This starts desktop development mode (Vite + Electron).
From repository root:
```bash
npm run dev:android
```
This starts Android live-reload development mode:
- Ensures `frontend/android` exists (`cap add android` on first run only)
- Starts Vite on `0.0.0.0:5173`
- Runs `cap run android -l --no-sync --port 5173 --forwardPorts 5173:5173`
## Troubleshooting (Android SDK)
If you get:
```text
ERR_SDK_NOT_FOUND: No valid Android SDK root found.
```
set one of these environment variables and run again:
```bash
export ANDROID_SDK_ROOT="/path/to/Android/Sdk"
# or
export ANDROID_HOME="/path/to/Android/Sdk"
```
Typical locations:
- Linux: `$HOME/Android/Sdk`
- WSL + Android Studio on Windows: `/mnt/c/Users/<windows-user>/AppData/Local/Android/Sdk`
## Other scripts
From `frontend/`:
```bash
npm run build
npm run preview
npm run cap:sync
npm run dist
```

View File

@@ -0,0 +1,6 @@
{
"appId": "com.walletgen.app",
"appName": "wallet-gen",
"webDir": "dist",
"bundledWebRuntime": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,16 @@
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"vite": "vite",
"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"
"vite": "vite",
"dev": "concurrently --kill-others -n Vite,Electron \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development ELECTRON_RUN_AS_NODE= electron .\"",
"android:ensure": "sh -c '[ -d android ] || cap add android'",
"dev:web:android": "vite --host 0.0.0.0 --port 5173",
"dev:android:run": "wait-on tcp:5173 && sh ./scripts/cap-run-android-live.sh",
"dev:android": "npm run android:ensure && concurrently -n Vite,Android \"npm run dev:web:android\" \"npm run dev:android:run\"",
"build": "vite build",
"preview": "vite preview",
"dist": "vite build && electron-builder",
"cap:sync": "vite build && cap sync android"
},
"build": {
"appId": "com.walletgen.app",
@@ -31,10 +36,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",

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env sh
set -eu
find_sdk() {
if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
printf '%s' "${ANDROID_SDK_ROOT}"
return 0
fi
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
printf '%s' "${ANDROID_HOME}"
return 0
fi
if [ -d "${HOME}/Android/Sdk" ]; then
printf '%s' "${HOME}/Android/Sdk"
return 0
fi
for candidate in /mnt/c/Users/*/AppData/Local/Android/Sdk; do
if [ -d "${candidate}" ]; then
printf '%s' "${candidate}"
return 0
fi
done
return 1
}
if ! SDK_PATH="$(find_sdk)"; then
echo "ERROR: Android SDK non trovato."
echo
echo "Imposta una delle due variabili e riprova:"
echo " export ANDROID_SDK_ROOT=\"/path/to/Android/Sdk\""
echo " export ANDROID_HOME=\"/path/to/Android/Sdk\""
echo
echo "Percorsi tipici:"
echo " Linux: \$HOME/Android/Sdk"
echo " WSL: /mnt/c/Users/<utente>/AppData/Local/Android/Sdk"
exit 1
fi
export ANDROID_SDK_ROOT="${SDK_PATH}"
export ANDROID_HOME="${ANDROID_HOME:-${SDK_PATH}}"
export PATH="${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${PATH}"
exec cap run android -l --no-sync --port 5173 --forwardPorts 5173:5173

View File

@@ -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) {
@@ -229,7 +230,7 @@ export default function HDWallet() {
value={saveFilename}
onChange={e => setSaveFilename(e.target.value)}
/>
<span className="form-hint">.json · saved to ~/.wallet-generator/hdwallet/</span>
<span className="form-hint">.json · Desktop: ~/.wallet-generator/hdwallet/ · Android: app storage</span>
</div>
<div className="form-group">
<label className="form-label">Encryption password (optional)</label>

View File

@@ -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)
@@ -155,7 +156,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
value={filename}
onChange={e => setFilename(e.target.value)}
/>
<span className="form-hint">.json · saved to ~/.wallet-generator/single/</span>
<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>

View File

@@ -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) {

View File

@@ -0,0 +1,245 @@
/**
* 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
let _storageDirectory = null
async function fs() {
if (!_fs) {
const { Filesystem, Directory } = await import('@capacitor/filesystem')
_fs = { Filesystem, Directory }
}
return _fs
}
function walletDir(kind) {
const dir = WALLET_DIRS[kind]
if (!dir) throw new Error(`Unsupported wallet kind: '${kind}'.`)
return dir
}
function normalizeFilename(filename) {
if (typeof filename !== 'string') throw new Error('Filename must be a string.')
const trimmed = filename.trim()
if (!trimmed) throw new Error('Filename is required.')
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed === '.' || trimmed === '..') {
throw new Error('Invalid filename.')
}
return trimmed
}
function unique(arr) {
return [...new Set(arr.filter(Boolean))]
}
function toMtime(value) {
if (!value) return 0
if (typeof value === 'number') return value
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? 0 : parsed
}
function isMissingFileError(err) {
const msg = String(err?.message || err || '').toLowerCase()
return (
msg.includes('not found') ||
msg.includes('does not exist') ||
msg.includes('no such file') ||
msg.includes('directory at')
)
}
function directoryLabel(directory, Directory) {
if (directory === Directory.Documents) return 'Documents'
if (directory === Directory.Data) return 'Data'
return 'Storage'
}
async function directoryCandidates() {
const { Directory } = await fs()
return unique([Directory.Documents, Directory.Data])
}
async function resolveWritableDirectory() {
if (_storageDirectory) return _storageDirectory
const { Filesystem } = await fs()
let lastErr = null
for (const directory of await directoryCandidates()) {
try {
await Filesystem.mkdir({ path: 'wallet-generator', directory, recursive: true })
_storageDirectory = directory
return directory
} catch (err) {
lastErr = err
}
}
throw lastErr || new Error('No writable storage directory available.')
}
function orderedDirectories(preferred, all) {
if (!preferred) return all
return [preferred, ...all.filter(d => d !== preferred)]
}
async function androidSaveWallet(filename, data, kind) {
const { Filesystem, Directory } = await fs()
const safeName = normalizeFilename(filename)
const dirPath = walletDir(kind)
const path = `${dirPath}/${safeName}`
const directory = await resolveWritableDirectory()
await Filesystem.mkdir({ path: dirPath, directory, recursive: true })
await Filesystem.writeFile({
path,
data: JSON.stringify(data, null, 2),
directory,
encoding: 'utf8',
})
return `${directoryLabel(directory, Directory)}/${path}`
}
async function androidListWallets() {
const { Filesystem } = await fs()
const out = { hd: [], single: [] }
const preferred = await resolveWritableDirectory().catch(() => null)
const dirs = orderedDirectories(preferred, await directoryCandidates())
for (const [kind, dir] of Object.entries(WALLET_DIRS)) {
const byName = new Map()
try {
for (const directory of dirs) {
try {
await Filesystem.mkdir({ path: dir, directory, recursive: true })
const { files = [] } = await Filesystem.readdir({ path: dir, directory })
files
.filter(f => (f.type === 'file' || !f.type) && f.name?.toLowerCase?.().endsWith('.json'))
.forEach(f => {
const item = {
name: f.name,
size: f.size || 0,
mtimeMs: toMtime(f.mtime),
}
const prev = byName.get(item.name)
if (!prev || item.mtimeMs > prev.mtimeMs) byName.set(item.name, item)
})
} catch {
// Try next storage directory.
}
}
} finally {
out[kind] = Array.from(byName.values()).sort((a, b) => b.mtimeMs - a.mtimeMs)
}
}
return out
}
async function androidReadWallet(kind, filename) {
const { Filesystem } = await fs()
const path = `${walletDir(kind)}/${normalizeFilename(filename)}`
const preferred = await resolveWritableDirectory().catch(() => null)
const dirs = orderedDirectories(preferred, await directoryCandidates())
let lastErr = null
for (const directory of dirs) {
try {
const { data } = await Filesystem.readFile({ path, directory, encoding: 'utf8' })
return JSON.parse(data)
} catch (err) {
lastErr = err
if (!isMissingFileError(err)) throw err
}
}
throw lastErr || new Error('Wallet file not found.')
}
// ── 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()
const { Directory } = await fs()
const directory = await resolveWritableDirectory()
return `${directoryLabel(directory, Directory)}/wallet-generator`
},
}

View 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
}

View File

@@ -2,6 +2,7 @@
"name": "address-gen",
"private": true,
"scripts": {
"dev": "cd frontend && npm run dev"
"dev": "cd frontend && npm run dev",
"dev:android": "cd frontend && npm run dev:android"
}
}