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;
}
}
}
|