aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/sync.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 /src/features/sync.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 'src/features/sync.js')
-rw-r--r--src/features/sync.js67
1 files changed, 45 insertions, 22 deletions
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(),
};
}