diff options
| author | Adam Soltys <[email protected]> | 2026-06-14 23:01:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-14 23:01:03 -0700 |
| commit | c5443946031ec32a7b3008fa751eb7916654a9f6 (patch) | |
| tree | 372e8d0ccaa2dec58a6bcaaa19d3902b80eb4396 /src | |
| parent | 683927e4132651b9198e5421fae16e9c60978fbc (diff) | |
| download | coinosv3-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.js | 104 | ||||
| -rw-r--r-- | src/app.css | 23 | ||||
| -rw-r--r-- | src/app.d.ts | 13 | ||||
| -rw-r--r-- | src/app.html | 12 | ||||
| -rw-r--r-- | src/app.js | 954 | ||||
| -rw-r--r-- | src/format.js | 56 | ||||
| -rw-r--r-- | src/lib/Copy.svelte | 15 | ||||
| -rw-r--r-- | src/lib/index.ts | 9 | ||||
| -rw-r--r-- | src/qr.js | 27 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 19 | ||||
| -rw-r--r-- | src/routes/+layout.ts | 2 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 342 | ||||
| -rw-r--r-- | src/style.css | 217 | ||||
| -rw-r--r-- | src/wallet.js | 565 |
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), + }; +} |
