aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-15 23:15:44 -0700
committerAdam Soltys <[email protected]>2026-06-15 23:15:44 -0700
commit60db237749a1189d57b8783cd318c60d6df0cbcc (patch)
treebc338d4c94fe02a93bca3505323a80b3516d55ea /src
parent5f478de7d90f9170b81b21af6492de1be2076696 (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src')
-rw-r--r--src/app.js9
-rw-r--r--src/nostr.js94
-rw-r--r--src/wallet.js103
3 files changed, 176 insertions, 30 deletions
diff --git a/src/app.js b/src/app.js
index c64c650..e59b1bd 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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.