summaryrefslogtreecommitdiffstats
path: root/src/sync-outbox.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-06 23:52:21 -0700
committerAdam Soltys <[email protected]>2026-09-06 23:52:21 -0700
commit989e29302e479277fb18ee105874c9a1eeb00be5 (patch)
tree43c9eacdde70d34875a5e8cf99068ec8bcb5d834 /src/sync-outbox.js
parent050c944036c32200618f78f1924c81b8c881bb91 (diff)
downloadcoinosv3-989e29302e479277fb18ee105874c9a1eeb00be5.tar.gz
coinosv3-989e29302e479277fb18ee105874c9a1eeb00be5.zip
Durable sync outbox: fix the fresh-device phantom "Sent" that showed a zero balance (authored by codex).
Root cause of the "signed in and my balance is 0 with a huge Sent I never made": a device's snapshot carrying its change/new coins never reached the relay. The publish was a setTimeout debounce that got canceled on page-hide / logout / feature stop, so coins created by another session were never synced; a fresh device then loaded only the old (now-spent) coins, and reconcile dressed the spent inputs as a definitive "Sent" row and zeroed the balance. No funds ever moved — verified on the ASP that no such payment existed and the change coin was still spendable. Fix (codex): - src/sync-outbox.js (new): a durable outbox. Snapshots are signed synchronously at save time under the current identity (no cross-account mixing) and persisted already-encrypted to localStorage (no keys/plaintext in storage). Delivery retries with backoff and wakes on online/pageshow/visibilitychange/storage, surviving suspension, logout and relay outages. The last copy doubles as a local recovery source if an account cache is cleared. - sync.js: replace the cancel-on-stop debounced publish with the outbox; fetchAllStates also merges the outbox's own (possibly-unpublished) events so a rejected publish is still recoverable locally; forgetAll clears it. - nostr.js: factor out stateEvent() (synchronous signing) and decodeStateEvents(), shared by fetch and the outbox. - ark.js: adoptArkSnapshot() applies a synced snapshot to a LIVE connected manager without restarting payment ceremonies (a snapshot arriving while connected previously stayed invisible until reload); the pre-handshake hold-key merges instead of overwrites; and the reconcile row is reworded from a red "Sent -N" to a neutral "History incomplete / Payment details not synced" (↻) with an explainer, since a spent input proves neither an amount nor a date. - app.js: clearAll also clears the outbox (forgetAll). Tests (codex): sync-outbox-test, sync-browser-test (real Chrome: relay rejects a snapshot, browser closes, reopens and retries; a second browser recovers the balance + history without reload), sync-split-test, plus build and morph smoke — all green. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src/sync-outbox.js')
-rw-r--r--src/sync-outbox.js88
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;
+ }
+ }
+}