aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js13
-rw-r--r--src/features/nwc.js1
-rw-r--r--src/features/sync.js65
-rw-r--r--src/nostr.js28
4 files changed, 89 insertions, 18 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) });
}
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