From cbc3a5907c55c20c248fa80fa14528cb483688ad Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 18:08:58 -0700 Subject: Split device sync into per-domain slots with bounded, dirty-only publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/ark.js | 13 ++++++++--- src/features/nwc.js | 1 + src/features/sync.js | 65 +++++++++++++++++++++++++++++++++++++++++++--------- src/nostr.js | 28 ++++++++++++++++++---- 4 files changed, 89 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index ff0dd13..8456f69 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -130,11 +130,17 @@ export function slimArkForSync(s) { const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, - vtxos: (s.vtxos || []).map((v) => v.state === 'spent' - ? { id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight } - : v), + // Spent stubs and done actions are pure history and grow forever — cap + // them (newest kept) so the sync event's size is bounded for life. + vtxos: (() => { + const live = (s.vtxos || []).filter((v) => v.state !== 'spent'); + const stubs = (s.vtxos || []).filter((v) => v.state === 'spent').slice(-200) + .map((v) => ({ id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight })); + return [...live, ...stubs]; + })(), actions: (s.actions || []) .filter((a) => a.step === 'done' && ['board', 'offboard', 'exit'].includes(a.type)) + .slice(-50) .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }), movements: (s.movements || []).slice(-200).map((m, i, arr) => // Older rows only need to render a history line; the bolt11 (~400 chars @@ -199,6 +205,7 @@ export function arkFeature(ctx) { // didn't), re-publish the superset. The slot converges to the union no // matter who publishes when. wallet.registerCacheExtension({ + domain: 'ark', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // load() is a commutative merge — apply older snapshots too save: () => { const cfg = getArkConfig(); diff --git a/src/features/nwc.js b/src/features/nwc.js index c2407ee..adafb06 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -155,6 +155,7 @@ export function nwcFeature(ctx) { // sync is an optional feature: a minimal build has no cache extensions if (wallet.registerCacheExtension) wallet.registerCacheExtension({ + domain: 'nwc', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // the merge is commutative, so older snapshots are fine save: () => { const list = (load().conns || []); diff --git a/src/features/sync.js b/src/features/sync.js index 76277a7..94dfc68 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -3,7 +3,11 @@ // (used e.g. to DM locked-gift claim codes). Installed onto the core wallet; // a build without sync ships none of it and never talks to a relay. -import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, isOurDtag, fetchNostrProfile, PROFILE_RELAYS } from '../nostr.js'; +import { + NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, + domainDtag, isOurDtag, isCoreDtag, isOwnDeviceDtag, + fetchNostrProfile, PROFILE_RELAYS, +} from '../nostr.js'; import { t } from '../i18n.js'; // Keep a published snapshot under the relay's event-size cap. relay.coinos.io @@ -24,6 +28,28 @@ function trimForRelay(snap) { return s; } +// Split a snapshot into per-domain pieces, each published as its own +// replaceable event: an extension registered with a `domain` claims its keys, +// everything left is the core wallet. Every piece carries netName + savedAt so +// readers can apply the same guards as for full snapshots. Small events scale +// on vanilla relays (no special size rules) and only changed domains +// republish, so a payment burst no longer re-uploads the whole wallet. +export function splitSnapshotDomains(snap, extensions = []) { + const core = { ...snap }; + const out = {}; + for (const e of extensions) { + if (!e.domain || !e.save) continue; + let obj; + try { obj = e.save(); } catch { continue; } + const keys = Object.keys(obj || {}); + if (!keys.length) continue; + out[e.domain] = { netName: snap.netName, savedAt: snap.savedAt, ...obj }; + for (const k of keys) delete core[k]; + } + out.core = core; + return out; +} + export function installSyncWallet(wallet) { if (wallet.syncFromNostr) return; // already installed wallet.nostr = new NostrSync(); @@ -106,11 +132,15 @@ export function installSyncWallet(wallet) { const mine = all.filter((s) => s.state.netName === this.netName && isOurDtag(s.dtag, this.netName)); if (!mine.length) return false; // Merge every device's merge-safe extension state (ark vtxos union) — a - // device sees ALL devices' coins, not only the newest snapshot's. + // device sees ALL devices' coins, not only the newest snapshot's. Domain + // fragments route themselves: an extension's load() only reads its keys. for (const s of mine) this._mergeSnapshotExtensions(s.state); - // Apply the newest FULL snapshot for the rescannable on-chain state. - const newest = mine[0].state; // fetchAllStates returns newest-first - if ((newest.savedAt || 0) > (this._savedAt || 0)) { + // Apply the newest CORE-carrying snapshot for the rescannable on-chain + // state. Domain fragments (ark/nwc slices) must never be applied as a + // full snapshot — they'd blank the core fields they don't carry. + const fulls = mine.filter((s) => isCoreDtag(s.dtag, this.netName)); + const newest = fulls[0] && fulls[0].state; // fetchAllStates returns newest-first + if (newest && (newest.savedAt || 0) > (this._savedAt || 0)) { this._applySnapshot(newest); // re-runs extension loads (idempotent merge) this.emit(); } @@ -123,6 +153,7 @@ export function installSyncWallet(wallet) { wallet.registerLoadHook(() => { if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase); else wallet.nostr.unload(); + wallet._syncPubSeen = {}; // a new identity has published nothing yet }); // Live cross-device state: subscribe to ALL our devices' slots so a save on // another device merges here within seconds. Merge-safe extension state (ark @@ -138,7 +169,7 @@ export function installSyncWallet(wallet) { stateUnsub = wallet.nostr.subscribeStates((v, dtag) => { if (!v.netName || v.netName !== wallet.netName) return; if (!isOurDtag(dtag, wallet.netName)) return; - if (dtag === deviceDtag(wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo + if (isOwnDeviceDtag(dtag, wallet.netName) && (v.savedAt || 0) === (wallet._savedAt || 0)) return; // our own echo wallet._mergeSnapshotExtensions(v); }); }, @@ -146,16 +177,28 @@ export function installSyncWallet(wallet) { }); // push every saved snapshot to the relays (debounced) unless sync is off — - // to THIS DEVICE's own slot, so it never clobbers another device's. + // 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) wallet.registerCacheSavedHook((snap) => { const sync = getSyncConfig(); if (wallet.offline || !sync.enabled) return; wallet.nostr.setRelays(sync.relays); - // bind the per-device d-tag now: the debounced publish must keep this - // snapshot's network even if the wallet switches networks before it fires - const dtag = deviceDtag(wallet.netName); + // 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(() => wallet.nostr.publish(trimForRelay(snap), dtag), 2500); + wallet._nostrPubTimer = setTimeout(async () => { + 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 + } + }, 2500); }); wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) }); } 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 (`:x::`): 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 -- cgit v1.2.3