diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 2 | ||||
| -rw-r--r-- | src/features/ark.js | 67 | ||||
| -rw-r--r-- | src/features/sync.js | 67 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/nostr.js | 30 | ||||
| -rw-r--r-- | src/sync-outbox.js | 88 |
6 files changed, 204 insertions, 55 deletions
@@ -2728,6 +2728,7 @@ function clearAll() { try { localStorage.removeItem(WATCH_KEY); } catch {} ui.confirmClear = false; lock(); + featureHook('forgetAll'); // discard encrypted sync recovery copies too } function pwPromptCard() { @@ -5071,4 +5072,3 @@ loadLocale(getLang()).finally(async () => { - diff --git a/src/features/ark.js b/src/features/ark.js index 6e68200..bc04dd6 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -277,6 +277,26 @@ export function mergeArkStates(a, b) { return out; } +// Apply sync to a connected manager without restarting payment ceremonies. +// Drivers retain references across awaits, so preserve the state, coin and +// action objects they already hold while adding/enriching the remote records. +export function adoptArkSnapshot(manager, snapshot) { + if (!snapshot || !manager.state) return false; + const state = manager.state; + const merged = mergeArkStates(state, snapshot); + if (JSON.stringify(merged) === JSON.stringify(state)) return false; + for (const field of ['vtxos', 'actions', 'movements', 'gifts']) { + const existing = new Map((state[field] || []).map(item => [item.id, item])); + merged[field] = (merged[field] || []).map(item => { + const current = existing.get(item.id); + return current ? Object.assign(current, item) : item; + }); + } + Object.assign(state, merged); + manager._save(); + return true; +} + export function arkFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; installArkWallet(wallet); // ark state storage lives outside the core wallet @@ -313,8 +333,10 @@ export function arkFeature(ctx) { || (wallet.loadArkState(cfg.ark) || {}).serverPubkey || null; if (owner && owner !== mine) { try { - localStorage.setItem(wallet._arkHoldKey(owner), - JSON.stringify({ ...d.arkState, serverPubkey: owner })); + const key = wallet._arkHoldKey(owner); + const held = JSON.parse(localStorage.getItem(key) || 'null'); + localStorage.setItem(key, + JSON.stringify(mergeArkStates(held, { ...d.arkState, serverPubkey: owner }))); } catch {} // `mine` is null on a FRESH restore (no local state, not yet // connected), so even our own snapshot lands in the hold. Connecting @@ -329,15 +351,15 @@ export function arkFeature(ctx) { wallet.saveArkState(merged, cfg.ark); const mergedN = (merged.vtxos || []).length; const remoteN = (d.arkState.vtxos || []).length; - const localN = (local && local.vtxos || []).length; // We know vtxos this snapshot lacked — push our superset back up so the // sender (and the shared slot) learns them. Guarded by mergedN>remoteN so // it can't loop once every device has converged to the union. if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} } - // The merge brought vtxos our live manager doesn't have — (re)connect so - // the balance actually shows. - const novel = mergedN > localN || (mergedN && (!ark || !ark.state)); - if (mergedN && novel) setTimeout(() => maybeInitArk(), 0); + // maybeInitArk deliberately does nothing when connected. Update that + // manager directly or the newly synced change/history stays invisible + // until reload, even though storage already contains the right balance. + if (ark) adoptArkSnapshot(ark, merged); + else if (mergedN) setTimeout(() => maybeInitArk(), 0); }, }); @@ -470,6 +492,8 @@ export function arkFeature(ctx) { arkConnectPromise = mgr.init().then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; + // Sync may have arrived while init awaited the server handshake. + adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark)); // Boards that died before funding (a failed tx build, a closed tab) // used to linger at 'created' forever — and an in-flight-looking // action blocks auto-renewal. Anything unfunded after an hour is a @@ -1145,15 +1169,9 @@ export function arkFeature(ctx) { : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : m.type === 'refresh' ? t('arkRenewedHistory') : t('sent'); - // coins the server saw spent outside this device's story (another - // device, a swept expiry, an adopted renewal) — the row that keeps the - // history summing to the balance. Folded per reconcile pass upstream. - // Deliberately dressed as an ORDINARY Sent row: the user spent their own - // money and doesn't need an alarm about which device did it. (The ASP's - // status API returns only a spent bit — no spend time or destination — - // so the timestamp is when this wallet found out.) The detail shows the - // hard facts: date, amount, and the coin ids that were consumed. The - // folded row isn't a stored movement, so the whole thing rides ui state. + // A spent input proves neither a payment amount nor its date: it can + // contain change, an offboard, or a renewal this device has not synced. + // Show the missing history explicitly until its records arrive. if (m.type === 'reconcile') { return h('div', { class: 'item', style: 'cursor:pointer', @@ -1162,12 +1180,12 @@ export function arkFeature(ctx) { render(); }, }, - h('div', { class: 'ico out', html: ARK_MARK(15) }), + h('div', { class: 'ico' }, '↻'), h('div', { class: 'grow' }, - h('div', {}, t('sent')), - h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), + h('div', {}, t('arkHistoryIncomplete')), + h('div', { class: 'small faint' }, t('arkDetailsNotSynced'))), h('div', { style: 'text-align:right' }, - h('div', { class: 'amount-neg' }, '-' + fmtAmount(m.amountSat)))); + h('div', { class: 'small muted' }, fmtAmount(m.amountSat) + ' ' + unitLabel()))); } // a renewal moves nothing anywhere — its history amount is what it COST if (m.type === 'refresh') { @@ -1277,7 +1295,7 @@ export function arkFeature(ctx) { h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, shortTxid(id))))); // Detail for a folded reconcile row: the hard facts only — when this wallet - // learned of it, how much left, and which coins were consumed. + // learned of it, the original coin total, and which coins were consumed. function arkReconDetailView(d) { const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), @@ -1285,9 +1303,10 @@ export function arkFeature(ctx) { return h('div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { html: ARK_ICON(18) }), - h('h3', { style: 'margin:0' }, t('sent'))), - h('div', { class: 'amount-neg', style: 'font-size:20px' }, '-' + fmtAmount(d.amountSat) + ' ' + unitLabel()), - row(t('dateLabel'), new Date(d.ts).toLocaleString()), + h('h3', { style: 'margin:0' }, t('arkHistoryIncomplete'))), + h('div', { class: 'small muted' }, t('arkHistoryIncompleteHelp')), + row(t('arkOriginalCoins'), fmtAmount(d.amountSat) + ' ' + unitLabel()), + row(t('arkDetectedAt'), new Date(d.ts).toLocaleString()), coinListRows((d.vtxoIds || []).length > 1 ? t('arkVtxoInputs') : t('arkVtxoId'), d.vtxoIds), h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkReconDetail = null; }) }, t('back'))); } diff --git a/src/features/sync.js b/src/features/sync.js index 4b011c4..702b93a 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -10,6 +10,28 @@ import { finalizeEvent, } from '../nostr.js'; import { t } from '../i18n.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; +import { SyncOutbox } from '../sync-outbox.js'; + +let sharedOutbox; +function syncOutbox() { + if (sharedOutbox) return sharedOutbox; + if (typeof localStorage === 'undefined') return null; + sharedOutbox = new SyncOutbox({ storage: localStorage, send: (event, relays) => publishOn(relays, event) }); + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('online', () => sharedOutbox.wake()); + window.addEventListener('pageshow', () => sharedOutbox.wake()); + window.addEventListener('storage', e => { if (e.key?.startsWith('btc-wallet-sync-outbox:')) sharedOutbox.wake(); }); + document.addEventListener('visibilitychange', () => { + // Ark flushes its cache synchronously on hide; the save hook below has + // already persisted encrypted events even if the network is suspended. + if (document.visibilityState === 'visible') sharedOutbox.wake(); + }); + } + sharedOutbox.wake(); + return sharedOutbox; +} // Keep a published snapshot under the relay's event-size cap. relay.coinos.io // rejects events over 64KB and nip44 expands the plaintext ~1.5x, so the JSON @@ -51,10 +73,9 @@ export function splitSnapshotDomains(snap, extensions = []) { return out; } -export function installSyncWallet(wallet) { +export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) { 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). @@ -120,12 +141,19 @@ export function installSyncWallet(wallet) { const sync = getSyncConfig(); if (this.offline || !sync.enabled) return false; this.nostr.setRelays(sync.relays); + const identity = this.nostr.pk; + const network = this.netName; let all; try { all = await this.nostr.fetchAllStates(); } catch { - return false; + all = []; } + if (this.nostr.pk !== identity || this.netName !== network) return false; + // A failed publish is still a recoverable encrypted local snapshot. + // Merge it even if the relay is down or holds only the old deposits. + all = [...all, ...this.nostr.decodeStateEvents(outbox?.events(identity) || [])] + .sort((a, b) => b.created_at - a.created_at); // Each device publishes to its OWN slot now, so there can be several // snapshots for this network. Keep only ours-for-this-network: the // netName inside is the real guard against cross-network bleed (legacy @@ -173,7 +201,7 @@ export function installSyncWallet(wallet) { wallet.registerLoadHook(() => { if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0); else wallet.nostr.unload(); - wallet._syncPubSeen = {}; // a new identity has published nothing yet + outbox?.wake(); adoptSyncRelays().catch(() => {}); }); @@ -233,31 +261,25 @@ export function installSyncWallet(wallet) { stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } }, }); - // push every saved snapshot to the relays (debounced) unless sync is off — - // split into per-domain slots on THIS DEVICE's tags, publishing only the - // domains whose content actually changed since the last publish. - wallet._syncPubSeen = {}; // domain -> last published JSON (per identity) + // Capture and sign changed domains NOW, under the saving wallet's identity. + // The encrypted outbox survives page suspension, logout and relay failure. + // Looking up extensions/keys inside a delayed callback could mix accounts; + // canceling that callback on stop discarded the only copy of recent change. wallet.registerCacheSavedHook((snap) => { const sync = getSyncConfig(); - if (wallet.offline || !sync.enabled) return; - wallet.nostr.setRelays(sync.relays); - // bind the network now: the debounced publish must keep this snapshot's - // network even if the wallet switches networks before the timer fires - const net = wallet.netName; - clearTimeout(wallet._nostrPubTimer); - wallet._nostrPubTimer = setTimeout(async () => { + if (wallet.offline || !sync.enabled || !wallet.nostr.pk || !outbox) return; + try { const domains = splitSnapshotDomains(snap, wallet._cacheExtensions || []); domains.core = trimForRelay(domains.core); for (const [name, obj] of Object.entries(domains)) { - const json = JSON.stringify(obj); - const slot = `${net}:${name}`; - if (wallet._syncPubSeen[slot] === json) continue; // unchanged - const ok = await wallet.nostr.publish(obj, domainDtag(net, name)); - if (ok) wallet._syncPubSeen[slot] = json; // a refused publish retries next save + const { savedAt, ...data } = obj; + const digest = bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(data)))); + const dtag = domainDtag(snap.netName, name); + outbox.enqueue({ pubkey: wallet.nostr.pk, dtag, digest, relays: sync.relays, + sign: createdAt => wallet.nostr.stateEvent(obj, dtag, createdAt) }); } - }, 2500); + } catch (e) { console.warn('wallet sync snapshot could not be queued:', e.message); } }); - wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) }); } export function syncFeature(ctx) { @@ -266,5 +288,6 @@ export function syncFeature(ctx) { return { id: 'sync', + forgetAll: () => sharedOutbox?.clear(), }; } diff --git a/src/i18n.js b/src/i18n.js index 84e4b5b..5b8cab6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -33,6 +33,11 @@ export const isRTL = (l) => RTL.has(l || lang); const STR = { en: { + arkHistoryIncomplete: 'History incomplete', + arkDetailsNotSynced: 'Payment details not synced', + arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.', + arkOriginalCoins: 'Original coin total', + arkDetectedAt: 'Detected on this device', appTitle: 'coinos', home: 'Home', lockWallet: 'Lock wallet', diff --git a/src/nostr.js b/src/nostr.js index 9fa892c..fdc797c 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -382,6 +382,27 @@ export class NostrSync { return results.some((r) => r.status === 'fulfilled'); } + // Prepare synchronously while this wallet's identity is still loaded. + // The outbox can deliver this event after logout without retaining a key. + stateEvent(stateObj, dtag, created_at) { + if (!this.sk || !this.ck) return null; + return finalizeEvent({ kind: 30078, created_at, tags: [['d', dtag]], + content: nip44.encrypt(JSON.stringify(stateObj), this.ck) }, this.sk); + } + + decodeStateEvents(events) { + if (!this.ck || !this.pk) return []; + const out = []; + for (const e of events.sort((a, b) => b.created_at - a.created_at)) { + if (e.pubkey !== this.pk || e.kind !== 30078 || !verifyEvent(e)) continue; + try { + const state = JSON.parse(nip44.decrypt(e.content, this.ck)); + if (state) out.push({ state, dtag: e.tags.find(t => t[0] === 'd')?.[1] || '', created_at: e.created_at }); + } catch {} + } + return out; + } + // Publish an arbitrary event (finalized here with our key) to the relays. // Used by features that speak their own event kinds (e.g. ark zaps). async publishEvent(partial) { @@ -476,13 +497,6 @@ export class NostrSync { if (!this.sk) return []; let events; try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk] }, { maxWait: 6000 }); } catch { return []; } - const out = []; - for (const e of events.sort((a, b) => b.created_at - a.created_at)) { - try { - const state = JSON.parse(nip44.decrypt(e.content, this.ck)); - if (state) out.push({ state, dtag: (e.tags.find((t) => t[0] === 'd') || [])[1] || '', created_at: e.created_at }); - } catch {} - } - return out; + return this.decodeStateEvents(events); } } diff --git a/src/sync-outbox.js b/src/sync-outbox.js new file mode 100644 index 0000000..3c358f1 --- /dev/null +++ b/src/sync-outbox.js @@ -0,0 +1,88 @@ +// Durable, already-encrypted and signed wallet snapshots. No signing keys or +// plaintext wallet state enter storage. Keep the last acknowledged copy too: +// it is a local recovery source when an account cache has been cleared. +const PREFIX = 'btc-wallet-sync-outbox:'; + +export class SyncOutbox { + constructor({ storage, send, now = Date.now, schedule = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id), warn = (...args) => console.warn(...args) }) { + Object.assign(this, { storage, send, now, schedule, cancel, warn }); + this.timer = null; + this.running = false; + this.retryMs = 5000; + } + + records() { + const out = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i); + if (!key?.startsWith(PREFIX)) continue; + try { + const record = JSON.parse(this.storage.getItem(key)); + if (record?.event?.kind === 30078 && Array.isArray(record.relays)) out.push({ key, ...record }); + } catch {} + } + return out; + } + + events(pubkey) { return this.records().filter(r => r.event.pubkey === pubkey).map(r => r.event); } + + clear() { + if (this.timer != null) this.cancel(this.timer); + this.timer = null; + for (const { key } of this.records()) this.storage.removeItem(key); + } + + enqueue({ pubkey, dtag, digest, relays, sign }) { + const key = PREFIX + pubkey + ':' + dtag; + let previous; + try { previous = JSON.parse(this.storage.getItem(key)); } catch {} + if (previous?.digest === digest) { this.wake(); return; } + // A pending, never-sent event can be replaced within the same second. + // Once attempted, use a later timestamp: Nostr resolves equal timestamps + // by event id, which can otherwise leave the OLD snapshot in the relay. + const createdAt = Math.max(Math.floor(this.now() / 1000), + (previous?.event?.created_at || 0) + (previous?.attempted ? 1 : 0)); + const event = sign(createdAt); + if (!event || event.pubkey !== pubkey) throw new Error('Cannot sign wallet sync snapshot'); + this.storage.setItem(key, JSON.stringify({ event, digest, relays, attempted: false, acknowledged: false })); + this.wake(); + } + + wake(delay = 0) { + if (this.timer != null) this.cancel(this.timer); + this.timer = this.schedule(() => { this.timer = null; this.flush(); }, delay); + } + + async flush() { + if (this.running) return; + this.running = true; + let failed = false; + try { + // Independent domains must not wait for the slowest relay request. + await Promise.all(this.records().filter(r => !r.acknowledged && r.event.created_at * 1000 <= this.now()).map(async r => { + try { + // Re-read before writing: another tab may have queued a newer copy. + const current = JSON.parse(this.storage.getItem(r.key)); + if (current?.event?.id !== r.event.id) return; + current.attempted = true; + this.storage.setItem(r.key, JSON.stringify(current)); + const ok = await this.send(r.event, r.relays); + if (!ok) { failed = true; return; } + const latest = JSON.parse(this.storage.getItem(r.key)); + if (latest?.event?.id === r.event.id) { + latest.acknowledged = true; + this.storage.setItem(r.key, JSON.stringify(latest)); + } + } catch (e) { failed = true; this.warn('wallet sync will retry:', e.message); } + })); + } finally { + this.running = false; + const pending = this.records().filter(r => !r.acknowledged); + if (pending.length) { + const due = Math.max(0, Math.min(...pending.map(r => r.event.created_at * 1000)) - this.now()); + this.wake(Math.max(due, failed ? this.retryMs : 0)); + this.retryMs = failed ? Math.min(this.retryMs * 2, 60000) : 5000; + } else this.retryMs = 5000; + } + } +} |
