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