aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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]>
Diffstat (limited to 'src')
-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
14 files changed, 1923 insertions, 435 deletions
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),
+ };
+}