aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-14 23:01:03 -0700
committerAdam Soltys <[email protected]>2026-06-14 23:01:03 -0700
commitc5443946031ec32a7b3008fa751eb7916654a9f6 (patch)
tree372e8d0ccaa2dec58a6bcaaa19d3902b80eb4396
parent683927e4132651b9198e5421fae16e9c60978fbc (diff)
downloadcoinosv3-c5443946031ec32a7b3008fa751eb7916654a9f6.tar.gz
coinosv3-c5443946031ec32a7b3008fa751eb7916654a9f6.zip
Rebuild as single-file offline BIP84 wallet
Replace the SvelteKit/daisyUI prototype with a vanilla JS + hand-rolled CSS app that Bun bundles (code + scure/noble crypto + QR + CSS) into one self-contained dist/index.html runnable offline from file://. Wallet core (src/wallet.js): - BIP84 native-segwit HD wallet from BIP39 seed (+optional passphrase) - gap-limit scan of receive/change chains, aggregate balance, tx history - fresh receive/change addresses; signs with witnessUtxo only (enables offline signing with no prev-tx fetch) - coin selection + fee estimation via btc-signer selectUTXO; send-max; manual coin control - offline JSON snapshot export/import (keyless) + signed-tx hex/QR export - realtime mempool.space WebSocket: track-addresses -> debounced auto refresh of balances/history (no polling); live status badge UI (src/app.js): clean light paper-wallet; create (with optional, skippable backup verification) / import flow; receive (QR) / send / history / coins / tools tabs; clickable logo returns home. API (src/api.js): mempool.space wrapper with retry/backoff for scan-burst rate limiting; single network choke-point for offline enforcement. Verified: BIP84 spec vectors, build/sign/parse for auto/max/manual flows, snapshot roundtrip, live scan + WebSocket, headless-Chrome UI smoke tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--.gitignore15
-rw-r--r--.npmrc1
-rw-r--r--README.md84
-rw-r--r--build.js53
-rwxr-xr-xbun.lockbbin68048 -> 13985 bytes
-rw-r--r--dev.js25
-rw-r--r--package.json59
-rw-r--r--src/api.js104
-rw-r--r--src/app.css23
-rw-r--r--src/app.d.ts13
-rw-r--r--src/app.html12
-rw-r--r--src/app.js954
-rw-r--r--src/format.js56
-rw-r--r--src/lib/Copy.svelte15
-rw-r--r--src/lib/index.ts9
-rw-r--r--src/qr.js27
-rw-r--r--src/routes/+layout.svelte19
-rw-r--r--src/routes/+layout.ts2
-rw-r--r--src/routes/+page.svelte342
-rw-r--r--src/style.css217
-rw-r--r--src/wallet.js565
-rw-r--r--static/bg.pngbin182111 -> 0 bytes
-rw-r--r--static/bg.webpbin27804 -> 0 bytes
-rw-r--r--static/bitcoin.svg7
-rw-r--r--static/favicon.svg1
-rw-r--r--static/random.svg6
-rw-r--r--svelte.config.js18
-rw-r--r--tailwind.config.cjs25
-rw-r--r--tsconfig.json19
-rw-r--r--vite.config.ts7
30 files changed, 2083 insertions, 595 deletions
diff --git a/.gitignore b/.gitignore
index 3b462cb..9f49548 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,7 @@
node_modules
-# Output
-.output
-.vercel
-.netlify
-.wrangler
-/.svelte-kit
-/build
+# Build output (single-file wallet; rebuild with `bun run build`)
+/dist
# OS
.DS_Store
@@ -15,9 +10,3 @@ Thumbs.db
# Env
.env
.env.*
-!.env.example
-!.env.test
-
-# Vite
-vite.config.js.timestamp-*
-vite.config.ts.timestamp-*
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index b6f27f1..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-engine-strict=true
diff --git a/README.md b/README.md
index b5b2950..3821f32 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,76 @@
-# sv
+# Bitcoin Wallet
-Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+A self-contained, **single-file** Bitcoin wallet that runs entirely in the
+browser. Think bitaddress.org, but a modern BIP84 HD wallet built from a seed
+phrase, able to scan history, fetch UTXOs, and spend — online or fully offline.
-## Creating a project
+- **BIP84 / native SegWit (p2wpkh)** HD wallet from a 12/24-word BIP39 seed
+ (optional passphrase).
+- **HD scanning** with the standard gap limit across the receive (`…/0/i`) and
+ change (`…/1/i`) chains — real aggregate balance, fresh addresses, and
+ transaction history via [mempool.space](https://mempool.space).
+- **Spending** with proper coin selection + fee estimation
+ (`@scure/btc-signer`'s `selectUTXO`), a fee-rate picker, send-max, and
+ **manual coin control**.
+- **Fresh addresses** for receiving and for change — no address reuse.
+- **Offline mode**: import UTXOs from a JSON snapshot (fetched on an online
+ device), sign locally, and export the signed transaction (hex / file / QR) to
+ broadcast elsewhere. No network calls are made.
+- **Runs offline from `file://`** — keys never leave the page; nothing is sent
+ anywhere except, when online, read-only requests + a broadcast to
+ mempool.space.
-If you're seeing this, you've probably already done this step. Congrats!
+Built with the modern audited [`@scure`](https://github.com/paulmillr/scure-btc-signer)
+/ [`@noble`](https://github.com/paulmillr/noble-hashes) libraries. Pure JS: no
+`crypto.subtle`, so it works from the filesystem with no server.
-```bash
-# create a new project in the current directory
-npx sv create
+## Develop
-# create a new project in my-app
-npx sv create my-app
-```
+Requires [Bun](https://bun.sh).
-## Developing
+```bash
+bun install
+bun run dev # http://localhost:5173 (rebuilds on each refresh)
+```
-Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+## Build
```bash
-npm run dev
-
-# or start the server and open the app in a new browser tab
-npm run dev -- --open
+bun run build # → dist/index.html (one self-contained file)
```
-## Building
+`dist/index.html` inlines all code, the crypto libraries, the QR generator, and
+the CSS. **To use it offline: save that one file and open it directly in a
+browser** — no server, no internet.
-To create a production version of your app:
+## Using it offline (air-gapped spending)
-```bash
-npm run build
-```
+1. **Online device** (with internet): open the wallet, let it scan, then go to
+ **Tools → Export snapshot**. This downloads a JSON file of your UTXOs and fee
+ rates. It contains **no keys**.
+2. Move the file to your **offline device** (e.g. an air-gapped laptop running
+ the saved `index.html`).
+3. On the offline device, open the wallet, enter your **seed phrase** with
+ *Offline mode* checked, then **Tools → Import snapshot**.
+4. Build and **sign** a transaction. Copy/download the signed hex (or scan the
+ QR) and broadcast it from any online device (e.g. mempool.space → Broadcast).
+
+## Layout
+
+| File | Purpose |
+| --- | --- |
+| `src/wallet.js` | BIP84 derivation, scanning, coin selection, signing, snapshots |
+| `src/api.js` | mempool.space REST wrapper (the single network choke-point) |
+| `src/app.js` | UI controller (vanilla DOM) |
+| `src/qr.js` | QR → SVG (zero-dep) |
+| `src/format.js` | sat/BTC formatting helpers |
+| `src/style.css` | Hand-rolled styles |
+| `build.js` / `dev.js` | Bun bundler → single inlined `index.html`, and dev server |
-You can preview the production build with `npm run preview`.
+## Security notes
-> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+- This is self-custody software handling real keys — review the code before
+ trusting it with funds, and prefer an offline machine for the seed phrase.
+- Mainnet and testnet are both supported (select on the unlock screen).
+- The seed phrase and passphrase live only in memory while the page is open;
+ locking or closing the tab clears them.
diff --git a/build.js b/build.js
new file mode 100644
index 0000000..486908e
--- /dev/null
+++ b/build.js
@@ -0,0 +1,53 @@
+// Build a single, self-contained dist/index.html.
+//
+// Everything — app code, the @scure/@noble crypto libs, the QR generator, and
+// all CSS — is bundled and inlined so the result is one file you can save and
+// open offline straight from the filesystem (file://). No server, no network.
+
+import { mkdir } from 'node:fs/promises';
+
+const FAVICON =
+ 'data:image/svg+xml,' +
+ encodeURIComponent(
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#f7931a"/><text x="16" y="23" font-size="20" font-family="Arial" font-weight="bold" text-anchor="middle" fill="#fff">₿</text></svg>`
+ );
+
+export async function buildHtml({ minify = true } = {}) {
+ const result = await Bun.build({
+ entrypoints: ['./src/app.js'],
+ target: 'browser',
+ minify,
+ });
+ if (!result.success) {
+ for (const log of result.logs) console.error(log);
+ throw new Error('bundle failed');
+ }
+ let js = await result.outputs[0].text();
+ // Guard against a literal </script> inside the bundle closing our tag early.
+ js = js.replaceAll('</script', '<\\/script');
+ const css = await Bun.file('./src/style.css').text();
+
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
+<meta name="color-scheme" content="light">
+<title>Bitcoin Wallet</title>
+<link rel="icon" href="${FAVICON}">
+<style>${css}</style>
+</head>
+<body>
+<div id="app"></div>
+<script>${js}</script>
+</body>
+</html>`;
+}
+
+if (import.meta.main) {
+ await mkdir('dist', { recursive: true });
+ const html = await buildHtml({ minify: true });
+ await Bun.write('dist/index.html', html);
+ const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
+ console.log(`✓ dist/index.html written (${kb} KB) — open it offline, no server needed`);
+}
diff --git a/bun.lockb b/bun.lockb
index 1836da6..d8182f7 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/dev.js b/dev.js
new file mode 100644
index 0000000..099069c
--- /dev/null
+++ b/dev.js
@@ -0,0 +1,25 @@
+// Dev server: rebuilds the inlined bundle on every request so you just refresh
+// the browser to see changes. Same output as `bun run build`, unminified.
+
+import { buildHtml } from './build.js';
+
+const port = Number(process.env.PORT || 5173);
+
+Bun.serve({
+ port,
+ async fetch() {
+ try {
+ const html = await buildHtml({ minify: false });
+ return new Response(html, {
+ headers: { 'content-type': 'text/html; charset=utf-8' },
+ });
+ } catch (e) {
+ return new Response(`Build error:\n\n${e.stack || e}`, {
+ status: 500,
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
+ });
+ }
+ },
+});
+
+console.log(`dev server → http://localhost:${port}`);
diff --git a/package.json b/package.json
index e31f2fc..9e38e24 100644
--- a/package.json
+++ b/package.json
@@ -1,42 +1,21 @@
{
- "name": "cold2",
- "private": true,
- "version": "0.0.1",
- "type": "module",
- "scripts": {
- "dev": "vite dev",
- "build": "vite build",
- "preview": "vite preview",
- "prepare": "svelte-kit sync || echo ''",
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
- "format": "prettier --write .",
- "lint": "prettier --check ."
- },
- "devDependencies": {
- "@sveltejs/adapter-auto": "^6.0.0",
- "@sveltejs/kit": "^2.22.0",
- "@sveltejs/vite-plugin-svelte": "^6.0.0",
- "@tailwindcss/forms": "^0.5.9",
- "@tailwindcss/typography": "^0.5.15",
- "@tailwindcss/vite": "^4.0.0",
- "prettier": "^3.4.2",
- "prettier-plugin-svelte": "^3.3.3",
- "prettier-plugin-tailwindcss": "^0.6.11",
- "svelte": "^5.0.0",
- "svelte-check": "^4.0.0",
- "tailwindcss": "^4.0.0",
- "typescript": "^5.0.0",
- "vite": "^7.0.4"
- },
- "dependencies": {
- "@iconify/iconify": "^3.1.1",
- "@scure/base": "^1.2.6",
- "@scure/bip32": "^1.7.0",
- "@scure/bip39": "^1.6.0",
- "@scure/btc-signer": "^1.8.1",
- "@sveltejs/adapter-static": "^3.0.8",
- "daisyui": "^5.0.46",
- "iconify-icon": "^3.0.0"
- }
+ "name": "bitcoin-wallet",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Offline-capable, single-file BIP84 HD Bitcoin wallet for the browser",
+ "scripts": {
+ "dev": "bun run dev.js",
+ "build": "bun run build.js"
+ },
+ "dependencies": {
+ "@scure/base": "^1.2.6",
+ "@scure/bip32": "^1.7.0",
+ "@scure/bip39": "^1.6.0",
+ "@scure/btc-signer": "^1.8.1",
+ "qrcode-generator": "^2.0.4"
+ },
+ "devDependencies": {
+ "puppeteer-core": "^25.1.0"
+ }
}
diff --git a/src/api.js b/src/api.js
new file mode 100644
index 0000000..c99ed40
--- /dev/null
+++ b/src/api.js
@@ -0,0 +1,104 @@
+// Thin wrapper around the mempool.space REST API.
+//
+// Every network call goes through here so that "offline mode" can be enforced
+// in exactly one place: when offline, any call throws and the UI relies on an
+// imported snapshot instead.
+
+const BASES = {
+ mainnet: 'https://mempool.space/api',
+ testnet: 'https://mempool.space/testnet/api',
+};
+
+export class Api {
+ constructor(net = 'mainnet') {
+ this.net = net;
+ this.offline = false;
+ }
+
+ base() {
+ return BASES[this.net] || BASES.mainnet;
+ }
+
+ explorerTx(txid) {
+ const path = this.net === 'testnet' ? 'testnet/tx' : 'tx';
+ return `https://mempool.space/${path}/${txid}`;
+ }
+
+ async #get(path, asText = false) {
+ if (this.offline) throw new Error('offline');
+ // Retry transient failures (mempool.space rate-limits bursts of requests
+ // during a scan, which can surface as network/CORS errors from file://).
+ let lastErr;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ if (attempt) await sleep(400 * attempt + 200);
+ try {
+ const res = await fetch(this.base() + path);
+ if (res.status === 429) throw new Error('rate limited');
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`);
+ return asText ? res.text() : res.json();
+ } catch (e) {
+ lastErr = e;
+ }
+ }
+ throw lastErr;
+ }
+
+ // chain_stats / mempool_stats let us decide whether an address has ever been
+ // used without downloading its full history.
+ addressInfo(address) {
+ return this.#get(`/address/${address}`);
+ }
+
+ addressUtxos(address) {
+ return this.#get(`/address/${address}/utxo`);
+ }
+
+ // Returns up to ~50 of the most recent transactions touching the address
+ // (mempool first, then confirmed).
+ addressTxs(address) {
+ return this.#get(`/address/${address}/txs`);
+ }
+
+ async feeRates() {
+ try {
+ return await this.#get('/v1/fees/recommended');
+ } catch {
+ return { fastestFee: 20, halfHourFee: 10, hourFee: 5, economyFee: 2, minimumFee: 1 };
+ }
+ }
+
+ async price() {
+ try {
+ const p = await this.#get('/v1/prices');
+ return p && p.USD ? p.USD : null;
+ } catch {
+ return null;
+ }
+ }
+
+ async broadcast(hexTx) {
+ if (this.offline) throw new Error('offline');
+ const res = await fetch(this.base() + '/tx', { method: 'POST', body: hexTx });
+ const body = await res.text();
+ if (!res.ok) throw new Error(body || `${res.status} broadcast failed`);
+ return body.trim(); // txid
+ }
+}
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+// Run async jobs with a small concurrency cap so we stay friendly to the API.
+export async function pool(items, worker, concurrency = 4) {
+ const results = new Array(items.length);
+ let next = 0;
+ async function run() {
+ while (next < items.length) {
+ const i = next++;
+ results[i] = await worker(items[i], i);
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
+ return results;
+}
diff --git a/src/app.css b/src/app.css
deleted file mode 100644
index cafcee5..0000000
--- a/src/app.css
+++ /dev/null
@@ -1,23 +0,0 @@
-@import 'tailwindcss';
-@plugin '@tailwindcss/forms';
-@plugin '@tailwindcss/typography';
-@plugin "daisyui";
-
-html { height: 100%; }
-html, body {
- margin: 0;
- padding: 0;
- overflow-x: hidden;
-}
-
-body {
- padding-top: 40px;
-}
-
-html {
- background-image: url('/bg.webp');
- background-size: cover;
- background-position: center;
- background-repeat: no-repeat;
-}
-
diff --git a/src/app.d.ts b/src/app.d.ts
deleted file mode 100644
index da08e6d..0000000
--- a/src/app.d.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-// See https://svelte.dev/docs/kit/types#app.d.ts
-// for information about these interfaces
-declare global {
- namespace App {
- // interface Error {}
- // interface Locals {}
- // interface PageData {}
- // interface PageState {}
- // interface Platform {}
- }
-}
-
-export {};
diff --git a/src/app.html b/src/app.html
deleted file mode 100644
index 1391f88..0000000
--- a/src/app.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="utf-8" />
- <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- %sveltekit.head%
- </head>
- <body data-sveltekit-preload-data="hover">
- <div style="display: contents">%sveltekit.body%</div>
- </body>
-</html>
diff --git a/src/app.js b/src/app.js
new file mode 100644
index 0000000..5ad8978
--- /dev/null
+++ b/src/app.js
@@ -0,0 +1,954 @@
+// Bitcoin Wallet — UI controller (vanilla DOM, no framework).
+//
+// State lives in `ui` + the singleton `wallet`. Mutating handlers call render(),
+// which rebuilds the active screen. Text inputs write back into `ui` on `input`
+// (without re-rendering) so their values survive structural re-renders.
+
+import { Wallet, newMnemonic, isValidMnemonic, utxoId } from './wallet.js';
+import { qrSvg } from './qr.js';
+import {
+ fmtBtc,
+ fmtSats,
+ fmtUsd,
+ parseAmount,
+ shortAddr,
+ shortTxid,
+ timeAgo,
+ SATS,
+} from './format.js';
+
+const wallet = new Wallet();
+
+const ui = {
+ screen: 'unlock', // 'unlock' | 'wallet'
+ unlockTab: 'create', // 'create' | 'import'
+ createStep: 'gen', // 'gen' | 'confirm'
+ draftMnemonic: '',
+ confirm: [], // [{ index, value }]
+ importText: '',
+ passphrase: '',
+ showPass: false,
+ netName: 'mainnet',
+ offline: false,
+ unlockError: '',
+
+ tab: 'receive', // receive | send | history | coins | tools
+ showAddrs: false,
+ send: blankSend(),
+ draft: null, // built tx summary awaiting review
+ sendError: '',
+ sendResult: null, // { txid } | { signedHex, txid }
+ busy: false,
+};
+
+function blankSend() {
+ return {
+ address: '',
+ amount: '',
+ unit: 'btc',
+ max: false,
+ feeChoice: 'halfHourFee',
+ customFee: '',
+ manual: false,
+ coins: new Set(),
+ };
+}
+
+// ---------------------------------------------------------------- DOM helper
+function h(tag, attrs = {}, ...children) {
+ const e = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v == null || v === false) continue;
+ if (k === 'class') e.className = v;
+ else if (k === 'html') e.innerHTML = v;
+ else if (k === 'value') e.value = v;
+ else if (k === 'checked' || k === 'disabled' || k === 'selected') e[k] = !!v;
+ else if (k.startsWith('on') && typeof v === 'function')
+ e.addEventListener(k.slice(2).toLowerCase(), v);
+ else e.setAttribute(k, v);
+ }
+ for (const c of children.flat()) {
+ if (c == null || c === false || c === true) continue;
+ e.append(c.nodeType ? c : document.createTextNode(String(c)));
+ }
+ return e;
+}
+
+const root = document.getElementById('app');
+function render() {
+ root.replaceChildren(ui.screen === 'wallet' ? walletScreen() : unlockScreen());
+}
+wallet.subscribe(render);
+
+// ---------------------------------------------------------------- utilities
+let toastTimer;
+function toast(msg) {
+ let t = document.querySelector('.toast');
+ if (!t) {
+ t = h('div', { class: 'toast' });
+ document.body.append(t);
+ }
+ t.textContent = msg;
+ requestAnimationFrame(() => t.classList.add('show'));
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove('show'), 1600);
+}
+
+async function copy(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ const ta = h('textarea', { value: text });
+ document.body.append(ta);
+ ta.select();
+ try {
+ document.execCommand('copy');
+ } catch {}
+ ta.remove();
+ }
+ toast('Copied to clipboard');
+}
+
+function download(filename, text, mime = 'application/json') {
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
+ const a = h('a', { href: url, download: filename });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+function copyBtn(text, label = 'Copy') {
+ return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label);
+}
+
+// ================================================================ UNLOCK
+function unlockScreen() {
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:16px' },
+ brandHeader(false),
+ h(
+ 'div',
+ { class: 'card col' },
+ h(
+ 'div',
+ { class: 'tabs' },
+ tabBtn('Create new', ui.unlockTab === 'create', () => {
+ ui.unlockTab = 'create';
+ render();
+ }),
+ tabBtn('Import existing', ui.unlockTab === 'import', () => {
+ ui.unlockTab = 'import';
+ render();
+ })
+ ),
+ ui.unlockTab === 'create' ? createPane() : importPane(),
+ ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError)
+ ),
+ h(
+ 'p',
+ { class: 'small muted center' },
+ 'Keys never leave your browser. Works fully offline — save this page and open it from your filesystem.'
+ )
+ );
+}
+
+function tabBtn(label, active, onClick) {
+ return h('button', { class: active ? 'active' : '', onClick }, label);
+}
+
+function createPane() {
+ if (ui.createStep === 'gen') {
+ if (!ui.draftMnemonic) {
+ return h(
+ 'div',
+ { class: 'col' },
+ h('p', { class: 'muted' }, 'Generate a new 12-word BIP39 seed phrase. Write it down — it is the only way to recover this wallet.'),
+ h(
+ 'button',
+ {
+ class: 'btn-primary btn-block',
+ onClick: () => {
+ ui.draftMnemonic = newMnemonic();
+ render();
+ },
+ },
+ 'Generate seed phrase'
+ )
+ );
+ }
+ const words = ui.draftMnemonic.split(' ');
+ return h(
+ 'div',
+ { class: 'col' },
+ h('div', { class: 'warn-box' }, '⚠ Write these 12 words down on paper, in order. Anyone with them can spend your coins.'),
+ h(
+ 'div',
+ { class: 'words' },
+ words.map((w, i) =>
+ h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))
+ )
+ ),
+ h(
+ 'div',
+ { class: 'row gap6' },
+ copyBtn(ui.draftMnemonic, 'Copy phrase'),
+ h(
+ 'button',
+ {
+ class: 'btn-ghost btn-sm',
+ onClick: () => {
+ ui.draftMnemonic = newMnemonic();
+ render();
+ },
+ },
+ 'Regenerate'
+ )
+ ),
+ optionsPanel(),
+ h(
+ 'button',
+ {
+ class: 'btn-primary btn-block',
+ onClick: () => {
+ ui.confirm = pickConfirm(words);
+ ui.unlockError = '';
+ ui.createStep = 'confirm';
+ render();
+ },
+ },
+ 'Verify backup'
+ ),
+ h(
+ 'button',
+ { class: 'linklike small center', style: 'align-self:center', onClick: () => openWallet(ui.draftMnemonic) },
+ 'Skip verification & open wallet →'
+ )
+ );
+ }
+
+ // confirm step (optional — reachable via "Verify backup")
+ return h(
+ 'div',
+ { class: 'col' },
+ h('p', { class: 'muted' }, 'Confirm your backup by entering the requested words.'),
+ ...ui.confirm.map((c, i) =>
+ h(
+ 'label',
+ { class: 'field' },
+ h('span', { class: 'lab' }, `Word #${c.index + 1}`),
+ h('input', {
+ type: 'text',
+ class: 'mono-input',
+ autocapitalize: 'none',
+ autocomplete: 'off',
+ spellcheck: 'false',
+ value: c.value,
+ onInput: (e) => (ui.confirm[i].value = e.target.value.trim()),
+ })
+ )
+ ),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, '← Back'),
+ h('button', {
+ class: 'btn-primary grow',
+ onClick: () => {
+ const words = ui.draftMnemonic.split(' ');
+ const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
+ if (!ok) { ui.unlockError = 'Those words don’t match your phrase. Check your backup.'; render(); return; }
+ openWallet(ui.draftMnemonic);
+ },
+ }, 'Open wallet')
+ )
+ );
+}
+
+function pickConfirm(words) {
+ const idx = new Set();
+ while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length));
+ return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' }));
+}
+
+function importPane() {
+ return h(
+ 'div',
+ { class: 'col' },
+ h(
+ 'label',
+ { class: 'field' },
+ h('span', { class: 'lab' }, 'Seed phrase (12 or 24 words)'),
+ h('textarea', {
+ placeholder: 'word1 word2 word3 …',
+ autocapitalize: 'none',
+ autocomplete: 'off',
+ spellcheck: 'false',
+ value: ui.importText,
+ onInput: (e) => (ui.importText = e.target.value),
+ })
+ ),
+ optionsPanel(),
+ h('button', { class: 'btn-primary btn-block', onClick: () => openWallet(ui.importText) }, 'Open wallet')
+ );
+}
+
+function optionsPanel() {
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:12px' },
+ h(
+ 'label',
+ { class: 'field' },
+ h('span', { class: 'lab' }, 'BIP39 passphrase (optional extra word)'),
+ h(
+ 'div',
+ { class: 'input-group' },
+ h('input', {
+ type: ui.showPass ? 'text' : 'password',
+ class: 'mono-input',
+ placeholder: 'leave blank if unused',
+ autocomplete: 'off',
+ value: ui.passphrase,
+ onInput: (e) => (ui.passphrase = e.target.value),
+ }),
+ h('button', { class: 'btn-sm', type: 'button', onClick: () => { ui.showPass = !ui.showPass; render(); } }, ui.showPass ? 'Hide' : 'Show')
+ )
+ ),
+ h(
+ 'div',
+ { class: 'row between wrap', style: 'gap:12px' },
+ h(
+ 'label',
+ { class: 'field', style: 'flex:1' },
+ h('span', { class: 'lab' }, 'Network'),
+ h(
+ 'select',
+ { onChange: (e) => { ui.netName = e.target.value; render(); } },
+ h('option', { value: 'mainnet', selected: ui.netName === 'mainnet' }, 'Mainnet'),
+ h('option', { value: 'testnet', selected: ui.netName === 'testnet' }, 'Testnet')
+ )
+ ),
+ h(
+ 'label',
+ { class: 'row gap6', style: 'margin-top:22px' },
+ h('input', { type: 'checkbox', checked: ui.offline, onChange: (e) => { ui.offline = e.target.checked; } }),
+ h('span', {}, 'Offline mode')
+ )
+ )
+ );
+}
+
+async function openWallet(mnemonic) {
+ ui.unlockError = '';
+ const m = (mnemonic || '').trim().replace(/\s+/g, ' ');
+ if (!isValidMnemonic(m)) {
+ ui.unlockError = 'Invalid seed phrase — check the spelling and word order.';
+ render();
+ return;
+ }
+ wallet.load({ mnemonic: m, passphrase: ui.passphrase, netName: ui.netName, offline: ui.offline });
+ ui.screen = 'wallet';
+ ui.tab = ui.offline ? 'tools' : 'receive';
+ ui.send = blankSend();
+ ui.draft = null;
+ ui.sendResult = null;
+ render();
+ if (ui.offline) {
+ wallet.deriveWindow(40);
+ render();
+ } else {
+ try {
+ await wallet.scan();
+ } catch (e) {
+ toast('Scan failed: ' + e.message);
+ }
+ wallet.startRealtime();
+ }
+}
+
+function lock() {
+ wallet.stopRealtime();
+ wallet.load({ mnemonic: '', passphrase: '', netName: ui.netName, offline: ui.offline });
+ wallet.mnemonic = '';
+ ui.screen = 'unlock';
+ ui.createStep = 'gen';
+ ui.draftMnemonic = '';
+ ui.importText = '';
+ ui.passphrase = '';
+ ui.confirm = [];
+ render();
+}
+
+// ================================================================ WALLET
+function brandHeader(withLock) {
+ const status = wallet.offline ? 'offline' : wallet.live ? 'live' : 'online';
+ return h(
+ 'div',
+ { class: 'row between' },
+ h(
+ 'div',
+ { class: 'brand', style: 'cursor:pointer', title: 'Home', onClick: goHome },
+ h('div', { class: 'logo' }, '₿'),
+ h('h1', {}, 'Bitcoin Wallet')
+ ),
+ h(
+ 'div',
+ { class: 'row gap6' },
+ ui.netName === 'testnet' && h('span', { class: 'badge testnet' }, 'testnet'),
+ withLock &&
+ h(
+ 'span',
+ { class: `badge dot ${wallet.offline ? 'off' : ''} ${wallet.live ? 'live' : ''}` },
+ status
+ ),
+ withLock && h('button', { class: 'btn-sm', onClick: lock }, '🔒 Lock')
+ )
+ );
+}
+
+function goHome() {
+ if (ui.screen === 'wallet') {
+ ui.tab = wallet.offline ? 'tools' : 'receive';
+ ui.draft = null;
+ ui.sendResult = null;
+ ui.sendError = '';
+ ui.showAddrs = false;
+ } else {
+ ui.unlockTab = 'create';
+ ui.createStep = 'gen';
+ ui.draftMnemonic = '';
+ ui.confirm = [];
+ ui.unlockError = '';
+ }
+ render();
+}
+
+function walletScreen() {
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:0' },
+ brandHeader(true),
+ h('div', { class: 'mt16' }, balanceCard()),
+ tabsBar(),
+ tabContent()
+ );
+}
+
+function balanceCard() {
+ const price = wallet.price;
+ return h(
+ 'div',
+ { class: 'card balance' },
+ h('div', { class: 'row between' },
+ h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, 'Total balance'),
+ wallet.scanning
+ ? h('span', { class: 'spinner' })
+ : !wallet.offline &&
+ h('button', { class: 'btn-sm btn-ghost', title: 'Refresh', onClick: () => wallet.scan() }, '↻ Refresh')
+ ),
+ h('div', { class: 'amt' }, fmtBtc(wallet.total), h('span', { class: 'unit' }, 'BTC')),
+ price ? h('div', { class: 'usd' }, '≈ ' + fmtUsd(wallet.total, price)) : null,
+ h(
+ 'div',
+ { class: 'split' },
+ h('div', {}, h('div', { class: 'k' }, 'Confirmed'), h('div', { class: 'v' }, fmtSats(wallet.confirmed) + ' sats')),
+ wallet.pending > 0 &&
+ h('div', {}, h('div', { class: 'k' }, 'Pending'), h('div', { class: 'v pending' }, fmtSats(wallet.pending) + ' sats'))
+ )
+ );
+}
+
+function tabsBar() {
+ const tabs = [
+ ['receive', 'Receive'],
+ ['send', 'Send'],
+ ['history', 'History'],
+ ['coins', 'Coins'],
+ ['tools', 'Tools'],
+ ];
+ return h(
+ 'div',
+ { class: 'tabs' },
+ tabs.map(([id, label]) =>
+ tabBtn(label, ui.tab === id, () => {
+ ui.tab = id;
+ render();
+ })
+ )
+ );
+}
+
+function tabContent() {
+ switch (ui.tab) {
+ case 'receive': return receiveTab();
+ case 'send': return sendTab();
+ case 'history': return historyTab();
+ case 'coins': return coinsTab();
+ case 'tools': return toolsTab();
+ }
+}
+
+// ---------------------------------------------------------------- Receive
+function receiveTab() {
+ const fresh = wallet.freshReceive();
+ const coin = wallet.netCfg.coin;
+ return h(
+ 'div',
+ { class: 'card col', style: 'align-items:center;gap:14px' },
+ h('div', { class: 'small muted' }, 'Your next receive address'),
+ h('div', { html: qrSvg(fresh.address) }),
+ h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address),
+ h(
+ 'div',
+ { class: 'row gap6' },
+ copyBtn(fresh.address, 'Copy address'),
+ !wallet.offline && h('button', { class: 'btn-sm btn-ghost', onClick: () => wallet.scan() }, '↻ Check for payments')
+ ),
+ h('div', { class: 'path' }, `m/84'/${coin}'/0'/0/${fresh.index}`),
+ h('p', { class: 'small muted center', style: 'margin:0' },
+ 'A fresh address is shown for better privacy. After a payment arrives, scan again to advance to a new one.'),
+ addressBook()
+ );
+}
+
+function addressBook() {
+ if (!wallet.receive.length) return null;
+ const toggle = h(
+ 'button',
+ { class: 'linklike small', onClick: () => { ui.showAddrs = !ui.showAddrs; render(); } },
+ ui.showAddrs ? 'Hide all addresses' : `View all addresses (${wallet.receive.length})`
+ );
+ if (!ui.showAddrs) return toggle;
+ return h(
+ 'div',
+ { class: 'col', style: 'width:100%;gap:0' },
+ toggle,
+ h(
+ 'div',
+ { class: 'list', style: 'width:100%' },
+ wallet.receive.map((a) =>
+ h(
+ 'div',
+ { class: 'item' },
+ h('div', { class: 'grow mono small break' }, a.address),
+ a.used && h('span', { class: 'tag conf' }, 'used'),
+ copyBtn(a.address, '⧉')
+ )
+ )
+ )
+ );
+}
+
+// ---------------------------------------------------------------- Send
+function sendTab() {
+ if (ui.sendResult) return sendResultView();
+ if (ui.draft) return reviewView();
+ return sendForm();
+}
+
+function sendForm() {
+ const s = ui.send;
+ const fr = wallet.feeRates;
+ const feeOpts = [
+ ['economyFee', 'Economy'],
+ ['halfHourFee', 'Normal'],
+ ['fastestFee', 'Priority'],
+ ['custom', 'Custom'],
+ ];
+ return h(
+ 'div',
+ { class: 'card col' },
+ h(
+ 'label',
+ { class: 'field' },
+ h('span', { class: 'lab' }, 'Recipient address'),
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: wallet.netName === 'testnet' ? 'tb1q…' : 'bc1q…',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: s.address,
+ onInput: (e) => (s.address = e.target.value),
+ })
+ ),
+ h(
+ 'label',
+ { class: 'field' },
+ h('span', { class: 'lab' }, 'Amount'),
+ h(
+ 'div',
+ { class: 'input-group' },
+ h('input', {
+ type: 'number', step: 'any', min: '0', placeholder: '0.00000000',
+ disabled: s.max, value: s.max ? '' : s.amount,
+ onInput: (e) => (s.amount = e.target.value),
+ }),
+ h(
+ 'div',
+ { class: 'seg' },
+ h('button', { type: 'button', class: s.unit === 'btc' ? 'active' : '', onClick: () => { s.unit = 'btc'; render(); } }, 'BTC'),
+ h('button', { type: 'button', class: s.unit === 'sats' ? 'active' : '', onClick: () => { s.unit = 'sats'; render(); } }, 'sats')
+ ),
+ h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, 'Max')
+ )
+ ),
+ h(
+ 'div',
+ { class: 'field' },
+ h('span', { class: 'lab' }, 'Fee rate'),
+ h(
+ 'div',
+ { class: 'seg', style: 'display:flex;width:100%' },
+ feeOpts.map(([k, label]) =>
+ h('button', {
+ type: 'button', class: (s.feeChoice === k ? 'active ' : '') + 'grow',
+ onClick: () => { s.feeChoice = k; render(); },
+ }, label + (fr && fr[k] ? ` ${fr[k]}` : ''))
+ )
+ ),
+ s.feeChoice === 'custom' &&
+ h('div', { class: 'input-group mt8' },
+ h('input', { type: 'number', min: '1', placeholder: 'sat/vB', value: s.customFee, onInput: (e) => (s.customFee = e.target.value) }),
+ h('span', { class: 'small muted', style: 'align-self:center' }, 'sat/vB')
+ ),
+ h('div', { class: 'small faint mt8' }, `Selected rate: ${currentFeeRate()} sat/vB`)
+ ),
+ coinControl(),
+ ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
+ h('button', { class: 'btn-primary btn-block', onClick: reviewSend }, 'Review transaction')
+ );
+}
+
+function coinControl() {
+ const s = ui.send;
+ const head = h(
+ 'div',
+ { class: 'row between' },
+ h('span', { class: 'lab', style: 'margin:0' }, 'Coin selection'),
+ h(
+ 'div',
+ { class: 'seg' },
+ h('button', { type: 'button', class: !s.manual ? 'active' : '', onClick: () => { s.manual = false; render(); } }, 'Automatic'),
+ h('button', { type: 'button', class: s.manual ? 'active' : '', onClick: () => { s.manual = true; render(); } }, 'Manual')
+ )
+ );
+ if (!s.manual) return h('div', { class: 'col gap6' }, head);
+
+ if (!wallet.utxos.length)
+ return h('div', { class: 'col gap6' }, head, h('div', { class: 'small muted' }, 'No coins available.'));
+
+ let selTotal = 0;
+ const rows = wallet.utxos.map((u) => {
+ const id = utxoId(u);
+ const checked = s.coins.has(id);
+ if (checked) selTotal += u.value;
+ return h(
+ 'label',
+ { class: 'coin' },
+ h('input', {
+ type: 'checkbox', checked,
+ onChange: (e) => { e.target.checked ? s.coins.add(id) : s.coins.delete(id); render(); },
+ }),
+ h('div', { class: 'grow' },
+ h('div', { class: 'mono small break' }, shortAddr(u.address, 14, 10)),
+ h('div', { class: 'path' }, `${u.chain}/${u.index} · ${shortTxid(u.txid)}:${u.vout}`)
+ ),
+ h('div', { class: 'amount small' }, fmtBtc(u.value))
+ );
+ });
+ return h(
+ 'div',
+ { class: 'col gap6' },
+ head,
+ h('div', { class: 'list' }, rows),
+ h('div', { class: 'row between small' },
+ h('span', { class: 'muted' }, `${s.coins.size} selected`),
+ h('span', { class: 'amount' }, fmtBtc(selTotal) + ' BTC')
+ )
+ );
+}
+
+function currentFeeRate() {
+ const s = ui.send;
+ if (s.feeChoice === 'custom') return Math.max(1, Math.round(Number(s.customFee) || 1));
+ const fr = wallet.feeRates;
+ if (fr && fr[s.feeChoice]) return fr[s.feeChoice];
+ return 5;
+}
+
+function reviewSend() {
+ ui.sendError = '';
+ try {
+ const s = ui.send;
+ if (!s.address.trim()) throw new Error('Enter a recipient address.');
+ const feeRate = currentFeeRate();
+ let coinIds = null;
+ if (s.manual) {
+ coinIds = [...s.coins];
+ if (!coinIds.length) throw new Error('Select at least one coin to spend.');
+ }
+ let recipients, sendMax = false;
+ if (s.max) {
+ recipients = [{ address: s.address.trim(), amount: 0 }];
+ sendMax = true;
+ } else {
+ const sats = parseAmount(s.amount, s.unit);
+ if (!sats || sats <= 0) throw new Error('Enter a valid amount.');
+ recipients = [{ address: s.address.trim(), amount: sats }];
+ }
+ ui.draft = wallet.buildTx({ recipients, feeRate, coinIds, sendMax });
+ } catch (e) {
+ ui.draft = null;
+ ui.sendError = e.message;
+ }
+ render();
+}
+
+function reviewView() {
+ const d = ui.draft;
+ const changeAddr = wallet.freshChange().address;
+ const recipientOuts = d.outputs.filter((o) => o.address !== changeAddr);
+ const rows = recipientOuts.map((o) =>
+ h('div', { class: 'line' }, h('span', { class: 'k mono break' }, shortAddr(o.address, 16, 10)), h('span', { class: 'v' }, fmtBtc(o.amount)))
+ );
+ const sent = recipientOuts.reduce((s, o) => s + o.amount, 0);
+ return h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, 'Review transaction'),
+ h(
+ 'div',
+ { class: 'summary col', style: 'gap:0' },
+ h('div', { class: 'line' }, h('span', { class: 'k' }, 'Sending'), h('span', { class: 'v' }, fmtBtc(sent) + ' BTC')),
+ ...rows,
+ h('div', { class: 'line' }, h('span', { class: 'k' }, 'Network fee'), h('span', { class: 'v' }, fmtSats(d.fee) + ' sats')),
+ h('div', { class: 'line' }, h('span', { class: 'k' }, 'Inputs / vsize'), h('span', { class: 'v' }, `${d.inputsCount} in · ${d.vsize} vB`)),
+ d.hasChange && h('div', { class: 'line' }, h('span', { class: 'k' }, 'Change returns to'), h('span', { class: 'v mono' }, shortAddr(changeAddr, 10, 8))),
+ h('div', { class: 'line' }, h('span', { class: 'k' }, 'Total cost'), h('span', { class: 'v' }, fmtBtc(sent + d.fee) + ' BTC'))
+ ),
+ ui.sendError && h('div', { class: 'notice err' }, ui.sendError),
+ wallet.offline
+ ? h('div', { class: 'notice info' }, 'Offline: sign now, then broadcast the signed transaction from an online device.')
+ : null,
+ h(
+ 'div',
+ { class: 'row gap6' },
+ h('button', { class: 'btn-ghost', onClick: () => { ui.draft = null; ui.sendError = ''; render(); } }, '← Back'),
+ ui.busy
+ ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
+ : wallet.offline
+ ? h('button', { class: 'btn-primary grow', onClick: signForExport }, 'Sign transaction')
+ : h('button', { class: 'btn-primary grow', onClick: broadcast }, 'Sign & broadcast')
+ )
+ );
+}
+
+async function broadcast() {
+ ui.busy = true;
+ ui.sendError = '';
+ render();
+ try {
+ const hexTx = wallet.sign(ui.draft.tx);
+ const txid = await wallet.broadcast(hexTx);
+ ui.sendResult = { txid };
+ ui.draft = null;
+ ui.send = blankSend();
+ await wallet.scan();
+ } catch (e) {
+ ui.sendError = 'Broadcast failed: ' + e.message;
+ }
+ ui.busy = false;
+ render();
+}
+
+function signForExport() {
+ ui.sendError = '';
+ try {
+ const tx = ui.draft.tx;
+ const hexTx = wallet.sign(tx);
+ ui.sendResult = { signedHex: hexTx, txid: tx.id };
+ ui.draft = null;
+ ui.send = blankSend();
+ } catch (e) {
+ ui.sendError = 'Signing failed: ' + e.message;
+ }
+ render();
+}
+
+function sendResultView() {
+ const r = ui.sendResult;
+ const again = h('button', { class: 'btn-block mt8', onClick: () => { ui.sendResult = null; render(); } }, 'Done');
+ if (r.signedHex) {
+ return h(
+ 'div',
+ { class: 'card col' },
+ h('div', { class: 'notice ok' }, '✓ Transaction signed. Broadcast the hex below from any online device (e.g. mempool.space → Broadcast).'),
+ h('div', { class: 'small muted' }, 'Transaction ID'),
+ h('div', { class: 'addr-box' }, r.txid),
+ h('div', { class: 'small muted mt8' }, 'Signed transaction (raw hex)'),
+ h('textarea', { readonly: true, style: 'min-height:120px', value: r.signedHex }),
+ h('div', { class: 'row gap6' },
+ copyBtn(r.signedHex, 'Copy hex'),
+ h('button', { class: 'btn-sm', onClick: () => download(`tx-${r.txid.slice(0, 8)}.txt`, r.signedHex, 'text/plain') }, 'Download'),
+ h('div', { class: 'grow', html: '' })
+ ),
+ h('details', { class: 'mt8' }, h('summary', { class: 'small muted' }, 'Show QR (for air-gapped transfer)'), h('div', { style: 'margin-top:10px', html: qrSvg(r.signedHex) })),
+ again
+ );
+ }
+ return h(
+ 'div',
+ { class: 'card col', style: 'align-items:center' },
+ h('div', { class: 'notice ok', style: 'width:100%' }, '✓ Transaction broadcast!'),
+ h('div', { class: 'small muted' }, 'Transaction ID'),
+ h('div', { class: 'addr-box' }, r.txid),
+ h('div', { class: 'row gap6' },
+ copyBtn(r.txid, 'Copy txid'),
+ h('a', { class: 'btn btn-sm', href: wallet.api.explorerTx(r.txid), target: '_blank', rel: 'noopener' }, 'View on mempool.space ↗')
+ ),
+ again
+ );
+}
+
+// ---------------------------------------------------------------- History
+function historyTab() {
+ if (wallet.offline)
+ return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, 'Transaction history is unavailable in offline mode.'));
+ if (wallet.scanning) return h('div', { class: 'card center' }, h('span', { class: 'spinner' }));
+ if (!wallet.txs.length)
+ return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, 'No transactions yet.'));
+ return h(
+ 'div',
+ { class: 'card' },
+ h(
+ 'div',
+ { class: 'list' },
+ wallet.txs.map((t) => {
+ const incoming = t.net >= 0;
+ const display = incoming ? t.net : t.net; // net already signed
+ return h(
+ 'a',
+ { class: 'item', href: wallet.api.explorerTx(t.txid), target: '_blank', rel: 'noopener', style: 'color:inherit' },
+ h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, incoming ? '↓' : '↑'),
+ h('div', { class: 'grow' },
+ h('div', {}, incoming ? 'Received' : 'Sent'),
+ h('div', { class: 'small faint' }, t.confirmed ? timeAgo(t.blockTime) : 'unconfirmed')
+ ),
+ h('div', { style: 'text-align:right' },
+ h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtBtc(display)),
+ !incoming && t.fee ? h('div', { class: 'small faint' }, `fee ${fmtSats(t.fee)}`) : null
+ )
+ );
+ })
+ )
+ );
+}
+
+// ---------------------------------------------------------------- Coins
+function coinsTab() {
+ if (wallet.scanning) return h('div', { class: 'card center' }, h('span', { class: 'spinner' }));
+ if (!wallet.utxos.length)
+ return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, wallet.offline ? 'Import a wallet snapshot in Tools to load coins.' : 'No coins (UTXOs) yet.'));
+ return h(
+ 'div',
+ { class: 'card' },
+ h('div', { class: 'row between', style: 'margin-bottom:8px' },
+ h('span', { class: 'muted small' }, `${wallet.utxos.length} coin${wallet.utxos.length > 1 ? 's' : ''}`),
+ h('span', { class: 'amount' }, fmtBtc(wallet.total) + ' BTC')
+ ),
+ h(
+ 'div',
+ { class: 'list' },
+ wallet.utxos.map((u) =>
+ h(
+ 'div',
+ { class: 'item' },
+ h('div', { class: 'grow' },
+ h('div', { class: 'mono small break' }, shortAddr(u.address, 16, 10)),
+ h('div', { class: 'path' }, `m/84'/${wallet.netCfg.coin}'/0'/${u.chain}/${u.index} · ${shortTxid(u.txid)}:${u.vout}`)
+ ),
+ h('div', { style: 'text-align:right' },
+ h('div', { class: 'amount' }, fmtBtc(u.value)),
+ h('span', { class: `tag ${u.confirmed ? 'conf' : 'pending'}` }, u.confirmed ? 'confirmed' : 'pending')
+ )
+ )
+ )
+ )
+ );
+}
+
+// ---------------------------------------------------------------- Tools
+function toolsTab() {
+ const coin = wallet.netCfg.coin;
+ return h(
+ 'div',
+ { class: 'col', style: 'gap:16px' },
+ // Connectivity
+ h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, 'Connectivity'),
+ h('p', { class: 'small muted', style: 'margin:0' }, wallet.offline
+ ? 'Offline: no network requests are made. Load coins from a snapshot file, sign locally, and broadcast elsewhere.'
+ : 'Online: fetching balances and history from mempool.space.'),
+ wallet.offline
+ ? h('button', { class: 'btn-primary', onClick: async () => { wallet.setOffline(false); toast('Going online…'); await wallet.scan(); wallet.startRealtime(); ui.tab = 'receive'; render(); } }, 'Go online & scan')
+ : h('button', { onClick: () => { wallet.setOffline(true); toast('Offline mode'); render(); } }, 'Switch to offline')
+ ),
+ // Offline snapshot exchange
+ h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, 'Offline transfer'),
+ h('p', { class: 'small muted', style: 'margin:0' }, 'On an online device, export a snapshot of your coins. Move the file to your offline device (with this wallet + seed) to spend without internet.'),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: wallet.offline ? '' : 'btn-primary', disabled: wallet.offline, onClick: exportSnapshot }, '⤓ Export snapshot'),
+ h('label', { class: 'btn btn-sm', style: 'cursor:pointer' }, 'Import snapshot…',
+ h('input', { type: 'file', accept: 'application/json,.json', style: 'display:none', onChange: importSnapshotFile })
+ )
+ ),
+ wallet.offline && wallet.utxos.length
+ ? h('div', { class: 'small muted' }, `Loaded ${wallet.utxos.length} coin(s), ${fmtBtc(wallet.total)} BTC from snapshot.`)
+ : null
+ ),
+ // Wallet info
+ h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, 'Wallet'),
+ infoLine('Network', wallet.netName),
+ infoLine('Type', 'BIP84 · Native SegWit (p2wpkh)'),
+ infoLine('Account path', `m/84'/${coin}'/0'`),
+ !wallet.offline && infoLine('Receive addresses scanned', String(wallet.receive.length)),
+ h('button', { class: 'btn-danger mt8', onClick: lock }, '🔒 Lock wallet')
+ )
+ );
+}
+
+function infoLine(k, v) {
+ return h('div', { class: 'row between small' }, h('span', { class: 'muted' }, k), h('span', { class: 'mono' }, v));
+}
+
+function exportSnapshot() {
+ const snap = wallet.exportSnapshot();
+ const stamp = new Date().toISOString().slice(0, 10);
+ download(`wallet-snapshot-${wallet.netName}-${stamp}.json`, JSON.stringify(snap, null, 2));
+ toast('Snapshot exported');
+}
+
+async function importSnapshotFile(e) {
+ const file = e.target.files[0];
+ if (!file) return;
+ try {
+ const snap = JSON.parse(await file.text());
+ const res = wallet.importSnapshot(snap);
+ let msg = `Imported ${res.imported} coin(s)`;
+ if (res.unmatched.length) msg += ` · ${res.unmatched.length} address(es) not in this wallet`;
+ toast(msg);
+ ui.tab = 'coins';
+ render();
+ } catch (err) {
+ toast('Import failed: ' + err.message);
+ }
+ e.target.value = '';
+}
+
+// ================================================================ start
+render();
diff --git a/src/format.js b/src/format.js
new file mode 100644
index 0000000..6191033
--- /dev/null
+++ b/src/format.js
@@ -0,0 +1,56 @@
+// Small formatting helpers. Internally amounts are integer satoshis (Number;
+// safe because max BTC supply ~2.1e15 sats < Number.MAX_SAFE_INTEGER).
+
+export const SATS = 100_000_000;
+
+export function btc(sats) {
+ return (Number(sats) / SATS).toFixed(8);
+}
+
+// "0.01234500" with the trailing-zero portion dimmed is done in CSS; here we
+// just produce a clean fixed-8 string.
+export function fmtBtc(sats) {
+ return btc(sats);
+}
+
+export function fmtSats(sats) {
+ return Number(sats).toLocaleString('en-US');
+}
+
+export function fmtUsd(sats, price) {
+ if (!price) return '';
+ const usd = (Number(sats) / SATS) * price;
+ return usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
+}
+
+// Parse a user-entered amount. Accepts BTC (default) or sats.
+export function parseAmount(value, unit) {
+ const n = Number(String(value).trim().replace(/,/g, ''));
+ if (!isFinite(n) || n < 0) return null;
+ return unit === 'sats' ? Math.round(n) : Math.round(n * SATS);
+}
+
+export function shortAddr(a, head = 10, tail = 8) {
+ if (!a || a.length <= head + tail + 1) return a;
+ return `${a.slice(0, head)}…${a.slice(-tail)}`;
+}
+
+export function shortTxid(t) {
+ return shortAddr(t, 8, 8);
+}
+
+export function timeAgo(unixSeconds) {
+ if (!unixSeconds) return 'pending';
+ const s = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
+ const units = [
+ [31536000, 'y'],
+ [2592000, 'mo'],
+ [86400, 'd'],
+ [3600, 'h'],
+ [60, 'm'],
+ ];
+ for (const [secs, label] of units) {
+ if (s >= secs) return `${Math.floor(s / secs)}${label} ago`;
+ }
+ return `${s}s ago`;
+}
diff --git a/src/lib/Copy.svelte b/src/lib/Copy.svelte
deleted file mode 100644
index c218030..0000000
--- a/src/lib/Copy.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
- let { text = $bindable() } = $props();
- let justCopied = $state();
- const copy = () => {
- navigator.clipboard.writeText(text);
- justCopied = true;
- setTimeout(() => (justCopied = false), 1000);
- };
-</script>
-
-<div data-tip="Copied!" class:tooltip={justCopied} class="grow !w-auto">
- <button type="button" class="btn w-full" onclick={copy}>
- <iconify-icon icon="ph:copy-bold" width={32} />
- </button>
-</div>
diff --git a/src/lib/index.ts b/src/lib/index.ts
deleted file mode 100644
index c05c77e..0000000
--- a/src/lib/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { browser } from "$app/environment";
-import { writable } from "svelte/store";
-
-export const focus = (el: HTMLElement) =>
- browser && screen.width > 1280 && setTimeout(() => el.focus(), 1);
-
-export const mnemonic = writable();
-export const password = writable();
-export const address = writable();
diff --git a/src/qr.js b/src/qr.js
new file mode 100644
index 0000000..eab74a7
--- /dev/null
+++ b/src/qr.js
@@ -0,0 +1,27 @@
+// QR rendering via the zero-dependency `qrcode-generator` library, output as a
+// crisp SVG path (scales perfectly, no canvas, prints well on a paper wallet).
+
+import qrcode from 'qrcode-generator';
+
+export function qrSvg(text, { margin = 2 } = {}) {
+ const qr = qrcode(0, 'M'); // type 0 = auto-size, error correction M
+ qr.addData(text);
+ qr.make();
+
+ const count = qr.getModuleCount();
+ const size = count + margin * 2;
+
+ let path = '';
+ for (let r = 0; r < count; r++) {
+ for (let c = 0; c < count; c++) {
+ if (qr.isDark(r, c)) {
+ path += `M${c + margin},${r + margin}h1v1h-1z`;
+ }
+ }
+ }
+
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" class="qr">
+ <rect width="${size}" height="${size}" fill="#fff"/>
+ <path d="${path}" fill="#111"/>
+ </svg>`;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
deleted file mode 100644
index fe115b3..0000000
--- a/src/routes/+layout.svelte
+++ /dev/null
@@ -1,19 +0,0 @@
-<script lang="ts">
- import '../app.css';
-
- let { children } = $props();
-</script>
-
-<div
- class="container mx-auto max-w-3xl space-y-5 rounded-2xl bg-gray-100 p-8 text-xl shadow-lg"
- data-theme="light"
->
- <div class="flex items-center justify-center gap-2">
- <img src="/bitcoin.svg" />
- <div>
- <a href="/" class="mx-auto text-2xl">Adamant</a>
- </div>
- </div>
-
- {@render children()}
-</div>
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
deleted file mode 100644
index 89da957..0000000
--- a/src/routes/+layout.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const ssr = false;
-export const prerender = true;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
deleted file mode 100644
index 4694a63..0000000
--- a/src/routes/+page.svelte
+++ /dev/null
@@ -1,342 +0,0 @@
-<script>
- import { hex } from '@scure/base';
- import { HDKey } from '@scure/bip32';
- import * as btc from '@scure/btc-signer';
- import { goto } from '$app/navigation';
- import { onMount, tick } from 'svelte';
- import { focus } from '$lib';
- import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
- import { wordlist } from '@scure/bip39/wordlists/english';
- import Copy from '$lib/Copy.svelte';
-
- import 'iconify-icon';
-
- import { getAddress, p2wpkh } from '@scure/btc-signer/payment';
-
- const pastePassword = async () => {
- password = await navigator.clipboard.readText();
- };
-
- const pasteSeed = async () => {
- mnemonic = await navigator.clipboard.readText();
- };
-
- const togglePassword = () => (revealPassword = !revealPassword);
- const toggleSeed = () => (revealSeed = !revealSeed);
-
- let NET = 'bitcoin';
- const versions = {
- bitcoin: {
- private: 0x0488ade4,
- public: 0x0488b21e
- },
- regtest: {
- private: 0x04358394,
- public: 0x043587cf
- }
- }[NET];
-
- const network = {
- bitcoin: {
- bech32: 'bc',
- pubKeyHash: 0x00,
- scriptHash: 0x05,
- wif: 0x80
- },
- regtest: {
- bech32: 'bcrt',
- pubKeyHash: 0x6f,
- scriptHash: 0xc4,
- wif: 0xef
- }
- }[NET];
-
- let fees, rate;
- let utxos = [];
-
- let api = 'https://mempool.space/api';
- let amt = $state();
- // let destination = $state('bc1qqz6nlwxwmfqw0v864p5sggqst2xf0mevtyc579');
- // let password = $state('123');
- // let mnemonic = $state(
- // 'lizard armor follow dinner quarter talent truly among column rebuild arrest mix'
- // );
- // let withdrawing = $state(true);
- let destination = $state();
- let password = $state('');
- let mnemonic = $state('');
- let withdrawing = $state();
- let revealPassword = $state(false);
- let revealSeed = $state(true);
-
- let balance = $state();
- let pending = $state();
-
- let loading = $state();
-
- let generate = async () => {
- mnemonic = await generateMnemonic(wordlist);
- };
-
- onMount(async () => {
- // setTimeout(checkBalance, 1000);
-
- if (NET === 'regtest') {
- fees = { fastestFee: 281, halfHourFee: 271, hourFee: 256, economyFee: 44, minimumFee: 22 };
- } else {
- fees = await fetch(`${api}/v1/fees/recommended`).then((r) => r.json());
- }
-
- rate = fees['halfHourFee'];
- });
-
- let getHex = async (txid) => {
- if (txid instanceof Uint8Array) txid = hex.encode(txid);
- return hex.decode(await fetch(`${api}/tx/${txid}/hex`).then((r) => r.text()));
- };
-
- let node = $derived.by(() => {
- if (!mnemonic) return;
- try {
- const seed = mnemonicToSeedSync(mnemonic.trim(), password.trim());
- const root = HDKey.fromMasterSeed(seed, versions);
- return root.derive(`m/84'/0'/0'/0/0`);
- } catch (e) {}
- });
-
- let address = $derived(node ? p2wpkh(node.publicKey).address : undefined);
- let txid = $state();
-
- let checkBalance = async (e) => {
- e?.preventDefault();
- loading = true;
- utxos = await fetch(`${api}/address/${address}/utxo`).then((r) => r.json());
-
- balance = 0;
- pending = 0;
-
- for (let { value, status } of utxos) {
- if (status.confirmed) balance += value;
- else pending += value;
- }
-
- loading = false;
- };
-
- let send = async () => {
- let sats = 100000000;
- let b = (n) => (Number(n) / sats).toFixed(8);
- let inputs = [];
- let outputs = [];
- let fee, tx;
-
- let i = 0;
- let total = 0n;
- let amount = BigInt(amt);
-
- while (total < amount) {
- total += BigInt(utxos[i].value);
- i++;
- if (i > utxos.length) throw new Error('insufficient funds');
- }
-
- let change = total - amount;
-
- tx = new btc.Transaction();
-
- for await (let { input, vout, txid } of utxos.slice(0, i)) {
- tx.addInput({
- txid,
- index: vout,
- nonWitnessUtxo: await getHex(txid),
- sequence: 0xfffffffd
- });
- }
-
- tx.addOutputAddress(destination, amount, network);
- tx.addOutputAddress(address, change, network);
-
- console.log(tx.unsignedTx.length);
-
- while (i <= utxos.length) {
- fee = BigInt(rate) * BigInt(tx.unsignedTx.length * 2);
-
- if (fee <= change) {
- let q = new btc.Transaction();
- for (let i = 0; i < tx.inputsLength; i++) {
- let input = tx.getInput(i);
-
- let nonWitnessUtxo = await getHex(input.txid);
- q.addInput({ ...input, nonWitnessUtxo });
- }
- q.addOutput(tx.getOutput(0));
- q.addOutputAddress(address, change - fee, network);
- tx = q;
-
- break;
- } else {
- if (i === utxos.length) {
- if (fee > amount) throw new Error('insufficient funds');
- else {
- let q = new btc.Transaction();
- for (let i = 0; i < tx.inputsLength; i++) {
- let input = tx.getInput(i);
-
- let nonWitnessUtxo = await getHex(input.txid);
- q.addInput({ ...input, nonWitnessUtxo });
- }
-
- q.addOutputAddress(destination, total - fee, network);
-
- tx = q;
- break;
- }
- }
-
- let { txid, vout } = utxos[++i];
- tx.addInput({
- hash: txid,
- index: vout,
- nonWitnessUtxo: await getHex(txid)
- });
- }
- }
-
- let privkey = node.privateKey;
- for (let i = 0; i < tx.inputsLength; i++) {
- tx.signIdx(privkey, 0);
- let input = tx.getInput(i);
- let utxo = input.nonWitnessUtxo.outputs[input.index];
-
- inputs = [
- ...inputs,
- {
- address: btc.Address(network).encode(btc.OutScript.decode(utxo.script)),
- amount: b(utxo.amount)
- }
- ];
- }
-
- outputs = tx.outputs.map((o) => ({
- address: btc.Address(network).encode(btc.OutScript.decode(o.script)),
- amount: b(o.amount)
- }));
-
- tx.finalize();
-
- txid = await fetch(`${api}/tx`, {
- method: 'POST',
- body: tx.hex
- }).then((r) => r.text());
- };
-</script>
-
-<form class="space-y-5" onsubmit={checkBalance}>
- <div class="flex flex-wrap gap-2">
- {#if revealSeed}
- <textarea
- use:focus
- class="textarea w-full min-w-[200px] sm:w-auto"
- placeholder="Seed phrase"
- bind:value={mnemonic}
- ></textarea>
- {:else}
- <input
- type="password"
- class="input w-full min-w-[200px] grow sm:w-auto"
- placeholder="Seed phrase"
- bind:value={mnemonic}
- />
- {/if}
- <div class="flex flex-wrap w-full justify-center sm:w-auto">
- <button type="button" class="btn !w-auto grow" onclick={toggleSeed}>
- <iconify-icon icon={revealSeed ? 'ph:eye-bold' : 'ph:eye-slash-bold'} width={32} />
- </button>
- <Copy bind:text={mnemonic} />
- <button type="button" class="btn !w-auto grow" onclick={pasteSeed}>
- <iconify-icon icon="ph:clipboard-text-bold" width={32} />
- </button>
- <button type="button" class="btn !w-auto grow" onclick={generate}>
- <iconify-icon icon="ph:dice-five-bold" width={32} />
- </button>
- </div>
- </div>
-
- <div class="flex flex-wrap gap-2">
- {#if revealPassword}
- <input
- class="input w-full min-w-[200px] sm:w-auto"
- placeholder="Passphrase"
- bind:value={password}
- />
- {:else}
- <input
- type="password"
- class="input w-full min-w-[200px] sm:w-auto"
- placeholder="Passphrase"
- bind:value={password}
- />
- {/if}
-
- <div class="flex w-full justify-center sm:w-auto">
- <button type="button" class="btn !w-auto grow" onclick={togglePassword}>
- <iconify-icon icon={revealPassword ? 'ph:eye-bold' : 'ph:eye-slash-bold'} width={32} />
- </button>
-
- <Copy bind:text={password} />
-
- <button type="button" class="btn !w-auto grow" onclick={pastePassword}>
- <iconify-icon icon="ph:clipboard-text-bold" width={32} />
- </button>
- </div>
- </div>
-
- <div>
- {#if address}
- <div class="flex flex-wrap items-center gap-2 break-all text-base">
- <div>
- {address}
- </div>
-
- <div class="flex w-full justify-center sm:w-auto">
- <Copy bind:text={address} />
- <button type="submit" class="btn !w-auto grow ml-auto">
- <iconify-icon icon="ph:coins-bold" width={32} />
- </button>
- </div>
- </div>
- {/if}
- </div>
-</form>
-
-{#if loading}
- <div>
- <div class="loading loading-spinner"></div>
- </div>
-{:else if typeof balance !== 'undefined'}
- <div class="flex items-center gap-2">
- <div>
- Balance: {balance + pending} sats
- </div>
- {#if balance + pending > 0}
- <button type="submit" class="btn ml-auto" onclick={() => (withdrawing = true)}>
- Withdraw</button
- >
- {/if}
- </div>
-{/if}
-
-{#if txid}
- <div class="flex">
- <div>Transaction completed!</div>
- <div class="ml-auto">
- <a href={`https://mempool.space/tx/${txid}`} class="btn">Details</a>
- </div>
- </div>
-{:else if withdrawing}
- <input class="input w-full" placeholder="Destination address" bind:value={destination} />
-
- <input class="input w-full" placeholder="Amount" bind:value={amt} />
-
- <button type="submit" class="btn ml-auto" onclick={send}> Send</button>
-{/if}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..868b12a
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,217 @@
+/* Bitcoin Wallet — hand-rolled CSS. Clean light "paper wallet" look. */
+
+:root {
+ --bg: #eef0f3;
+ --paper: #ffffff;
+ --ink: #15171a;
+ --muted: #6b7280;
+ --faint: #9aa1ac;
+ --line: #e6e8ec;
+ --line-strong: #d4d8de;
+ --accent: #f7931a; /* bitcoin orange */
+ --accent-ink: #b5670a;
+ --accent-soft: #fff3e2;
+ --green: #14935a;
+ --green-soft: #e7f6ee;
+ --red: #c43b3b;
+ --red-soft: #fbeaea;
+ --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 10px 30px rgba(16, 24, 40, 0.07);
+ --radius: 16px;
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ -webkit-font-smoothing: antialiased;
+ font-size: 16px;
+ line-height: 1.45;
+}
+
+#app {
+ max-width: 560px;
+ margin: 0 auto;
+ padding: 24px 16px 64px;
+}
+
+/* ---- typography ---- */
+h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
+.muted { color: var(--muted); }
+.faint { color: var(--faint); }
+.small { font-size: 13px; }
+.mono { font-family: var(--mono); }
+.center { text-align: center; }
+.break { word-break: break-all; }
+a { color: var(--accent-ink); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ---- layout helpers ---- */
+.row { display: flex; align-items: center; gap: 10px; }
+.between { justify-content: space-between; }
+.wrap { flex-wrap: wrap; }
+.col { display: flex; flex-direction: column; gap: 10px; }
+.grow { flex: 1; }
+.gap6 { gap: 6px; }
+.mt8 { margin-top: 8px; } .mt16 { margin-top: 16px; } .mt24 { margin-top: 24px; }
+.hidden { display: none !important; }
+
+/* ---- brand / header ---- */
+.brand { display: flex; align-items: center; gap: 10px; }
+.logo {
+ width: 34px; height: 34px; border-radius: 9px; flex: none;
+ background: var(--accent); color: #fff; font-weight: 800; font-size: 20px;
+ display: grid; place-items: center;
+}
+.brand h1 { font-size: 19px; }
+
+.badge {
+ font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
+ border: 1px solid var(--line-strong); color: var(--muted); white-space: nowrap;
+}
+.badge.dot::before {
+ content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%;
+ background: var(--green); margin-right: 6px; vertical-align: middle;
+}
+.badge.off.dot::before { background: var(--faint); }
+.badge.live { color: var(--green); border-color: #c4e7d4; background: var(--green-soft); }
+.badge.live.dot::before { animation: pulse 1.5s ease-in-out infinite; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
+.badge.testnet { color: var(--accent-ink); border-color: var(--accent); background: var(--accent-soft); }
+
+/* ---- card ---- */
+.card {
+ background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius);
+ box-shadow: var(--shadow); padding: 20px;
+}
+.card + .card { margin-top: 16px; }
+
+/* ---- balance card ---- */
+.balance .amt { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; font-family: var(--mono); }
+.balance .amt .unit { font-size: 18px; color: var(--muted); margin-left: 6px; font-weight: 600; }
+.balance .usd { color: var(--muted); margin-top: 2px; }
+.balance .split { margin-top: 14px; display: flex; gap: 22px; }
+.balance .split .k { font-size: 12px; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; }
+.balance .split .v { font-family: var(--mono); font-size: 14px; }
+.balance .split .v.pending { color: var(--accent-ink); }
+
+/* ---- tabs ---- */
+.tabs { display: flex; gap: 4px; background: #e9ebef; border-radius: 12px; padding: 4px; margin: 16px 0; }
+.tabs button {
+ flex: 1; border: 0; background: transparent; padding: 9px 4px; border-radius: 9px;
+ font: inherit; font-weight: 600; font-size: 14px; color: var(--muted); cursor: pointer;
+}
+.tabs button.active { background: var(--paper); color: var(--ink); box-shadow: var(--shadow); }
+
+/* ---- buttons ---- */
+button, .btn {
+ font: inherit; font-weight: 600; cursor: pointer; border-radius: 11px;
+ border: 1px solid var(--line-strong); background: var(--paper); color: var(--ink);
+ padding: 11px 16px; transition: filter .12s, background .12s, border-color .12s;
+}
+button:hover { background: #f7f8fa; }
+button:active { transform: translateY(1px); }
+button:disabled { opacity: .5; cursor: not-allowed; }
+.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn-primary:hover { background: #ec8a12; }
+.btn-ghost { background: transparent; }
+.btn-sm { padding: 7px 11px; font-size: 13px; border-radius: 9px; }
+.btn-block { width: 100%; display: block; }
+.btn-danger { color: var(--red); border-color: var(--red); background: var(--red-soft); }
+.iconbtn { padding: 9px 11px; line-height: 1; }
+
+/* ---- inputs ---- */
+label.field { display: block; }
+.field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; }
+input[type=text], input[type=password], input[type=number], textarea, select {
+ width: 100%; font: inherit; color: var(--ink); background: var(--paper);
+ border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
+}
+input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
+textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-size: 15px; }
+.mono-input { font-family: var(--mono); }
+.input-group { display: flex; gap: 8px; }
+.input-group input { flex: 1; }
+
+/* unit toggle */
+.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
+.seg button { border: 0; border-radius: 0; padding: 9px 14px; background: var(--paper); }
+.seg button.active { background: var(--accent-soft); color: var(--accent-ink); }
+
+/* ---- address / qr ---- */
+.qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); }
+.addr-box {
+ font-family: var(--mono); font-size: 15px; background: #f7f8fa; border: 1px dashed var(--line-strong);
+ border-radius: 11px; padding: 12px 14px; word-break: break-all; text-align: center;
+}
+.path { font-family: var(--mono); font-size: 12px; color: var(--faint); }
+
+/* ---- mnemonic grid ---- */
+.words { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.words .w {
+ display: flex; align-items: baseline; gap: 7px; background: #f7f8fa;
+ border: 1px solid var(--line); border-radius: 10px; padding: 9px 11px; font-family: var(--mono);
+}
+.words .w .n { color: var(--faint); font-size: 12px; width: 18px; text-align: right; }
+.words .w .t { font-weight: 600; }
+.warn-box {
+ background: var(--accent-soft); border: 1px solid #f6d8a8; color: #7a4d05;
+ border-radius: 11px; padding: 12px 14px; font-size: 14px;
+}
+
+/* ---- lists (tx / utxo) ---- */
+.list { display: flex; flex-direction: column; }
+.item { display: flex; align-items: center; gap: 12px; padding: 13px 2px; border-top: 1px solid var(--line); }
+.item:first-child { border-top: 0; }
+.ico { width: 34px; height: 34px; border-radius: 9px; flex: none; display: grid; place-items: center; font-size: 16px; }
+.ico.in { background: var(--green-soft); color: var(--green); }
+.ico.out { background: var(--red-soft); color: var(--red); }
+.amount-pos { color: var(--green); font-family: var(--mono); font-weight: 600; }
+.amount-neg { color: var(--red); font-family: var(--mono); font-weight: 600; }
+.amount { font-family: var(--mono); font-weight: 600; }
+.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; }
+.tag.pending { background: var(--accent-soft); color: var(--accent-ink); }
+.tag.conf { background: var(--green-soft); color: var(--green); }
+
+/* coin control rows */
+.coin { display: flex; align-items: center; gap: 11px; padding: 11px 2px; border-top: 1px solid var(--line); }
+.coin:first-child { border-top: 0; }
+.coin input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent); }
+
+/* ---- review / summary ---- */
+.summary { background: #f7f8fa; border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
+.summary .line { display: flex; justify-content: space-between; padding: 4px 0; font-size: 14px; }
+.summary .line .k { color: var(--muted); }
+.summary .line .v { font-family: var(--mono); }
+
+/* ---- misc ---- */
+.notice { border-radius: 11px; padding: 11px 13px; font-size: 14px; }
+.notice.err { background: var(--red-soft); color: var(--red); border: 1px solid #f1c9c9; }
+.notice.ok { background: var(--green-soft); color: var(--green); border: 1px solid #c4e7d4; }
+.notice.info { background: #eef2ff; color: #3949ab; border: 1px solid #d4ddfb; }
+.spinner {
+ width: 18px; height: 18px; border: 2px solid var(--line-strong); border-top-color: var(--accent);
+ border-radius: 50%; animation: spin .7s linear infinite; display: inline-block; vertical-align: middle;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+.toast {
+ position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
+ background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 10px;
+ font-size: 14px; box-shadow: var(--shadow); z-index: 50; opacity: 0; pointer-events: none;
+ transition: opacity .2s, transform .2s;
+}
+.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+.divider { height: 1px; background: var(--line); margin: 16px 0; border: 0; }
+hr.divider { margin: 16px 0; }
+.linklike { background: none; border: 0; padding: 0; color: var(--accent-ink); font-weight: 600; cursor: pointer; }
+.linklike:hover { text-decoration: underline; background: none; }
+
+@media (max-width: 420px) {
+ .words { grid-template-columns: repeat(2, 1fr); }
+ .balance .amt { font-size: 28px; }
+}
diff --git a/src/wallet.js b/src/wallet.js
new file mode 100644
index 0000000..04f303a
--- /dev/null
+++ b/src/wallet.js
@@ -0,0 +1,565 @@
+// BIP84 (native segwit, p2wpkh) HD wallet core.
+//
+// Derivation: m / 84' / coin' / 0' / chain / index
+// chain 0 = receive (external) addresses
+// chain 1 = change (internal) addresses
+//
+// All signing uses witnessUtxo only (script + amount). That is all segwit needs,
+// which is what makes offline signing easy: we never have to fetch full previous
+// transactions, only the (txid, vout, value, address) of each UTXO.
+
+import { HDKey } from '@scure/bip32';
+import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { hex } from '@scure/base';
+import * as btc from '@scure/btc-signer';
+import { p2wpkh } from '@scure/btc-signer/payment';
+
+import { Api, pool } from './api.js';
+
+const GAP_LIMIT = 20; // BIP44 standard gap limit
+
+const NETS = {
+ mainnet: { net: btc.NETWORK, coin: 0 },
+ testnet: { net: btc.TEST_NETWORK, coin: 1 },
+};
+
+export function newMnemonic(strengthBits = 128) {
+ return generateMnemonic(wordlist, strengthBits);
+}
+
+export function isValidMnemonic(m) {
+ try {
+ return validateMnemonic(m.trim().replace(/\s+/g, ' '), wordlist);
+ } catch {
+ return false;
+ }
+}
+
+export class Wallet {
+ constructor() {
+ this.mnemonic = '';
+ this.passphrase = '';
+ this.netName = 'mainnet';
+
+ this.api = new Api('mainnet');
+ this.offline = false;
+
+ // Scanned chains: arrays of { chain, index, address, used }
+ this.receive = [];
+ this.change = [];
+ this.addrMap = new Map(); // address -> { chain, index }
+
+ this.utxos = []; // { txid, vout, value, address, chain, index, confirmed }
+ this.txs = []; // aggregated history
+ this.feeRates = null;
+ this.price = null;
+
+ this.scanning = false;
+ this.nextReceiveIndex = 0;
+ this.nextChangeIndex = 0;
+
+ this._account = null;
+ this._accountKey = '';
+ this._addrCache = new Map();
+
+ // Realtime (mempool.space WebSocket)
+ this.live = false;
+ this._ws = null;
+ this._wsWant = false;
+ this._wsRetry = null;
+ this._refreshTimer = null;
+
+ this._subs = new Set();
+ }
+
+ // --- reactive glue (tiny pub/sub; the UI re-renders on change) ----------
+ subscribe(fn) {
+ this._subs.add(fn);
+ return () => this._subs.delete(fn);
+ }
+ emit() {
+ for (const fn of this._subs) fn(this);
+ }
+
+ // --- setup --------------------------------------------------------------
+ load({ mnemonic, passphrase = '', netName = 'mainnet', offline = false }) {
+ this.stopRealtime();
+ this.mnemonic = mnemonic.trim().replace(/\s+/g, ' ');
+ this.passphrase = passphrase;
+ this.netName = netName;
+ this.offline = offline;
+ this.api = new Api(netName);
+ this.api.offline = offline;
+ this._account = null;
+ this._addrCache.clear();
+ this.reset();
+ }
+
+ reset() {
+ this.receive = [];
+ this.change = [];
+ this.addrMap = new Map();
+ this.utxos = [];
+ this.txs = [];
+ this.nextReceiveIndex = 0;
+ this.nextChangeIndex = 0;
+ }
+
+ setOffline(off) {
+ this.offline = off;
+ this.api.offline = off;
+ if (off) this.stopRealtime();
+ this.emit();
+ }
+
+ get netCfg() {
+ return NETS[this.netName];
+ }
+
+ // --- derivation ---------------------------------------------------------
+ account() {
+ const key = `${this.netName}|${this.mnemonic}|${this.passphrase}`;
+ if (this._account && this._accountKey === key) return this._account;
+ const seed = mnemonicToSeedSync(this.mnemonic, this.passphrase);
+ const root = HDKey.fromMasterSeed(seed);
+ const { coin } = this.netCfg;
+ this._account = root.derive(`m/84'/${coin}'/0'`);
+ this._accountKey = key;
+ this._addrCache.clear();
+ return this._account;
+ }
+
+ node(chain, index) {
+ // Relative derivation from the account node (chain/index are non-hardened).
+ return this.account().deriveChild(chain).deriveChild(index);
+ }
+
+ // Returns { address, script, pubkey }
+ derive(chain, index) {
+ const cacheKey = `${chain}/${index}`;
+ const hit = this._addrCache.get(cacheKey);
+ if (hit) return hit;
+ const node = this.node(chain, index);
+ const pay = p2wpkh(node.publicKey, this.netCfg.net);
+ const info = { address: pay.address, script: pay.script, pubkey: node.publicKey, chain, index };
+ this._addrCache.set(cacheKey, info);
+ return info;
+ }
+
+ // Derive a window of addresses on both chains and (re)build addrMap. Used in
+ // offline mode where we cannot scan for usage but still must map a UTXO's
+ // address back to its derivation path in order to sign it.
+ deriveWindow(count) {
+ this.addrMap = new Map();
+ for (const chain of [0, 1]) {
+ for (let i = 0; i < count; i++) {
+ const { address } = this.derive(chain, i);
+ this.addrMap.set(address, { chain, index: i });
+ }
+ }
+ }
+
+ // --- online scan --------------------------------------------------------
+ async scanChain(chain) {
+ const found = [];
+ let gap = 0;
+ let i = 0;
+ while (gap < GAP_LIMIT) {
+ const { address } = this.derive(chain, i);
+ const info = await this.api.addressInfo(address);
+ const used =
+ info.chain_stats.tx_count > 0 || info.mempool_stats.tx_count > 0;
+ found.push({ chain, index: i, address, used });
+ this.addrMap.set(address, { chain, index: i });
+ gap = used ? 0 : gap + 1;
+ i++;
+ }
+ return found;
+ }
+
+ async scan() {
+ if (this.offline) return;
+ this.scanning = true;
+ this.emit();
+ try {
+ this.addrMap = new Map();
+ [this.receive, this.change] = await Promise.all([
+ this.scanChain(0),
+ this.scanChain(1),
+ ]);
+ this.nextReceiveIndex = firstUnused(this.receive);
+ this.nextChangeIndex = firstUnused(this.change);
+
+ [this.feeRates, this.price] = await Promise.all([
+ this.api.feeRates(),
+ this.api.price(),
+ ]);
+
+ await this.refreshUtxos();
+ await this.refreshHistory();
+ } finally {
+ this.scanning = false;
+ this.emit();
+ }
+ }
+
+ usedAddresses() {
+ return [...this.receive, ...this.change].filter((a) => a.used);
+ }
+
+ async refreshUtxos() {
+ const used = this.usedAddresses();
+ const lists = await pool(used, (a) => this.api.addressUtxos(a.address));
+ const utxos = [];
+ used.forEach((a, idx) => {
+ for (const u of lists[idx]) {
+ utxos.push({
+ txid: u.txid,
+ vout: u.vout,
+ value: u.value,
+ address: a.address,
+ chain: a.chain,
+ index: a.index,
+ confirmed: !!u.status.confirmed,
+ });
+ }
+ });
+ utxos.sort((a, b) => b.value - a.value);
+ this.utxos = utxos;
+ }
+
+ async refreshHistory() {
+ const used = this.usedAddresses();
+ const lists = await pool(used, (a) => this.api.addressTxs(a.address));
+ const mine = new Set(this.addrMap.keys());
+ const byId = new Map();
+ for (const list of lists) {
+ for (const tx of list) {
+ if (byId.has(tx.txid)) continue;
+ let received = 0;
+ let sent = 0;
+ for (const vin of tx.vin) {
+ const a = vin.prevout && vin.prevout.scriptpubkey_address;
+ if (a && mine.has(a)) sent += vin.prevout.value;
+ }
+ for (const vout of tx.vout) {
+ if (vout.scriptpubkey_address && mine.has(vout.scriptpubkey_address))
+ received += vout.value;
+ }
+ byId.set(tx.txid, {
+ txid: tx.txid,
+ net: received - sent, // >0 incoming, <0 outgoing
+ fee: tx.fee || 0,
+ confirmed: !!tx.status.confirmed,
+ blockTime: tx.status.block_time || 0,
+ blockHeight: tx.status.block_height || 0,
+ });
+ }
+ }
+ const txs = [...byId.values()];
+ txs.sort((a, b) => {
+ if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first
+ return (b.blockTime || 0) - (a.blockTime || 0);
+ });
+ this.txs = txs;
+ }
+
+ // --- balances -----------------------------------------------------------
+ get confirmed() {
+ return this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0);
+ }
+ get pending() {
+ return this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0);
+ }
+ get total() {
+ return this.confirmed + this.pending;
+ }
+
+ freshReceive() {
+ return this.derive(0, this.nextReceiveIndex);
+ }
+ freshChange() {
+ return this.derive(1, this.nextChangeIndex);
+ }
+
+ // --- spending -----------------------------------------------------------
+ // recipients: [{ address, amount(sats) }]
+ // coinIds: optional array of "txid:vout" to force manual coin control.
+ // sendMax: drain all selected coins to the single recipient.
+ buildTx({ recipients, feeRate, coinIds = null, sendMax = false }) {
+ const pool_ = coinIds
+ ? this.utxos.filter((u) => coinIds.includes(utxoId(u)))
+ : this.utxos.slice();
+ if (!pool_.length) throw new Error('No spendable coins selected.');
+
+ const inputs = pool_.map((u) => {
+ const { script, pubkey } = this.derive(u.chain, u.index);
+ const pay = p2wpkh(pubkey, this.netCfg.net);
+ return {
+ ...pay,
+ txid: u.txid,
+ index: u.vout,
+ witnessUtxo: { script: pay.script, amount: BigInt(u.value) },
+ };
+ });
+
+ const changeAddress = this.freshChange().address;
+ const feePerByte = BigInt(Math.max(1, Math.round(feeRate)));
+
+ if (sendMax) {
+ if (recipients.length !== 1)
+ throw new Error('Send-max requires exactly one recipient.');
+ // Drain everything: use the recipient as the "change" address with no
+ // fixed outputs, so the estimator sends (total − exact fee) to them.
+ const dest = recipients[0].address;
+ const sel = btc.selectUTXO(inputs, [], 'all', {
+ changeAddress: dest,
+ feePerByte,
+ network: this.netCfg.net,
+ createTx: true,
+ bip69: true,
+ });
+ if (!sel || !sel.tx) throw new Error('Fee exceeds balance.');
+ return summarize(sel, this.netCfg.net);
+ }
+
+ const outputs = recipients.map((r) => ({
+ address: r.address,
+ amount: BigInt(r.amount),
+ }));
+ const strategy = coinIds ? 'all' : 'default';
+ const sel = btc.selectUTXO(inputs, outputs, strategy, {
+ changeAddress,
+ feePerByte,
+ network: this.netCfg.net,
+ createTx: true,
+ bip69: true,
+ });
+ if (!sel || !sel.tx)
+ throw new Error('Insufficient funds for amount + fee.');
+ return summarize(sel, this.netCfg.net);
+ }
+
+ // Sign every input of an already-built Transaction with the matching HD key.
+ sign(tx) {
+ const byOutpoint = new Map();
+ for (const u of this.utxos) byOutpoint.set(utxoId(u), u);
+ for (let i = 0; i < tx.inputsLength; i++) {
+ const inp = tx.getInput(i);
+ const id = `${hex.encode(inp.txid)}:${inp.index}`;
+ const u = byOutpoint.get(id);
+ if (!u) throw new Error(`Cannot find key for input ${id}`);
+ const node = this.node(u.chain, u.index);
+ tx.signIdx(node.privateKey, i);
+ }
+ tx.finalize();
+ return tx.hex;
+ }
+
+ async broadcast(hexTx) {
+ return this.api.broadcast(hexTx);
+ }
+
+ // --- realtime (mempool.space WebSocket) ---------------------------------
+ // Pushes us new mempool/confirmed transactions for our addresses so history
+ // and balances update with no polling.
+ wsUrl() {
+ return this.netName === 'testnet'
+ ? 'wss://mempool.space/testnet/api/v1/ws'
+ : 'wss://mempool.space/api/v1/ws';
+ }
+
+ // Addresses worth watching: those holding coins (to catch spends) plus a
+ // window of upcoming fresh receive/change addresses (to catch deposits).
+ watchedAddresses() {
+ const set = new Set();
+ for (const u of this.utxos) set.add(u.address);
+ for (let i = 0; i < 10; i++) set.add(this.derive(0, this.nextReceiveIndex + i).address);
+ for (let i = 0; i < 5; i++) set.add(this.derive(1, this.nextChangeIndex + i).address);
+ return [...set];
+ }
+
+ startRealtime() {
+ if (this.offline || typeof WebSocket === 'undefined') return;
+ this.stopRealtime();
+ this._wsWant = true;
+ this._connectWs();
+ }
+
+ _connectWs() {
+ if (!this._wsWant) return;
+ let ws;
+ try {
+ ws = new WebSocket(this.wsUrl());
+ } catch {
+ this._scheduleReconnect();
+ return;
+ }
+ this._ws = ws;
+ ws.onopen = () => {
+ this.live = true;
+ this.retrack();
+ this.emit();
+ };
+ ws.onmessage = (ev) => {
+ let msg;
+ try {
+ msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
+ } catch {
+ return;
+ }
+ // React only to address/block events — never the global tx firehose.
+ if (
+ msg['multi-address-transactions'] ||
+ msg['address-transactions'] ||
+ msg['block-transactions'] ||
+ msg['txConfirmed']
+ ) {
+ this._scheduleRefresh();
+ }
+ };
+ ws.onerror = () => {
+ try {
+ ws.close();
+ } catch {}
+ };
+ ws.onclose = () => {
+ this.live = false;
+ this._ws = null;
+ this.emit();
+ this._scheduleReconnect();
+ };
+ }
+
+ retrack() {
+ if (this._ws && this._ws.readyState === 1) {
+ try {
+ this._ws.send(JSON.stringify({ 'track-addresses': this.watchedAddresses() }));
+ } catch {}
+ }
+ }
+
+ _scheduleReconnect() {
+ if (!this._wsWant) return;
+ clearTimeout(this._wsRetry);
+ this._wsRetry = setTimeout(() => this._connectWs(), 4000);
+ }
+
+ // Debounced: a payment may touch several of our addresses in one go.
+ _scheduleRefresh() {
+ clearTimeout(this._refreshTimer);
+ this._refreshTimer = setTimeout(async () => {
+ if (this.scanning || this.offline) return;
+ try {
+ await this.scan();
+ this.retrack();
+ } catch {}
+ }, 1200);
+ }
+
+ stopRealtime() {
+ this._wsWant = false;
+ this.live = false;
+ clearTimeout(this._wsRetry);
+ clearTimeout(this._refreshTimer);
+ if (this._ws) {
+ try {
+ this._ws.onclose = null;
+ this._ws.close();
+ } catch {}
+ this._ws = null;
+ }
+ }
+
+ // --- offline snapshot ---------------------------------------------------
+ // Exported on the ONLINE device. Contains no secrets — just what an offline
+ // signer needs: coins, fee rates, and which addresses are next/fresh.
+ exportSnapshot() {
+ return {
+ app: 'bitcoin-wallet',
+ version: 1,
+ netName: this.netName,
+ exportedAt: new Date().toISOString(),
+ feeRates: this.feeRates,
+ price: this.price,
+ nextReceiveIndex: this.nextReceiveIndex,
+ nextChangeIndex: this.nextChangeIndex,
+ utxos: this.utxos.map((u) => ({
+ txid: u.txid,
+ vout: u.vout,
+ value: u.value,
+ address: u.address,
+ confirmed: u.confirmed,
+ })),
+ };
+ }
+
+ // Imported on the OFFLINE device (which already has the seed loaded).
+ importSnapshot(snap) {
+ if (!snap || !Array.isArray(snap.utxos))
+ throw new Error('Not a valid wallet snapshot file.');
+ if (snap.netName && snap.netName !== this.netName)
+ throw new Error(
+ `Snapshot is for ${snap.netName} but wallet is ${this.netName}.`
+ );
+
+ this.feeRates = snap.feeRates || this.feeRates;
+ this.price = snap.price || this.price;
+ this.nextReceiveIndex = snap.nextReceiveIndex || 0;
+ this.nextChangeIndex = snap.nextChangeIndex || 0;
+
+ // Derive a generous window so every UTXO address resolves to a path.
+ const maxIdx = snap.utxos.reduce((m, _u) => m, 0);
+ const window = Math.max(this.nextReceiveIndex, this.nextChangeIndex, maxIdx) + GAP_LIMIT + 5;
+ this.deriveWindow(window);
+
+ const utxos = [];
+ const unmatched = [];
+ for (const u of snap.utxos) {
+ const path = this.addrMap.get(u.address);
+ if (!path) {
+ unmatched.push(u.address);
+ continue;
+ }
+ utxos.push({
+ txid: u.txid,
+ vout: u.vout,
+ value: u.value,
+ address: u.address,
+ chain: path.chain,
+ index: path.index,
+ confirmed: !!u.confirmed,
+ });
+ }
+ utxos.sort((a, b) => b.value - a.value);
+ this.utxos = utxos;
+ this.emit();
+ return { imported: utxos.length, unmatched };
+ }
+}
+
+export function utxoId(u) {
+ return `${u.txid}:${u.vout}`;
+}
+
+function firstUnused(chain) {
+ const u = chain.find((a) => !a.used);
+ return u ? u.index : chain.length;
+}
+
+function summarize(sel, network) {
+ const outputs = sel.tx.outputs.map((o) => ({
+ address: btc.Address(network).encode(btc.OutScript.decode(o.script)),
+ amount: Number(o.amount),
+ }));
+ return {
+ tx: sel.tx,
+ fee: Number(sel.fee),
+ hasChange: !!sel.change,
+ inputsCount: sel.tx.inputsLength,
+ outputs,
+ weight: sel.weight,
+ vsize: Math.ceil(Number(sel.weight) / 4),
+ };
+}
diff --git a/static/bg.png b/static/bg.png
deleted file mode 100644
index f542416..0000000
--- a/static/bg.png
+++ /dev/null
Binary files differ
diff --git a/static/bg.webp b/static/bg.webp
deleted file mode 100644
index 290cb41..0000000
--- a/static/bg.webp
+++ /dev/null
Binary files differ
diff --git a/static/bitcoin.svg b/static/bitcoin.svg
deleted file mode 100644
index 04079be..0000000
--- a/static/bitcoin.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
-<g transform="translate(0.00630876,-0.00301984)">
-<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
-<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
-</g>
-</svg>
diff --git a/static/favicon.svg b/static/favicon.svg
deleted file mode 100644
index cc5dc66..0000000
--- a/static/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> \ No newline at end of file
diff --git a/static/random.svg b/static/random.svg
deleted file mode 100644
index f3e5923..0000000
--- a/static/random.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<title>perspective-dice-random</title>
-<path d="M15.676 17.312h0.048c-0.114-0.273-0.263-0.539-0.436-0.78l-11.114-6.346c-0.37 0.13-0.607 0.519-0.607 1.109v9.84c0 1.034 0.726 2.291 1.621 2.808l9.168 5.294c0.544 0.314 1.026 0.282 1.32-0.023v-11.902h-0zM10.049 24.234l-1.83-1.057v-1.918l1.83 1.057v1.918zM11.605 19.993c-0.132 0.2-0.357 0.369-0.674 0.505l-0.324 0.12c-0.23 0.090-0.38 0.183-0.451 0.278-0.071 0.092-0.106 0.219-0.106 0.38v0.242l-1.83-1.056v-0.264c0-0.294 0.056-0.523 0.167-0.685 0.111-0.165 0.346-0.321 0.705-0.466l0.324-0.125c0.193-0.076 0.333-0.171 0.421-0.285 0.091-0.113 0.137-0.251 0.137-0.417 0-0.251-0.081-0.494-0.243-0.728-0.162-0.237-0.389-0.44-0.679-0.608-0.274-0.158-0.569-0.268-0.887-0.329-0.318-0.065-0.649-0.078-0.994-0.040v-1.691c0.409 0.085 0.782 0.19 1.12 0.313s0.664 0.276 0.978 0.457c0.825 0.476 1.453 1.019 1.886 1.627 0.433 0.605 0.649 1.251 0.649 1.937 0 0.352-0.066 0.63-0.198 0.834zM27.111 8.247l-9.531-5.514c-0.895-0.518-2.346-0.518-3.241 0l-9.531 5.514c-0.763 0.442-0.875 1.117-0.336 1.628l10.578 6.040c0.583 0.146 1.25 0.145 1.832-0.003l10.589-6.060c0.512-0.508 0.392-1.17-0.36-1.605zM16.305 10.417l-0.23-0.129c-0.257-0.144-0.421-0.307-0.492-0.488-0.074-0.183-0.062-0.474 0.037-0.874l0.095-0.359c0.055-0.214 0.061-0.389 0.016-0.525-0.041-0.139-0.133-0.248-0.277-0.329-0.219-0.123-0.482-0.167-0.788-0.133-0.309 0.033-0.628 0.141-0.958 0.326-0.31 0.174-0.592 0.391-0.846 0.653-0.257 0.26-0.477 0.557-0.661 0.892l-1.476-0.827c0.332-0.333 0.658-0.625 0.978-0.875s0.659-0.474 1.015-0.674c0.934-0.524 1.803-0.835 2.607-0.934 0.8-0.101 1.5 0.016 2.098 0.352 0.307 0.172 0.508 0.368 0.603 0.589 0.092 0.219 0.097 0.507 0.016 0.865l-0.1 0.356c-0.066 0.255-0.080 0.438-0.041 0.55 0.035 0.11 0.124 0.205 0.265 0.284l0.212 0.118-2.074 1.162zM18.674 11.744l-1.673-0.937 2.074-1.162 1.673 0.937-2.074 1.162zM27.747 10.174l-11.060 6.329c-0.183 0.25-0.34 0.527-0.459 0.813v11.84c0.287 0.358 0.793 0.414 1.37 0.081l9.168-5.294c0.895-0.517 1.621-1.774 1.621-2.808v-9.84c0-0.608-0.251-1.003-0.641-1.121zM23.147 23.68l-1.83 1.056v-1.918l1.83-1.057v1.918zM24.703 17.643c-0.132 0.353-0.357 0.78-0.674 1.284l-0.324 0.494c-0.23 0.355-0.38 0.622-0.451 0.799-0.071 0.174-0.106 0.342-0.106 0.503v0.242l-1.83 1.056v-0.264c0-0.294 0.056-0.587 0.167-0.878 0.111-0.294 0.346-0.721 0.705-1.279l0.324-0.5c0.193-0.298 0.333-0.555 0.421-0.771 0.091-0.218 0.137-0.409 0.137-0.575 0-0.251-0.081-0.4-0.243-0.447-0.162-0.050-0.389 0.009-0.679 0.177-0.274 0.158-0.569 0.39-0.887 0.695-0.318 0.302-0.649 0.671-0.994 1.107v-1.692c0.409-0.387 0.782-0.714 1.12-0.981s0.664-0.491 0.978-0.673c0.825-0.476 1.453-0.659 1.886-0.55 0.433 0.106 0.649 0.502 0.649 1.188 0 0.352-0.066 0.706-0.198 1.062z"></path>
-</svg> \ No newline at end of file
diff --git a/svelte.config.js b/svelte.config.js
deleted file mode 100644
index 0caa92f..0000000
--- a/svelte.config.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import adapter from "@sveltejs/adapter-static";
-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- // Consult https://svelte.dev/docs/kit/integrations
- // for more information about preprocessors
- preprocess: vitePreprocess(),
-
- kit: {
- // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
- // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
- // See https://svelte.dev/docs/kit/adapters for more information about adapters.
- adapter: adapter(),
- },
-};
-
-export default config;
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
deleted file mode 100644
index 8bc2b2d..0000000
--- a/tailwind.config.cjs
+++ /dev/null
@@ -1,25 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
- content: ["./src/**/*.{html,js,svelte}"],
- plugins: [require("daisyui")],
- daisyui: {
- themes: [
- {
- light: {
- ...require("daisyui/src/theming/themes").lofi,
- gradient: "#C3CFE2",
- error: "#b91c1c",
- },
- },
- ], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
- darkTheme: "dark", // name of one of the included themes for dark mode
- darkMode: 'class',
- base: true, // applies background color and foreground color for root element by default
- styled: true, // include daisyUI colors and design decisions for all components
- utils: true, // adds responsive and modifier utility classes
- prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
- logs: false, // Shows info about daisyUI version and used config in the console when building your CSS
- themeRoot: ":root", // The element that receives theme color CSS variables
- },
- safelist: ["w-16", "h-16", "w-20", "h-20", "w-24", "h-24"],
-};
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 0b2d886..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "extends": "./.svelte-kit/tsconfig.json",
- "compilerOptions": {
- "allowJs": true,
- "checkJs": true,
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "sourceMap": true,
- "strict": true,
- "moduleResolution": "bundler"
- }
- // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
- // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
- //
- // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
- // from the referenced tsconfig.json - TypeScript does not merge them in
-}
diff --git a/vite.config.ts b/vite.config.ts
deleted file mode 100644
index 2d35c4f..0000000
--- a/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import tailwindcss from '@tailwindcss/vite';
-import { sveltekit } from '@sveltejs/kit/vite';
-import { defineConfig } from 'vite';
-
-export default defineConfig({
- plugins: [tailwindcss(), sveltekit()]
-});