aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--src/app.js2
-rw-r--r--src/features/ark.js67
-rw-r--r--src/features/sync.js67
-rw-r--r--src/i18n.js5
-rw-r--r--src/nostr.js30
-rw-r--r--src/sync-outbox.js88
-rw-r--r--tools/sync-browser-harness.js30
-rw-r--r--tools/sync-browser-test.js74
-rw-r--r--tools/sync-outbox-test.js195
9 files changed, 503 insertions, 55 deletions
diff --git a/src/app.js b/src/app.js
index 802781c..127fda4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2728,6 +2728,7 @@ function clearAll() {
try { localStorage.removeItem(WATCH_KEY); } catch {}
ui.confirmClear = false;
lock();
+ featureHook('forgetAll'); // discard encrypted sync recovery copies too
}
function pwPromptCard() {
@@ -5071,4 +5072,3 @@ loadLocale(getLang()).finally(async () => {
-
diff --git a/src/features/ark.js b/src/features/ark.js
index 6e68200..bc04dd6 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -277,6 +277,26 @@ export function mergeArkStates(a, b) {
return out;
}
+// Apply sync to a connected manager without restarting payment ceremonies.
+// Drivers retain references across awaits, so preserve the state, coin and
+// action objects they already hold while adding/enriching the remote records.
+export function adoptArkSnapshot(manager, snapshot) {
+ if (!snapshot || !manager.state) return false;
+ const state = manager.state;
+ const merged = mergeArkStates(state, snapshot);
+ if (JSON.stringify(merged) === JSON.stringify(state)) return false;
+ for (const field of ['vtxos', 'actions', 'movements', 'gifts']) {
+ const existing = new Map((state[field] || []).map(item => [item.id, item]));
+ merged[field] = (merged[field] || []).map(item => {
+ const current = existing.get(item.id);
+ return current ? Object.assign(current, item) : item;
+ });
+ }
+ Object.assign(state, merged);
+ manager._save();
+ return true;
+}
+
export function arkFeature(ctx) {
const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx;
installArkWallet(wallet); // ark state storage lives outside the core wallet
@@ -313,8 +333,10 @@ export function arkFeature(ctx) {
|| (wallet.loadArkState(cfg.ark) || {}).serverPubkey || null;
if (owner && owner !== mine) {
try {
- localStorage.setItem(wallet._arkHoldKey(owner),
- JSON.stringify({ ...d.arkState, serverPubkey: owner }));
+ const key = wallet._arkHoldKey(owner);
+ const held = JSON.parse(localStorage.getItem(key) || 'null');
+ localStorage.setItem(key,
+ JSON.stringify(mergeArkStates(held, { ...d.arkState, serverPubkey: owner })));
} catch {}
// `mine` is null on a FRESH restore (no local state, not yet
// connected), so even our own snapshot lands in the hold. Connecting
@@ -329,15 +351,15 @@ export function arkFeature(ctx) {
wallet.saveArkState(merged, cfg.ark);
const mergedN = (merged.vtxos || []).length;
const remoteN = (d.arkState.vtxos || []).length;
- const localN = (local && local.vtxos || []).length;
// We know vtxos this snapshot lacked — push our superset back up so the
// sender (and the shared slot) learns them. Guarded by mergedN>remoteN so
// it can't loop once every device has converged to the union.
if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} }
- // The merge brought vtxos our live manager doesn't have — (re)connect so
- // the balance actually shows.
- const novel = mergedN > localN || (mergedN && (!ark || !ark.state));
- if (mergedN && novel) setTimeout(() => maybeInitArk(), 0);
+ // maybeInitArk deliberately does nothing when connected. Update that
+ // manager directly or the newly synced change/history stays invisible
+ // until reload, even though storage already contains the right balance.
+ if (ark) adoptArkSnapshot(ark, merged);
+ else if (mergedN) setTimeout(() => maybeInitArk(), 0);
},
});
@@ -470,6 +492,8 @@ export function arkFeature(ctx) {
arkConnectPromise = mgr.init().then(() => {
if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect
ark = mgr;
+ // Sync may have arrived while init awaited the server handshake.
+ adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark));
// Boards that died before funding (a failed tx build, a closed tab)
// used to linger at 'created' forever — and an in-flight-looking
// action blocks auto-renewal. Anything unfunded after an hour is a
@@ -1145,15 +1169,9 @@ export function arkFeature(ctx) {
: m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory')
: m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited')
: m.type === 'refresh' ? t('arkRenewedHistory') : t('sent');
- // coins the server saw spent outside this device's story (another
- // device, a swept expiry, an adopted renewal) — the row that keeps the
- // history summing to the balance. Folded per reconcile pass upstream.
- // Deliberately dressed as an ORDINARY Sent row: the user spent their own
- // money and doesn't need an alarm about which device did it. (The ASP's
- // status API returns only a spent bit — no spend time or destination —
- // so the timestamp is when this wallet found out.) The detail shows the
- // hard facts: date, amount, and the coin ids that were consumed. The
- // folded row isn't a stored movement, so the whole thing rides ui state.
+ // A spent input proves neither a payment amount nor its date: it can
+ // contain change, an offboard, or a renewal this device has not synced.
+ // Show the missing history explicitly until its records arrive.
if (m.type === 'reconcile') {
return h('div', {
class: 'item', style: 'cursor:pointer',
@@ -1162,12 +1180,12 @@ export function arkFeature(ctx) {
render();
},
},
- h('div', { class: 'ico out', html: ARK_MARK(15) }),
+ h('div', { class: 'ico' }, '↻'),
h('div', { class: 'grow' },
- h('div', {}, t('sent')),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ h('div', {}, t('arkHistoryIncomplete')),
+ h('div', { class: 'small faint' }, t('arkDetailsNotSynced'))),
h('div', { style: 'text-align:right' },
- h('div', { class: 'amount-neg' }, '-' + fmtAmount(m.amountSat))));
+ h('div', { class: 'small muted' }, fmtAmount(m.amountSat) + ' ' + unitLabel())));
}
// a renewal moves nothing anywhere — its history amount is what it COST
if (m.type === 'refresh') {
@@ -1277,7 +1295,7 @@ export function arkFeature(ctx) {
h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, shortTxid(id)))));
// Detail for a folded reconcile row: the hard facts only — when this wallet
- // learned of it, how much left, and which coins were consumed.
+ // learned of it, the original coin total, and which coins were consumed.
function arkReconDetailView(d) {
const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' },
h('span', { class: 'small muted', style: 'flex-shrink:0' }, k),
@@ -1285,9 +1303,10 @@ export function arkFeature(ctx) {
return h('div', { class: 'card col', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { html: ARK_ICON(18) }),
- h('h3', { style: 'margin:0' }, t('sent'))),
- h('div', { class: 'amount-neg', style: 'font-size:20px' }, '-' + fmtAmount(d.amountSat) + ' ' + unitLabel()),
- row(t('dateLabel'), new Date(d.ts).toLocaleString()),
+ h('h3', { style: 'margin:0' }, t('arkHistoryIncomplete'))),
+ h('div', { class: 'small muted' }, t('arkHistoryIncompleteHelp')),
+ row(t('arkOriginalCoins'), fmtAmount(d.amountSat) + ' ' + unitLabel()),
+ row(t('arkDetectedAt'), new Date(d.ts).toLocaleString()),
coinListRows((d.vtxoIds || []).length > 1 ? t('arkVtxoInputs') : t('arkVtxoId'), d.vtxoIds),
h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkReconDetail = null; }) }, t('back')));
}
diff --git a/src/features/sync.js b/src/features/sync.js
index 4b011c4..702b93a 100644
--- a/src/features/sync.js
+++ b/src/features/sync.js
@@ -10,6 +10,28 @@ import {
finalizeEvent,
} from '../nostr.js';
import { t } from '../i18n.js';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex } from '@noble/hashes/utils';
+import { SyncOutbox } from '../sync-outbox.js';
+
+let sharedOutbox;
+function syncOutbox() {
+ if (sharedOutbox) return sharedOutbox;
+ if (typeof localStorage === 'undefined') return null;
+ sharedOutbox = new SyncOutbox({ storage: localStorage, send: (event, relays) => publishOn(relays, event) });
+ if (typeof window !== 'undefined' && window.addEventListener) {
+ window.addEventListener('online', () => sharedOutbox.wake());
+ window.addEventListener('pageshow', () => sharedOutbox.wake());
+ window.addEventListener('storage', e => { if (e.key?.startsWith('btc-wallet-sync-outbox:')) sharedOutbox.wake(); });
+ document.addEventListener('visibilitychange', () => {
+ // Ark flushes its cache synchronously on hide; the save hook below has
+ // already persisted encrypted events even if the network is suspended.
+ if (document.visibilityState === 'visible') sharedOutbox.wake();
+ });
+ }
+ sharedOutbox.wake();
+ return sharedOutbox;
+}
// Keep a published snapshot under the relay's event-size cap. relay.coinos.io
// rejects events over 64KB and nip44 expands the plaintext ~1.5x, so the JSON
@@ -51,10 +73,9 @@ export function splitSnapshotDomains(snap, extensions = []) {
return out;
}
-export function installSyncWallet(wallet) {
+export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) {
if (wallet.syncFromNostr) return; // already installed
wallet.nostr = new NostrSync();
- wallet._nostrPubTimer = null;
Object.assign(wallet, {
// Our nostr identity (used to DM a locked gift's claim code to the recipient).
@@ -120,12 +141,19 @@ export function installSyncWallet(wallet) {
const sync = getSyncConfig();
if (this.offline || !sync.enabled) return false;
this.nostr.setRelays(sync.relays);
+ const identity = this.nostr.pk;
+ const network = this.netName;
let all;
try {
all = await this.nostr.fetchAllStates();
} catch {
- return false;
+ all = [];
}
+ if (this.nostr.pk !== identity || this.netName !== network) return false;
+ // A failed publish is still a recoverable encrypted local snapshot.
+ // Merge it even if the relay is down or holds only the old deposits.
+ all = [...all, ...this.nostr.decodeStateEvents(outbox?.events(identity) || [])]
+ .sort((a, b) => b.created_at - a.created_at);
// Each device publishes to its OWN slot now, so there can be several
// snapshots for this network. Keep only ours-for-this-network: the
// netName inside is the real guard against cross-network bleed (legacy
@@ -173,7 +201,7 @@ export function installSyncWallet(wallet) {
wallet.registerLoadHook(() => {
if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0);
else wallet.nostr.unload();
- wallet._syncPubSeen = {}; // a new identity has published nothing yet
+ outbox?.wake();
adoptSyncRelays().catch(() => {});
});
@@ -233,31 +261,25 @@ export function installSyncWallet(wallet) {
stop: () => { if (stateUnsub) { try { stateUnsub(); } catch {} stateUnsub = null; } },
});
- // push every saved snapshot to the relays (debounced) unless sync is off —
- // split into per-domain slots on THIS DEVICE's tags, publishing only the
- // domains whose content actually changed since the last publish.
- wallet._syncPubSeen = {}; // domain -> last published JSON (per identity)
+ // Capture and sign changed domains NOW, under the saving wallet's identity.
+ // The encrypted outbox survives page suspension, logout and relay failure.
+ // Looking up extensions/keys inside a delayed callback could mix accounts;
+ // canceling that callback on stop discarded the only copy of recent change.
wallet.registerCacheSavedHook((snap) => {
const sync = getSyncConfig();
- if (wallet.offline || !sync.enabled) return;
- wallet.nostr.setRelays(sync.relays);
- // bind the network now: the debounced publish must keep this snapshot's
- // network even if the wallet switches networks before the timer fires
- const net = wallet.netName;
- clearTimeout(wallet._nostrPubTimer);
- wallet._nostrPubTimer = setTimeout(async () => {
+ if (wallet.offline || !sync.enabled || !wallet.nostr.pk || !outbox) return;
+ try {
const domains = splitSnapshotDomains(snap, wallet._cacheExtensions || []);
domains.core = trimForRelay(domains.core);
for (const [name, obj] of Object.entries(domains)) {
- const json = JSON.stringify(obj);
- const slot = `${net}:${name}`;
- if (wallet._syncPubSeen[slot] === json) continue; // unchanged
- const ok = await wallet.nostr.publish(obj, domainDtag(net, name));
- if (ok) wallet._syncPubSeen[slot] = json; // a refused publish retries next save
+ const { savedAt, ...data } = obj;
+ const digest = bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(data))));
+ const dtag = domainDtag(snap.netName, name);
+ outbox.enqueue({ pubkey: wallet.nostr.pk, dtag, digest, relays: sync.relays,
+ sign: createdAt => wallet.nostr.stateEvent(obj, dtag, createdAt) });
}
- }, 2500);
+ } catch (e) { console.warn('wallet sync snapshot could not be queued:', e.message); }
});
- wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) });
}
export function syncFeature(ctx) {
@@ -266,5 +288,6 @@ export function syncFeature(ctx) {
return {
id: 'sync',
+ forgetAll: () => sharedOutbox?.clear(),
};
}
diff --git a/src/i18n.js b/src/i18n.js
index 84e4b5b..5b8cab6 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -33,6 +33,11 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
+ arkHistoryIncomplete: 'History incomplete',
+ arkDetailsNotSynced: 'Payment details not synced',
+ arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.',
+ arkOriginalCoins: 'Original coin total',
+ arkDetectedAt: 'Detected on this device',
appTitle: 'coinos',
home: 'Home',
lockWallet: 'Lock wallet',
diff --git a/src/nostr.js b/src/nostr.js
index 9fa892c..fdc797c 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -382,6 +382,27 @@ export class NostrSync {
return results.some((r) => r.status === 'fulfilled');
}
+ // Prepare synchronously while this wallet's identity is still loaded.
+ // The outbox can deliver this event after logout without retaining a key.
+ stateEvent(stateObj, dtag, created_at) {
+ if (!this.sk || !this.ck) return null;
+ return finalizeEvent({ kind: 30078, created_at, tags: [['d', dtag]],
+ content: nip44.encrypt(JSON.stringify(stateObj), this.ck) }, this.sk);
+ }
+
+ decodeStateEvents(events) {
+ if (!this.ck || !this.pk) return [];
+ const out = [];
+ for (const e of events.sort((a, b) => b.created_at - a.created_at)) {
+ if (e.pubkey !== this.pk || e.kind !== 30078 || !verifyEvent(e)) continue;
+ try {
+ const state = JSON.parse(nip44.decrypt(e.content, this.ck));
+ if (state) out.push({ state, dtag: e.tags.find(t => t[0] === 'd')?.[1] || '', created_at: e.created_at });
+ } catch {}
+ }
+ return out;
+ }
+
// Publish an arbitrary event (finalized here with our key) to the relays.
// Used by features that speak their own event kinds (e.g. ark zaps).
async publishEvent(partial) {
@@ -476,13 +497,6 @@ export class NostrSync {
if (!this.sk) return [];
let events;
try { events = await pool.querySync(this.relays, { kinds: [30078], authors: [this.pk] }, { maxWait: 6000 }); } catch { return []; }
- const out = [];
- for (const e of events.sort((a, b) => b.created_at - a.created_at)) {
- try {
- const state = JSON.parse(nip44.decrypt(e.content, this.ck));
- if (state) out.push({ state, dtag: (e.tags.find((t) => t[0] === 'd') || [])[1] || '', created_at: e.created_at });
- } catch {}
- }
- return out;
+ return this.decodeStateEvents(events);
}
}
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;
+ }
+ }
+}
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