diff options
| author | Adam Soltys <[email protected]> | 2026-06-15 23:15:44 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-15 23:15:44 -0700 |
| commit | 60db237749a1189d57b8783cd318c60d6df0cbcc (patch) | |
| tree | bc338d4c94fe02a93bca3505323a80b3516d55ea | |
| parent | 5f478de7d90f9170b81b21af6492de1be2076696 (diff) | |
| download | coinosv3-60db237749a1189d57b8783cd318c60d6df0cbcc.tar.gz coinosv3-60db237749a1189d57b8783cd318c60d6df0cbcc.zip | |
Sync wallet state across devices via encrypted Nostr events
Persist wallet state (addresses, utxos, history, indices, fees) as a NIP-44
encrypted, parameterized-replaceable event (kind 30078) addressed to self on
relay.coinos.io. The Nostr identity is derived from the same seed (NIP-06), so
no extra secret. On load we pull the latest state from Nostr — a device with
no local cache gets full state without re-scanning, and another device's
updates propagate — using savedAt to keep the newest copy. localStorage stays
as the instant/offline cache; state is published (debounced) on every change.
(Relay note: added kind 30078 to the strfry write-policy allowlist on prod.)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rwxr-xr-x | bun.lockb | bin | 13985 -> 17101 bytes | |||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/app.js | 9 | ||||
| -rw-r--r-- | src/nostr.js | 94 | ||||
| -rw-r--r-- | src/wallet.js | 103 |
5 files changed, 177 insertions, 30 deletions
| Binary files differ diff --git a/package.json b/package.json index 9e38e24..eef5b5d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", + "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4" }, "devDependencies": { @@ -391,9 +391,12 @@ async function enterWallet(mnemonic, passphrase) { return; } try { - // With a cache, only check the live frontier (cheap) instead of a full - // re-scan; a full scan still runs for a brand-new wallet and periodically. - if (hadCache) await wallet.refreshLive(); + // Cross-device state: pull the latest from Nostr (may supply state on a + // device with no local cache, or a newer copy from another device). + const hadNostr = await wallet.syncFromNostr(); + // With existing state (local or remote), only check the live frontier; + // a brand-new wallet does one full scan. + if (hadCache || hadNostr) await wallet.refreshLive(); else await wallet.scan(); wallet.startRealtime(); } catch { diff --git a/src/nostr.js b/src/nostr.js new file mode 100644 index 0000000..1616c1e --- /dev/null +++ b/src/nostr.js @@ -0,0 +1,94 @@ +// Encrypted cross-device state sync via Nostr. +// +// The wallet's Nostr identity is derived from the same seed (NIP-06, +// m/44'/1237'/0'/0/0). Wallet state is encrypted to ourselves (NIP-44) and +// published as a single parameterized-replaceable event (kind 30078, NIP-78) +// to relay.coinos.io — so any device with the seed can pull the latest state +// without re-scanning, and the relay only ever keeps the newest copy. + +import * as nip06 from 'nostr-tools/nip06'; +import * as nip44 from 'nostr-tools/nip44'; +import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { Relay } from 'nostr-tools/relay'; + +const RELAY = 'wss://relay.coinos.io'; +const DTAG = 'bitcoin-wallet'; + +export class NostrSync { + constructor() { + this.sk = null; + this.pk = null; + this.ck = null; // self conversation key for NIP-44 + this._relay = null; + } + + load(mnemonic, passphrase = '') { + this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined); + this.pk = getPublicKey(this.sk); + this.ck = nip44.getConversationKey(this.sk, this.pk); + } + + unload() { + this.sk = this.pk = this.ck = null; + if (this._relay) { + try { + this._relay.close(); + } catch {} + this._relay = null; + } + } + + async _conn() { + if (this._relay && this._relay.connected) return this._relay; + this._relay = await Relay.connect(RELAY); + return this._relay; + } + + // Encrypt + publish the latest state (best-effort; never throws). + async publish(stateObj) { + if (!this.sk) return; + try { + const content = nip44.encrypt(JSON.stringify(stateObj), this.ck); + const evt = finalizeEvent( + { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', DTAG]], content }, + this.sk + ); + const relay = await this._conn(); + await relay.publish(evt); + } catch { + /* offline / relay down — localStorage still has the state */ + } + } + + // Fetch + decrypt the latest state, or null. + async fetch() { + if (!this.sk) return null; + try { + const relay = await this._conn(); + return await new Promise((resolve) => { + let result = null; + const sub = relay.subscribe([{ kinds: [30078], authors: [this.pk], '#d': [DTAG], limit: 1 }], { + onevent: (e) => { + try { + result = JSON.parse(nip44.decrypt(e.content, this.ck)); + } catch {} + }, + oneose: () => { + try { + sub.close(); + } catch {} + resolve(result); + }, + }); + setTimeout(() => { + try { + sub.close(); + } catch {} + resolve(result); + }, 6000); + }); + } catch { + return null; + } + } +} diff --git a/src/wallet.js b/src/wallet.js index 440f8dc..4e1a43c 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -17,6 +17,7 @@ import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { Api, pool, wsUrl } from './api.js'; +import { NostrSync } from './nostr.js'; // No look-ahead: stop scanning a chain at the first unused address. This wallet // only ever exposes ONE unused address at a time (freshReceive = first unused; @@ -90,6 +91,11 @@ export class Wallet { this._lastMsg = 0; // time of last WS message (incl. pong) this._wakeHooked = false; + // Encrypted cross-device state sync over Nostr. + this.nostr = new NostrSync(); + this._savedAt = 0; + this._nostrPubTimer = null; + this._subs = new Set(); } @@ -113,6 +119,11 @@ export class Wallet { this.api.offline = offline; this._account = null; this._addrCache.clear(); + this._savedAt = 0; + try { + if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase); + else this.nostr.unload(); + } catch {} this.reset(); } @@ -668,6 +679,7 @@ export class Wallet { clearInterval(this._pollTimer); clearInterval(this._deepTimer); clearInterval(this._hbTimer); + clearTimeout(this._nostrPubTimer); if (this._ws) { try { this._ws.onclose = null; @@ -687,42 +699,57 @@ export class Wallet { return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); } + // The serializable wallet state, shared by the localStorage cache and the + // Nostr sync. savedAt lets us pick the newest copy across devices. + _snapshot() { + return { + v: 1, + savedAt: Date.now(), + receive: this.receive, + change: this.change, + utxos: this.utxos, + txs: this.txs, + nextReceiveIndex: this.nextReceiveIndex, + nextChangeIndex: this.nextChangeIndex, + feeRates: this.feeRates, + }; + } + + _applySnapshot(d) { + this.receive = d.receive || []; + this.change = d.change || []; + this.utxos = d.utxos || []; + this.txs = d.txs || []; + this.nextReceiveIndex = d.nextReceiveIndex || 0; + this.nextChangeIndex = d.nextChangeIndex || 0; + this.feeRates = d.feeRates || this.feeRates; + this.addrMap = new Map(); + for (const a of [...this.receive, ...this.change]) { + this.addrMap.set(a.address, { chain: a.chain, index: a.index }); + } + this._recomputeBalanceFromChains(); + this._savedAt = d.savedAt || 0; + this.loaded = true; + } + saveCache() { + const snap = this._snapshot(); + this._savedAt = snap.savedAt; try { - localStorage.setItem( - this._cacheKey(), - JSON.stringify({ - v: 1, - receive: this.receive, - change: this.change, - utxos: this.utxos, - txs: this.txs, - nextReceiveIndex: this.nextReceiveIndex, - nextChangeIndex: this.nextChangeIndex, - feeRates: this.feeRates, - }) - ); + localStorage.setItem(this._cacheKey(), JSON.stringify(snap)); } catch {} + // Push to Nostr too (debounced), so other devices get the update. + if (!this.offline) { + clearTimeout(this._nostrPubTimer); + this._nostrPubTimer = setTimeout(() => this.nostr.publish(snap), 2500); + } } restoreCache() { try { const raw = localStorage.getItem(this._cacheKey()); if (!raw) return false; - const d = JSON.parse(raw); - this.receive = d.receive || []; - this.change = d.change || []; - this.utxos = d.utxos || []; - this.txs = d.txs || []; - this.nextReceiveIndex = d.nextReceiveIndex || 0; - this.nextChangeIndex = d.nextChangeIndex || 0; - this.feeRates = d.feeRates || this.feeRates; - this.addrMap = new Map(); - for (const a of [...this.receive, ...this.change]) { - this.addrMap.set(a.address, { chain: a.chain, index: a.index }); - } - this._recomputeBalanceFromChains(); - this.loaded = true; + this._applySnapshot(JSON.parse(raw)); this.emit(); return true; } catch { @@ -730,6 +757,28 @@ export class Wallet { } } + // Pull the latest state from Nostr; apply it if it's newer than what we have. + // Returns true if state was applied (so the caller can skip a full scan). + async syncFromNostr() { + if (this.offline) return false; + let remote; + try { + remote = await this.nostr.fetch(); + } catch { + return false; + } + if (!remote) return false; + if ((remote.savedAt || 0) > (this._savedAt || 0)) { + this._applySnapshot(remote); + this.saveCache(); // mirror into localStorage + this.emit(); + return true; + } + // Our local copy is newer (or equal) — push it up so the relay catches up. + if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache(); + return true; // remote existed, so no full scan needed + } + // --- 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. |
