aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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/features
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/features')
-rw-r--r--src/features/ark.js13
-rw-r--r--src/features/nwc.js1
-rw-r--r--src/features/sync.js65
3 files changed, 65 insertions, 14 deletions
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) });
}