Compare commits
4 Commits
main
...
wip-androi
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a390841f4 | |||
| 079b650d60 | |||
| 7b2ff5445b | |||
| e785179ee4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,10 +15,12 @@ __pycache__/
|
|||||||
!package-lock.json
|
!package-lock.json
|
||||||
!frontend/package.json
|
!frontend/package.json
|
||||||
!frontend/package-lock.json
|
!frontend/package-lock.json
|
||||||
|
!frontend/capacitor.config.json
|
||||||
|
|
||||||
# Frontend build output and dependencies
|
# Frontend build output and dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/android/
|
||||||
frontend/release/
|
frontend/release/
|
||||||
release/
|
release/
|
||||||
|
|||||||
59
contrib/android/Dockerfile
Normal file
59
contrib/android/Dockerfile
Normal 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
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"
|
||||||
@@ -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
|
## Install dependencies
|
||||||
- [@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
|
|
||||||
|
|
||||||
## 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
|
||||||
|
```
|
||||||
|
|||||||
6
frontend/capacitor.config.json
Normal file
6
frontend/capacitor.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.walletgen.app",
|
||||||
|
"appName": "wallet-gen",
|
||||||
|
"webDir": "dist",
|
||||||
|
"bundledWebRuntime": false
|
||||||
|
}
|
||||||
990
frontend/package-lock.json
generated
990
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"vite": "vite",
|
"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 .\"",
|
"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",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"dist": "vite build && electron-builder"
|
"dist": "vite build && electron-builder",
|
||||||
|
"cap:sync": "vite build && cap sync android"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.walletgen.app",
|
"appId": "com.walletgen.app",
|
||||||
@@ -31,10 +36,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@capacitor/android": "^7.0.0",
|
||||||
|
"@capacitor/cli": "^7.0.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
47
frontend/scripts/cap-run-android-live.sh
Normal file
47
frontend/scripts/cap-run-android-live.sh
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import CopyButton from './CopyButton'
|
import CopyButton from './CopyButton'
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
export default function HDWallet() {
|
export default function HDWallet() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -34,7 +35,7 @@ export default function HDWallet() {
|
|||||||
setSavePasswordConfirm('')
|
setSavePasswordConfirm('')
|
||||||
setShowSavePassword(false)
|
setShowSavePassword(false)
|
||||||
try {
|
try {
|
||||||
const data = await window.electronAPI.hdGenerate({
|
const data = await api.hdGenerate({
|
||||||
network: form.network,
|
network: form.network,
|
||||||
bip_type: form.bip_type,
|
bip_type: form.bip_type,
|
||||||
mnemonic: form.mnemonic.trim() || null,
|
mnemonic: form.mnemonic.trim() || null,
|
||||||
@@ -73,12 +74,12 @@ export default function HDWallet() {
|
|||||||
try {
|
try {
|
||||||
let data = wallet
|
let data = wallet
|
||||||
if (savePassword.trim()) {
|
if (savePassword.trim()) {
|
||||||
data = await window.electronAPI.hdEncrypt({ wallet, password: savePassword })
|
data = await api.hdEncrypt({ wallet, password: savePassword })
|
||||||
}
|
}
|
||||||
const filename = saveFilename.trim().endsWith('.json')
|
const filename = saveFilename.trim().endsWith('.json')
|
||||||
? saveFilename.trim()
|
? saveFilename.trim()
|
||||||
: `${saveFilename.trim()}.json`
|
: `${saveFilename.trim()}.json`
|
||||||
const savedPath = await window.electronAPI.saveWallet(filename, data, 'hd')
|
const savedPath = await api.saveWallet(filename, data, 'hd')
|
||||||
const encrypted = savePassword ? ' (encrypted)' : ''
|
const encrypted = savePassword ? ' (encrypted)' : ''
|
||||||
setSaveMsg(`Saved${encrypted}: ${savedPath}`)
|
setSaveMsg(`Saved${encrypted}: ${savedPath}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -229,7 +230,7 @@ export default function HDWallet() {
|
|||||||
value={saveFilename}
|
value={saveFilename}
|
||||||
onChange={e => setSaveFilename(e.target.value)}
|
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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Encryption password (optional)</label>
|
<label className="form-label">Encryption password (optional)</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import CopyButton from './CopyButton'
|
import CopyButton from './CopyButton'
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'p2pk', label: 'P2PK', desc: 'Pay-to-PubKey' },
|
{ 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 }
|
if (tab === 'p2sh') args = { ...args, m, n, compressed }
|
||||||
else if (tab !== 'p2tr') args = { ...args, compressed }
|
else if (tab !== 'p2tr') args = { ...args, compressed }
|
||||||
|
|
||||||
const fn = window.electronAPI[tab]
|
const fn = api[tab]
|
||||||
const data = await fn(args)
|
const data = await fn(args)
|
||||||
setResult(data)
|
setResult(data)
|
||||||
setFilename('')
|
setFilename('')
|
||||||
@@ -71,10 +72,10 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
|
|||||||
try {
|
try {
|
||||||
let data = result
|
let data = result
|
||||||
if (savePassword.trim()) {
|
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 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}`)
|
setSaveMsg(savePassword.trim() ? `Saved (encrypted): ${savedPath}` : `Saved: ${savedPath}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveMsg('Error: ' + e.message)
|
setSaveMsg('Error: ' + e.message)
|
||||||
@@ -155,7 +156,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
|
|||||||
value={filename}
|
value={filename}
|
||||||
onChange={e => setFilename(e.target.value)}
|
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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Encryption password (optional)</label>
|
<label className="form-label">Encryption password (optional)</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import CopyButton from './CopyButton'
|
import CopyButton from './CopyButton'
|
||||||
|
import { api } from '../services/api.js'
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -46,7 +47,7 @@ export default function WalletViewer() {
|
|||||||
setLoadingFiles(true)
|
setLoadingFiles(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await window.electronAPI.listWallets()
|
const data = await api.listWallets()
|
||||||
setFiles({ hd: data.hd || [], single: data.single || [] })
|
setFiles({ hd: data.hd || [], single: data.single || [] })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
@@ -68,7 +69,7 @@ export default function WalletViewer() {
|
|||||||
setWallet(null)
|
setWallet(null)
|
||||||
setSelected({ kind, filename })
|
setSelected({ kind, filename })
|
||||||
try {
|
try {
|
||||||
const data = await window.electronAPI.readWallet({ kind, filename })
|
const data = await api.readWallet({ kind, filename })
|
||||||
setWallet(data)
|
setWallet(data)
|
||||||
if (!data?.use_encryption) {
|
if (!data?.use_encryption) {
|
||||||
setDecryptedWallet(data)
|
setDecryptedWallet(data)
|
||||||
@@ -85,7 +86,7 @@ export default function WalletViewer() {
|
|||||||
setLoadingDecrypt(true)
|
setLoadingDecrypt(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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 })
|
const data = await fn({ wallet, password })
|
||||||
setDecryptedWallet(data)
|
setDecryptedWallet(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
245
frontend/src/services/api.js
Normal file
245
frontend/src/services/api.js
Normal 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`
|
||||||
|
},
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "address-gen",
|
"name": "address-gen",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cd frontend && npm run dev"
|
"dev": "cd frontend && npm run dev",
|
||||||
|
"dev:android": "cd frontend && npm run dev:android"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user