aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.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/nostr.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/nostr.js')
-rw-r--r--src/nostr.js30
1 files changed, 22 insertions, 8 deletions
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);
}
}