diff options
| author | Adam Soltys <[email protected]> | 2026-06-16 00:20:52 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-16 00:20:52 -0700 |
| commit | fc054510669f1e1017f985df11f603f0efe47b9b (patch) | |
| tree | 854850d7c57016c2a993c3348657f0bc211fa9b3 | |
| parent | db438d1d6feb6edccf782e0e22793cf8e4602cb1 (diff) | |
| download | coinosv3-fc054510669f1e1017f985df11f603f0efe47b9b.tar.gz coinosv3-fc054510669f1e1017f985df11f603f0efe47b9b.zip | |
Persist payment-received ack across reloads; footer + README
- The "Payment received!" celebration now persists: the acknowledged fresh
index is stored (per seed) in localStorage, so a received payment keeps
showing the dialog until you tap past it, even after a refresh.
- Footer: only "GitHub" is the link now.
- README rewritten for the current state (mainnet-only, realtime, Nostr sync,
multi-recipient, sats default, explorer resilience, etc.).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | README.md | 109 | ||||
| -rw-r--r-- | src/app.js | 17 | ||||
| -rw-r--r-- | src/wallet.js | 18 |
3 files changed, 100 insertions, 44 deletions
@@ -1,28 +1,43 @@ # Bitcoin Wallet 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. +browser — think bitaddress.org, but a modern BIP84 HD wallet from a seed +phrase that scans history, watches for payments in real time, and spends. + +Live at **https://bitcoin.coinos.io** (and the built `index.html` runs offline +straight from the filesystem). + +## Features - **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. + (optional passphrase). Mainnet. +- **One fresh address at a time** — a new receive address is only handed out + after the current one is paid, so used addresses stay contiguous and scans + stay tiny (no 20-address gap probing). +- **Real-time** via the mempool.space WebSocket: an incoming payment shows a + "Payment received!" screen the moment it hits the mempool, then reveals the + next address. A ping/pong heartbeat keeps the socket alive (auto-reconnect on + drop, sleep, or network change). +- **Spending** with proper coin selection + fee estimation (`@scure/btc-signer` + `selectUTXO`), multiple recipients, a fee-rate picker, send-max, and manual + coin control. +- **Encrypted cross-device sync over Nostr** — wallet state is NIP-44-encrypted + to yourself and stored as a replaceable event (kind 30078) on + relay.coinos.io. The Nostr identity is derived from the same seed (NIP-06), + so opening the wallet on another device restores state with no rescan. +- **Offline signing** — export a keyless JSON snapshot of your coins on an + online device, import it on an offline one, sign locally, and export the + signed transaction (hex / file / QR) to broadcast elsewhere. +- **Global sats/BTC toggle** (defaults to sats) — every amount label is + clickable to switch, persisted across sessions. +- **Resilient explorer access** — mempool.space + Blockstream with a global + request throttle, per-host cooldown on 429s, request timeouts with failover, + and a localStorage cache so reloads are instant. -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. +Built with the audited [`@scure`](https://github.com/paulmillr/scure-btc-signer) +/ [`@noble`](https://github.com/paulmillr/noble-hashes) libraries plus +[`nostr-tools`](https://github.com/nbd-wtf/nostr-tools). Pure JS, no +`crypto.subtle`, so it works from `file://` with no server. ## Develop @@ -39,28 +54,42 @@ bun run dev # http://localhost:5173 (rebuilds on each refresh) bun run build # → dist/index.html (one self-contained file) ``` -`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. +`dist/index.html` inlines all code, the crypto/QR/Nostr libraries, and the CSS. +Save that one file and open it directly in a browser — no server, no internet +needed (network features simply stay idle until you're online). + +## How state is kept + +Three layers, fastest first: + +1. **sessionStorage** — keeps the wallet open across a refresh (cleared on + logout / tab close). +2. **localStorage** — caches scanned state (addresses, UTXOs, history) keyed by + a hash of the seed, so a reload shows balances instantly. +3. **Nostr** — encrypted, replaceable cross-device state on relay.coinos.io. + +Idle polling only ever re-checks the fresh receive/change address; already- +scanned coin addresses are never re-polled (spends come over the WebSocket / +post-send refresh / Nostr). A manual **Settings → Rescan** covers anything +missed (e.g. a payment to a reused old address). -## Using it offline (air-gapped spending) +## Offline / air-gapped spending -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). +1. **Online device:** open the wallet, let it scan, then **Settings → Export + snapshot** (a keyless JSON of your UTXOs + fee rates). +2. Move the file to an **offline device** running the saved `index.html`. +3. There, enter your **seed phrase** (it auto-detects no network), then + **Settings → Import snapshot**. +4. Build and **sign** a transaction; copy/download the signed hex (or scan the + QR) and broadcast it from any online device. ## Layout | File | Purpose | | --- | --- | -| `src/wallet.js` | BIP84 derivation, scanning, coin selection, signing, snapshots | -| `src/api.js` | Esplora REST wrapper (mempool.space + Blockstream, with throttle/rotation/cooldown to dodge rate limits); the single network choke-point | +| `src/wallet.js` | BIP84 derivation, scanning, coin selection, signing, realtime, cache, sync | +| `src/api.js` | Esplora wrapper (mempool.space + Blockstream) with throttle/cooldown/timeout | +| `src/nostr.js` | Encrypted cross-device state sync over Nostr | | `src/app.js` | UI controller (vanilla DOM) | | `src/qr.js` | QR → SVG (zero-dep) | | `src/format.js` | sat/BTC formatting helpers | @@ -69,8 +98,10 @@ browser** — no server, no internet. ## Security notes -- 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. +- Self-custody software handling real keys — review the code before trusting it + with funds, and prefer an offline machine for the seed phrase. +- The seed/passphrase live in memory while the page is open and in + sessionStorage for the tab session; locking or closing the tab clears them. +- The localStorage cache and the Nostr event hold public chain data (addresses, + UTXOs, amounts); the Nostr copy is encrypted and keyed to your seed-derived + identity, but the relay can see that an identity is publishing app data. @@ -83,7 +83,8 @@ function footer() { ' by ', h('a', { href: 'https://adamsoltys.com', target: '_blank', rel: 'noopener' }, 'Adam Soltys'), h('span', { class: 'faint' }, ' · '), - h('a', { href: 'https://github.com/asoltys/cold', target: '_blank', rel: 'noopener' }, 'source available on GitHub') + 'source available on ', + h('a', { href: 'https://github.com/asoltys/cold', target: '_blank', rel: 'noopener' }, 'GitHub') ); } @@ -412,9 +413,15 @@ async function enterWallet(mnemonic, passphrase) { // a brand-new wallet does one full scan. if (hadCache || hadNostr) await wallet.refreshLive(); else await wallet.scan(); - // Align the acknowledged index after load so we only celebrate payments - // that land while watching (not historical reconciliation). - ui.receiveSeenIndex = wallet.nextReceiveIndex; + // Persisted acknowledgement: first time, baseline to the current index so we + // don't celebrate historical payments. After that, any advance beyond the + // acknowledged index shows the celebration (and survives refreshes). + let ack = wallet.getReceiveAck(); + if (ack == null) { + ack = wallet.nextReceiveIndex; + wallet.setReceiveAck(ack); + } + ui.receiveSeenIndex = ack; wallet.startRealtime(); } catch { enterOfflineFallback(); @@ -658,7 +665,7 @@ function receiveTab() { { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', - onClick: () => { ui.receiveSeenIndex = wallet.nextReceiveIndex; render(); }, + onClick: () => { ui.receiveSeenIndex = wallet.nextReceiveIndex; wallet.setReceiveAck(wallet.nextReceiveIndex); render(); }, }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, 'Payment received!'), diff --git a/src/wallet.js b/src/wallet.js index c57149f..f47d2f1 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -767,6 +767,24 @@ export class Wallet { return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); } + // The fresh receive index the user has acknowledged (tapped past the + // "payment received" screen for). Persisted so a received payment keeps + // showing the celebration until acknowledged, even across refreshes. + getReceiveAck() { + try { + const v = localStorage.getItem(this._cacheKey() + ':ack'); + return v == null ? null : Number(v); + } catch { + return null; + } + } + + setReceiveAck(i) { + try { + localStorage.setItem(this._cacheKey() + ':ack', String(i)); + } catch {} + } + // The serializable wallet state, shared by the localStorage cache and the // Nostr sync. savedAt lets us pick the newest copy across devices. _snapshot() { |
