aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/sync.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 12:35:15 -0700
committerAdam Soltys <[email protected]>2026-07-04 12:35:15 -0700
commitcc2c05d2a1b7a9601eb3cb0df998483099e590cc (patch)
tree53f388790f8cca263c15d6d870a36ea283541995 /src/features/sync.js
parentb7c5abd93868b97c25f52610510544764a3e44eb (diff)
downloadcoinosv3-cc2c05d2a1b7a9601eb3cb0df998483099e590cc.tar.gz
coinosv3-cc2c05d2a1b7a9601eb3cb0df998483099e590cc.zip
Features phase 2b: silent payments + nostr sync leave the core wallet
wallet.js drops from 2,374 to ~1,970 lines, holding only BIP84 on-chain logic plus generic feature seams. New seams added this pass: registerBalanceExtra extra confirmed/pending buckets registerInputProvider extra spendable inputs for buildTx registerSendPreparer destinations needing post-selection derivation registerInputSigner non-BIP84 input signing registerHistoryProvider / registerScanHook / registerRealtimeHook / registerLoadHook / registerCacheExtension / registerCacheSavedHook src/features/sp-wallet.js: the whole BIP-352 engine — key derivation, worker-accelerated indexer scanning, the live indexer WebSocket, SP outputs as spendable inputs, sp1 send preparation, and per-output-key signing — installs via those seams (installSpWallet). src/features/sync.js: nostr identity + encrypted cross-device state sync plus its Settings card, installed via load/cache-saved/realtime hooks. profileChip/nostrProfile move here-adjacent (into gifts, the only other nostr consumer), so core app.js no longer imports nostr at all. buildTx/sign are now feature-agnostic (input providers + preparers + signers); _recomputeBalance* use the balance-extra registry, so a core build with no SP no longer calls a missing spBalance() (that regression dropped the minimal build into offline mode — caught by the smoke test). New coverage: tools/sp-test.js drives two browser "devices" through an SP send, indexer discovery, and an SP-input spend on the ark regtest chain (needs tools/sp-indexer.js pointed at bitcoind:18543). Minimal smoke now also asserts no SP/nostr code ships. Minimal profile: 314KB, down from 412KB (full: 580KB). All suites green: minimal smoke, sp-test, ark UI, gift lifecycle. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
Diffstat (limited to 'src/features/sync.js')
-rw-r--r--src/features/sync.js119
1 files changed, 119 insertions, 0 deletions
diff --git a/src/features/sync.js b/src/features/sync.js
new file mode 100644
index 0000000..8cbd9f3
--- /dev/null
+++ b/src/features/sync.js
@@ -0,0 +1,119 @@
+// Cross-device sync feature — encrypted wallet state on Nostr relays
+// (NIP-44 to self, replaceable kind 30078) plus the wallet's nostr identity
+// (used e.g. to DM locked-gift claim codes). Installed onto the core wallet;
+// a build without sync ships none of it and never talks to a relay.
+
+import { NostrSync, getSyncConfig, setSyncConfig, npubOf } from '../nostr.js';
+import { t } from '../i18n.js';
+
+export function installSyncWallet(wallet) {
+ if (wallet.syncFromNostr) return; // already installed
+ wallet.nostr = new NostrSync();
+ wallet._nostrPubTimer = null;
+
+ Object.assign(wallet, {
+ // Our nostr identity (used to DM a locked gift's claim code to the recipient).
+ nostrPubkey() { return (this.nostr && this.nostr.pk) || null; },
+
+ nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; },
+
+ // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey.
+ async sendNostrDM(recipientPkHex, text) {
+ return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false;
+ },
+
+ // 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() {
+ const sync = getSyncConfig();
+ if (this.offline || !sync.enabled) return false;
+ this.nostr.setRelays(sync.relays);
+ 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
+ },
+ });
+
+ // identity follows the open wallet
+ wallet.registerLoadHook(() => {
+ if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase);
+ else wallet.nostr.unload();
+ });
+ // push every saved snapshot to the relays (debounced) unless sync is off
+ wallet.registerCacheSavedHook((snap) => {
+ const sync = getSyncConfig();
+ if (wallet.offline || !sync.enabled) return;
+ wallet.nostr.setRelays(sync.relays);
+ clearTimeout(wallet._nostrPubTimer);
+ wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap), 2500);
+ });
+ wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) });
+}
+
+export function syncFeature(ctx) {
+ const { h, ui, render, wallet } = ctx;
+ installSyncWallet(wallet);
+
+ // Cross-device sync settings: toggle + editable relay list (default coinos).
+ function syncCard() {
+ const cfg = getSyncConfig();
+ const setEnabled = (enabled) => {
+ if (enabled === cfg.enabled) return;
+ setSyncConfig({ enabled, relays: cfg.relays });
+ render();
+ // On enable, pull anything newer from the relays, then push our copy up.
+ if (enabled && !wallet.offline) {
+ wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache());
+ }
+ };
+ return h(
+ 'div',
+ { class: 'card col' },
+ h('h3', {}, t('deviceSync')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')),
+ h('div', { class: 'row between' },
+ h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')),
+ h('div', { class: 'seg' },
+ h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')),
+ h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff'))
+ )
+ ),
+ cfg.enabled
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('relaysLabel')),
+ h('textarea', {
+ placeholder: 'wss://relay.example.com',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ style: 'min-height:64px',
+ value: cfg.relays.join('\n'),
+ onInput: (e) => {
+ const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean);
+ setSyncConfig({ enabled: true, relays });
+ },
+ }),
+ h('div', { class: 'small faint' }, t('relaysHint'))
+ )
+ : null
+ );
+ }
+
+ return {
+ id: 'sync',
+ settingsCards() {
+ return wallet.watchOnly || !wallet.mnemonic ? [] : [syncCard()];
+ },
+ };
+}