aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sync-browser-test.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 /tools/sync-browser-test.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 'tools/sync-browser-test.js')
-rw-r--r--tools/sync-browser-test.js74
1 files changed, 74 insertions, 0 deletions
diff --git a/tools/sync-browser-test.js b/tools/sync-browser-test.js
new file mode 100644
index 0000000..05f8316
--- /dev/null
+++ b/tools/sync-browser-test.js
@@ -0,0 +1,74 @@
+// Actual Chrome storage + WebSocket relay: interrupted publish, restart,
+// retry, and an already-connected second device adopting the missing change.
+// Run: bun tools/sync-browser-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { verifyEvent } from 'nostr-tools/pure';
+
+const build = await Bun.build({ entrypoints: ['./tools/sync-browser-harness.js'], target: 'browser', minify: true });
+assert.ok(build.success);
+const script = await build.outputs[0].text();
+let accepting = true, rejected = 0;
+const events = new Map();
+const server = Bun.serve({
+ port: 0,
+ fetch(req, server) {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay' && server.upgrade(req)) return;
+ if (path === '/harness.js') return new Response(script, { headers: { 'content-type': 'text/javascript' } });
+ if (path !== '/') return new Response('', { status: 404 });
+ return new Response('<body>0<script type="module" src="/harness.js"></script>', { headers: { 'content-type': 'text/html' } });
+ },
+ websocket: {
+ message(ws, raw) {
+ const m = JSON.parse(String(raw));
+ if (m[0] === 'EVENT') {
+ const e = m[1];
+ if (!accepting) { rejected++; ws.send(JSON.stringify(['OK', e.id, false, 'rate-limited: test outage'])); return; }
+ assert.ok(verifyEvent(e));
+ const key = e.pubkey + ':' + e.tags.find(t => t[0] === 'd')[1];
+ const old = events.get(key);
+ if (!old || e.created_at > old.created_at || (e.created_at === old.created_at && e.id < old.id)) events.set(key, e);
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ }
+ if (m[0] === 'REQ') {
+ const filter = m[2];
+ for (const e of events.values()) if (filter.authors.includes(e.pubkey) && filter.kinds.includes(e.kind)) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ }
+ },
+ },
+});
+let browser;
+try {
+ browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+ const first = await browser.createBrowserContext(), second = await browser.createBrowserContext();
+ const open = async context => { const p = await context.newPage(); await p.goto(`http://127.0.0.1:${server.port}/`); await p.waitForFunction(() => !!window.syncTest); return p; };
+ let sender = await open(first);
+ const receiver = await open(second);
+ const initial = { vtxos: [{ id: 'deposit', amountSat: 5000, state: 'spendable' }], actions: [], movements: [], nextKeyIndex: 1 };
+ await sender.evaluate(s => syncTest.save(s), initial);
+ await sender.waitForFunction(() => syncTest.records().every(r => r.acknowledged));
+ await receiver.evaluate(() => syncTest.restore());
+ assert.equal(await receiver.$eval('body', b => b.textContent), '5000');
+
+ accepting = false;
+ const paid = { vtxos: [{ id: 'deposit', amountSat: 5000, state: 'spent' }, { id: 'change', amountSat: 4499, state: 'spendable' }], actions: [], movements: [{ id: 'test-payment', type: 'ln-send', status: 'complete', amountSat: 501, inputIds: ['deposit'] }], nextKeyIndex: 19 };
+ await sender.evaluate(s => syncTest.save(s), paid);
+ for (let i = 0; i < 50 && !rejected; i++) await new Promise(r => setTimeout(r, 100));
+ assert.ok(rejected, 'test relay must refuse the updated state');
+ assert.equal((await sender.evaluate(() => syncTest.records()))[0].acknowledged, false);
+ await sender.close();
+ accepting = true;
+ sender = await open(first);
+ await sender.waitForFunction(() => syncTest.records().length && syncTest.records().every(r => r.acknowledged));
+ const state = await receiver.evaluate(() => syncTest.restore());
+ assert.equal(await receiver.$eval('body', b => b.textContent), '4499');
+ assert.equal(state.nextKeyIndex, 19);
+ assert.equal(state.movements[0].amountSat, 501);
+ console.log('✓ Chrome restart retries refused snapshot from persistent storage');
+ console.log('✓ independent connected browser recovers 4499 sats and history without reload');
+} finally {
+ await browser?.close();
+ server.stop(true);
+}