diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 18:08:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 18:08:58 -0700 |
| commit | cbc3a5907c55c20c248fa80fa14528cb483688ad (patch) | |
| tree | 85f7b815f18095ec550583bb16850ba6a6c05ce3 /src/nostr.js | |
| parent | 711807cb81f85408451b843e1a53e9616fd6df3b (diff) | |
| download | coinosv3-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.js | 28 |
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 |
