From 989e29302e479277fb18ee105874c9a1eeb00be5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 6 Sep 2026 23:52:21 -0700 Subject: Durable sync outbox: fix the fresh-device phantom "Sent" that showed a zero balance (authored by codex). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz --- tools/sync-browser-harness.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tools/sync-browser-harness.js (limited to 'tools/sync-browser-harness.js') diff --git a/tools/sync-browser-harness.js b/tools/sync-browser-harness.js new file mode 100644 index 0000000..9ec3a45 --- /dev/null +++ b/tools/sync-browser-harness.js @@ -0,0 +1,30 @@ +import { NostrSync, publishOn } from '../src/nostr.js'; +import { SyncOutbox } from '../src/sync-outbox.js'; +import { adoptArkSnapshot } from '../src/features/ark.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +const identity = new NostrSync(); +identity.load('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'); +const relays = [location.origin.replace('http', 'ws') + '/relay']; +identity.setRelays(relays); +const outbox = new SyncOutbox({ storage: localStorage, send: (event, targets) => publishOn(targets, event) }); +const manager = { + state: { vtxos: [], movements: [], actions: [] }, + _save() { document.body.textContent = String(this.state.vtxos.filter(v => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0)); }, +}; +window.syncTest = { + save(arkState) { + const dtag = 'bitcoin-wallet:x:browser-test:ark'; + const state = { netName: 'mainnet', arkState }; + outbox.enqueue({ pubkey: identity.pk, dtag, relays, + digest: bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(state)))), + sign: timestamp => identity.stateEvent(state, dtag, timestamp) }); + }, + async restore() { + for (const s of await identity.fetchAllStates()) adoptArkSnapshot(manager, s.state.arkState); + return manager.state; + }, + records: () => outbox.records().map(r => ({ acknowledged: r.acknowledged, id: r.event.id })), +}; +outbox.wake(); -- cgit v1.2.3