diff options
| author | Adam Soltys <[email protected]> | 2026-07-04 12:35:15 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-04 12:35:15 -0700 |
| commit | cc2c05d2a1b7a9601eb3cb0df998483099e590cc (patch) | |
| tree | 53f388790f8cca263c15d6d870a36ea283541995 /src/features/sync.js | |
| parent | b7c5abd93868b97c25f52610510544764a3e44eb (diff) | |
| download | coinosv3-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.js | 119 |
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()]; + }, + }; +} |
