aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md109
-rw-r--r--src/app.js17
-rw-r--r--src/wallet.js18
3 files changed, 100 insertions, 44 deletions
diff --git a/README.md b/README.md
index 96a35dd..e848fa2 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/src/app.js b/src/app.js
index 3f24c1c..7a931b3 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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() {