aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 18:08:58 -0700
committerAdam Soltys <[email protected]>2026-08-01 18:08:58 -0700
commitcbc3a5907c55c20c248fa80fa14528cb483688ad (patch)
tree85f7b815f18095ec550583bb16850ba6a6c05ce3 /src/nostr.js
parent711807cb81f85408451b843e1a53e9616fd6df3b (diff)
downloadcoinosv3-cbc3a5907c55c20c248fa80fa14528cb483688ad.tar.gz
coinosv3-cbc3a5907c55c20c248fa80fa14528cb483688ad.zip
Split device sync into per-domain slots with bounded, dirty-only publishes
One monolithic snapshot per device outgrew a vanilla relay's 64KB event cap the moment real payment traffic arrived, and sync silently died. Snapshots now fragment into per-domain replaceable events (core wallet, ark, nwc — an extension claims a domain by naming it), published under a new ':x:' d-tag marker that pre-split clients don't match, so they can never mistake a fragment for a full snapshot. Only domains whose content changed republish, a refused publish retries on the next save instead of being marked done, and readers apply full-snapshot semantics only to core-carrying events. The ark projection also caps its history: spent stubs at 200, done actions at 50 — the event size is bounded for life, no special relay rules required. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/nostr.js')
-rw-r--r--src/nostr.js28
1 files changed, 24 insertions, 4 deletions
diff --git a/src/nostr.js b/src/nostr.js
index 05ae723..0992122 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -149,12 +149,29 @@ function deviceId() {
}
const bytesToHexLocal = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
export const deviceDtag = (netName) => `${syncDtag(netName)}:d:${deviceId()}`;
+// Per-DOMAIN slot (`<base>:x:<deviceId>:<domain>`): the snapshot is split
+// into independent pieces (core wallet, ark, nwc, …) so each event stays far
+// below common relay size limits and only changed domains republish. The
+// `:x:` marker is deliberately NOT `:d:` — clients from before the split
+// don't match it, so they never mistake a domain fragment for a full
+// snapshot and wipe their core state with it.
+export const domainDtag = (netName, domain) => `${syncDtag(netName)}:x:${deviceId()}:${domain}`;
// True if `evtDtag` is one of OUR slots for `netName` (our device, another
-// device, or the legacy shared tag) — used to select events to merge.
+// device, a domain slot, or the legacy shared tag) — selects events to merge.
export const isOurDtag = (evtDtag, netName) => {
const base = syncDtag(netName);
- return evtDtag === base || evtDtag.startsWith(base + ':d:');
+ return evtDtag === base || evtDtag.startsWith(base + ':d:') || evtDtag.startsWith(base + ':x:');
};
+// True if the event carries CORE wallet fields (legacy full snapshots and the
+// split 'core' domain) — the only events safe to apply as a full snapshot.
+export const isCoreDtag = (evtDtag, netName) => {
+ const base = syncDtag(netName);
+ if (evtDtag === base || evtDtag.startsWith(base + ':d:')) return true;
+ return evtDtag.startsWith(base + ':x:') && evtDtag.endsWith(':core');
+};
+// True if the slot belongs to THIS device (legacy or domain form).
+export const isOwnDeviceDtag = (evtDtag, netName) =>
+ evtDtag === deviceDtag(netName) || evtDtag.startsWith(`${syncDtag(netName)}:x:${deviceId()}:`);
const SYNC_KEY = 'btc-wallet-sync';
export const DEFAULT_SYNC_RELAYS = ['wss://relay.coinos.io'];
@@ -212,7 +229,7 @@ export class NostrSync {
try {
const content = nip44.encrypt(JSON.stringify(stateObj), this.ck);
evt = finalizeEvent({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag]], content }, this.sk);
- } catch { return; }
+ } catch { return false; }
const results = await Promise.allSettled(pool.publish(this.relays, evt));
// A refused snapshot means devices silently stop syncing — the failure
// that once hid behind "my other device shows no history". Say so.
@@ -221,6 +238,7 @@ export class NostrSync {
console.warn(`nostr: state snapshot (${evt.content.length}b) refused by ${this.relays[i]}:`, r.reason?.message || r.reason);
}
});
+ return results.some((r) => r.status === 'fulfilled');
}
// Deliver an encrypted DM (a locked gift's claim code) as a NIP-17 gift wrap
@@ -301,7 +319,9 @@ export class NostrSync {
// best snapshot). Prefers our own device's slot, else the newest of ours.
async fetch() {
const all = await this.fetchAllStates();
- return all.length ? all[0].state : null;
+ // a domain fragment (ark/nwc slice) is not a usable "best snapshot"
+ const fulls = all.filter((s) => !s.dtag.includes(':x:') || s.dtag.endsWith(':core'));
+ return fulls.length ? fulls[0].state : null;
}
// All of this identity's decrypted state snapshots, newest first, each with