diff options
Diffstat (limited to 'src/sync-outbox.js')
| -rw-r--r-- | src/sync-outbox.js | 88 |
1 files changed, 88 insertions, 0 deletions
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; + } + } +} |
