aboutsummaryrefslogtreecommitdiffstats
path: root/src/sync-outbox.js
blob: 3c358f1191cc7d10a098a6b2e63d1d9174e945c4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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;
    }
  }
}