diff options
| author | Adam Soltys <[email protected]> | 2026-09-06 23:52:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-06 23:52:21 -0700 |
| commit | 989e29302e479277fb18ee105874c9a1eeb00be5 (patch) | |
| tree | 43c9eacdde70d34875a5e8cf99068ec8bcb5d834 /tools | |
| parent | 050c944036c32200618f78f1924c81b8c881bb91 (diff) | |
| download | coinosv3-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')
| -rw-r--r-- | tools/sync-browser-harness.js | 30 | ||||
| -rw-r--r-- | tools/sync-browser-test.js | 74 | ||||
| -rw-r--r-- | tools/sync-outbox-test.js | 195 |
3 files changed, 299 insertions, 0 deletions
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(); 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); +} 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 |
