aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sync-outbox-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-outbox-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-outbox-test.js')
-rw-r--r--tools/sync-outbox-test.js195
1 files changed, 195 insertions, 0 deletions
diff --git a/tools/sync-outbox-test.js b/tools/sync-outbox-test.js
new file mode 100644
index 0000000..02c2020
--- /dev/null
+++ b/tools/sync-outbox-test.js
@@ -0,0 +1,195 @@
+// Run: bun tools/sync-outbox-test.js. No live wallet or network required.
+import assert from 'node:assert/strict';
+import { SyncOutbox } from '../src/sync-outbox.js';
+import { NostrSync } from '../src/nostr.js';
+import { installSyncWallet } from '../src/features/sync.js';
+import { mergeArkStates, adoptArkSnapshot, arkFeature } from '../src/features/ark.js';
+import { createHash } from 'node:crypto';
+
+class Storage {
+ data = new Map();
+ get length() { return this.data.size; }
+ key(i) { return [...this.data.keys()][i] ?? null; }
+ getItem(k) { return this.data.get(k) ?? null; }
+ setItem(k, v) { this.data.set(k, String(v)); }
+ removeItem(k) { this.data.delete(k); }
+}
+const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+const signer = (passphrase = '') => { const n = new NostrSync(); n.load(mnemonic, passphrase); return n; };
+const a = signer(), b = signer('different wallet');
+const storage = new Storage();
+globalThis.localStorage = storage;
+let now = 1788758000000;
+let nextTimer = 0;
+const timers = new Map();
+const scheduled = [];
+const deps = {
+ storage, now: () => now,
+ schedule: (fn, delay) => { const id = ++nextTimer; timers.set(id, fn); scheduled.push(delay); return id; },
+ cancel: id => timers.delete(id), warn: () => {},
+};
+const enqueue = (box, n, state, dtag = 'bitcoin-wallet:x:test:ark') => box.enqueue({
+ pubkey: n.pk, dtag, digest: createHash('sha256').update(JSON.stringify(state)).digest('hex'), relays: ['wss://test.invalid'],
+ sign: ts => n.stateEvent(state, dtag, ts),
+});
+const balance = state => state.vtxos.filter(v => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0);
+const restored = {
+ netName: 'mainnet', savedAt: now, arkServer: 'asp', arkState: {
+ vtxos: [{ id: 'deposit', amountSat: 5000, state: 'spent' }, { id: 'change', amountSat: 4499, state: 'spendable', bytes: 'signed-coin' }],
+ actions: [], movements: [{ id: 'payment', type: 'ln-send', status: 'complete', amountSat: 501, inputIds: ['deposit'] }], nextKeyIndex: 19,
+ },
+};
+
+// The exact outage: save a payment, relay refuses it, page dies. A fresh
+// outbox retries the same signed event and a new device recovers the change.
+const offline = new SyncOutbox({ ...deps, send: async () => false });
+enqueue(offline, a, restored);
+await offline.flush();
+assert.equal(offline.records()[0].acknowledged, false);
+assert.ok(scheduled.at(-1) >= 5000);
+const received = [];
+const restarted = new SyncOutbox({ ...deps, send: async event => { received.push(event); return true; } });
+await restarted.flush();
+assert.equal(restarted.records()[0].acknowledged, true);
+assert.equal(balance(a.decodeStateEvents(received)[0].state.arkState), 4499);
+assert.equal(a.decodeStateEvents(received)[0].state.arkState.nextKeyIndex, 19);
+console.log('✓ rejected publish survives restart and restores change/history');
+
+// Delivery does not need the live signing key, and never switches to the
+// account activated while the relay was offline.
+enqueue(restarted, b, { netName: 'mainnet', protected: 'private-nwc-connection' });
+const bpk = b.pk;
+b.unload();
+await restarted.flush();
+assert.equal(received.at(-1).pubkey, bpk);
+assert.equal(a.decodeStateEvents([received.at(-1)]).length, 0);
+const raw = [...storage.data.values()].join('');
+assert.ok(!raw.includes('private-nwc-connection') && !raw.includes('signed-coin') && !raw.includes(mnemonic));
+console.log('✓ queued snapshots stay encrypted and bound to their original account');
+
+// A relay acceptance for the old event must not erase a newer queued update.
+now += 2000;
+let release;
+const racing = new SyncOutbox({ ...deps, send: () => new Promise(r => { release = r; }) });
+enqueue(racing, a, { ...restored, savedAt: now });
+const inFlight = racing.flush();
+enqueue(racing, a, { ...restored, savedAt: now + 1 });
+const latestId = racing.events(a.pk)[0].id;
+release(true);
+await inFlight;
+assert.equal(racing.records().find(r => r.event.id === latestId).acknowledged, false);
+assert.ok(racing.events(a.pk)[0].created_at > received[0].created_at);
+console.log('✓ late acceptance cannot discard a newer snapshot');
+
+// Changes inside a second coalesce until their timestamp is safe to publish.
+const future = racing.events(a.pk)[0].created_at;
+enqueue(racing, a, { ...restored, savedAt: now + 2 });
+assert.equal(racing.events(a.pk)[0].created_at, future);
+let calls = 0;
+const timed = new SyncOutbox({ ...deps, send: async () => { calls++; return true; } });
+await timed.flush();
+assert.equal(calls, 0);
+now = future * 1000;
+await timed.flush();
+assert.equal(calls, 1);
+console.log('✓ successive updates obey relay replacement ordering');
+
+// Exercise the actual feature save/restore hooks, including stopping realtime
+// immediately after a payment and restoring from a stale deposit-only cache.
+function walletStub() {
+ const hooks = { saved: [], realtime: [] };
+ const wallet = {
+ hooks, mnemonic, offline: false, netName: 'mainnet', _savedAt: now,
+ txs: [], _cacheExtensions: [],
+ registerCacheSavedHook: fn => hooks.saved.push(fn),
+ registerLoadHook: () => {}, registerRealtimeHook: h => hooks.realtime.push(h),
+ saveCache: () => {}, emit: () => {}, _sortTxs: () => {},
+ _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
+ _applySnapshot: () => {},
+ };
+ return wallet;
+}
+const featureStorage = new Storage();
+globalThis.localStorage = featureStorage;
+const featureBox = new SyncOutbox({ ...deps, storage: featureStorage, send: async () => false });
+const wallet = walletStub();
+installSyncWallet(wallet, { outbox: featureBox });
+wallet.nostr.load(mnemonic);
+let liveArk = restored.arkState;
+wallet._cacheExtensions = [{ domain: 'ark', save: () => ({ arkState: liveArk, arkServer: 'asp' }) }];
+wallet.hooks.saved[0]({ ...restored, txs: [], utxos: [] });
+for (const hook of wallet.hooks.realtime) hook.stop?.();
+assert.equal(featureBox.events(a.pk).length, 2);
+const captured = featureBox.events(a.pk).map(e => e.id);
+wallet.hooks.saved[0]({ ...restored, savedAt: now + 50, txs: [], utxos: [] });
+assert.deepEqual(featureBox.events(a.pk).map(e => e.id), captured, 'timestamp-only saves must not republish');
+liveArk = { vtxos: [{ id: 'foreign-coin', amountSat: 99 }] };
+wallet.nostr.load(mnemonic, 'different wallet');
+const state = a.decodeStateEvents(featureBox.events(a.pk)).find(s => s.state.arkState).state.arkState;
+assert.equal(balance(state), 4499, 'extension state is captured before account switch');
+console.log('✓ realtime stop preserves snapshots; account switches cannot mix domains');
+
+const fresh = walletStub();
+installSyncWallet(fresh, { outbox: featureBox });
+fresh.nostr.load(mnemonic);
+fresh.arkState = { vtxos: [{ id: 'deposit', state: 'spendable', amountSat: 5000 }], movements: [], actions: [] };
+fresh.nostr.fetchAllStates = async () => [];
+assert.equal(await fresh.syncFromNostr(), true);
+assert.equal(balance(fresh.arkState), 4499);
+assert.equal(fresh.arkState.movements[0].amountSat, 501);
+assert.equal(fresh.arkState.nextKeyIndex, 19);
+console.log('✓ fresh session merges encrypted local recovery while relay is unavailable');
+
+// Do not apply a fetched snapshot if the user switched accounts during I/O.
+let finishFetch;
+fresh.nostr.fetchAllStates = () => new Promise(resolve => { finishFetch = resolve; });
+const fetching = fresh.syncFromNostr();
+fresh.nostr.load(mnemonic, 'different wallet');
+finishFetch([{ state: restored, dtag: 'bitcoin-wallet:x:test:ark', created_at: now / 1000 }]);
+assert.equal(await fetching, false);
+console.log('✓ account switch during fetch rejects the previous wallet result');
+
+// Stored or relayed events must pass signatures as well as decryption.
+const corrupt = { ...JSON.parse(JSON.stringify(received[0])), sig: '00'.repeat(64) };
+assert.deepEqual(a.decodeStateEvents([corrupt]), []);
+console.log('✓ invalid recovery signatures are rejected');
+
+// The receiving manager is already connected when the relay supplies change.
+// Update its live balance and history, keeping references held by an awaiting
+// payment driver intact. A second identical snapshot must not trigger a loop.
+const pendingCoin = { id: 'pending', amountSat: 100, state: 'pending' };
+const pendingAction = { id: 'in-flight', type: 'ln-pay', step: 'initiated' };
+const managerState = { vtxos: [pendingCoin, { id: 'deposit', state: 'spendable', amountSat: 5000 }], actions: [pendingAction], movements: [] };
+let saves = 0;
+const mgr = { state: managerState, _save: () => { saves++; } };
+assert.equal(adoptArkSnapshot(mgr, restored.arkState), true);
+assert.equal(balance(mgr.state), 4499);
+assert.equal(mgr.state.movements[0].amountSat, 501);
+assert.equal(mgr.state, managerState);
+assert.equal(mgr.state.vtxos.find(v => v.id === 'pending'), pendingCoin);
+assert.equal(mgr.state.actions[0], pendingAction);
+pendingAction.step = 'done';
+pendingCoin.state = 'spent';
+assert.equal(mgr.state.actions[0].step, 'done');
+assert.equal(mgr.state.vtxos.find(v => v.id === 'pending').state, 'spent');
+assert.equal(adoptArkSnapshot(mgr, restored.arkState), false);
+assert.equal(saves, 1);
+console.log('✓ connected wallet adopts change without restarting or breaking active payments');
+
+// Before the ASP handshake, every device snapshot is parked under its server
+// key. Older snapshots arriving last must not overwrite newer change coins.
+let extension;
+const parkingWallet = { _cacheKey: () => 'parking-test', registerCacheExtension: e => { extension = e; } };
+arkFeature({ wallet: parkingWallet, ui: {}, render: () => {} });
+const serverPubkey = '02' + 'ab'.repeat(32);
+const freshState = { ...restored.arkState, serverPubkey };
+extension.load({ arkState: freshState });
+extension.load({ arkState: { serverPubkey, vtxos: [], actions: [], movements: [] } });
+const held = JSON.parse(localStorage.getItem(parkingWallet._arkHoldKey(serverPubkey)));
+assert.equal(balance(held), 4499);
+assert.equal(held.movements[0].amountSat, 501);
+console.log('✓ older snapshots cannot overwrite recovery while the ASP is connecting');
+featureBox.clear();
+assert.equal(featureBox.records().length, 0);
+console.log('✓ explicit forget-all removes encrypted recovery copies');
+process.exit(0); // the feature schedules a lazy-connect callback; no live ASP in this test