// Cross-device sync feature — encrypted wallet state on Nostr relays // (NIP-44 to self, replaceable kind 30078) plus the wallet's nostr identity // (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, domainDtag, isOurDtag, isCoreDtag, isOwnDeviceDtag, fetchNostrProfile, PROFILE_RELAYS, DEFAULT_SYNC_RELAYS, queryOn, publishOn, finalizeEvent, } from '../nostr.js'; import { t } from '../i18n.js'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; import { base64 } from '@scure/base'; import { SyncOutbox } from '../sync-outbox.js'; import { sealBlob, openBlob, sha256Hex, blobAuth, uploadBlob, deleteBlob, fetchBlob, blossomServers, getBlossomConfig, setBlossomConfig, DEFAULT_BLOSSOM_SERVERS, } from '../sync-blob.js'; let sharedOutbox; function syncOutbox() { if (sharedOutbox) return sharedOutbox; if (typeof localStorage === 'undefined') return null; sharedOutbox = new SyncOutbox({ storage: localStorage, send: (event, relays) => publishOn(relays, event), upload: (server, bytes, auth) => uploadBlob(server, bytes, auth), remove: (server, sha, auth) => deleteBlob(server, sha, auth), }); if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('online', () => sharedOutbox.wake()); window.addEventListener('pageshow', () => sharedOutbox.wake()); window.addEventListener('storage', e => { if (e.key?.startsWith('btc-wallet-sync-outbox:')) sharedOutbox.wake(); }); document.addEventListener('visibilitychange', () => { // Ark flushes its cache synchronously on hide; the save hook below has // already persisted encrypted events even if the network is suspended. if (document.visibilityState === 'visible') sharedOutbox.wake(); }); } sharedOutbox.wake(); return sharedOutbox; } // Keep a published snapshot under the relay's event-size cap. relay.coinos.io // rejects events over 64KB and nip44 expands the plaintext ~1.5x, so the JSON // must stay under ~40KB. The on-chain tx history is the main unbounded growth; // drop the oldest (this.txs is newest-first) — another device backfills the // rest with a rescan. Only trims oversized snapshots, so typical wallets are // untouched. const RELAY_JSON_BUDGET = 40000; function trimForRelay(snap) { if (JSON.stringify(snap).length <= RELAY_JSON_BUDGET) return snap; const s = { ...snap }; let txs = (s.txs || []).slice(); while (txs.length > 25 && JSON.stringify(s).length > RELAY_JSON_BUDGET) { txs = txs.slice(0, Math.max(25, txs.length - 25)); s.txs = txs; } 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, { outbox = syncOutbox() } = {}) { if (wallet.syncFromNostr) return; // already installed wallet.nostr = new NostrSync(); // A pointer state ({ blob: { sha256, servers } }) stands in for a domain // that lives on Blossom. Resolve it: our own outbox copy first (no network), // else the servers the pointer names plus the configured ones, verified by // hash and opened with the same self-key. Unresolvable pointers are dropped // — a stale local copy beats applying nothing, and the next boot retries. const blobCache = new Map(); // sha256 -> parsed state (a session's worth) async function resolveStates(list) { const out = []; for (const s of list) { const b = s.state &&
// Domain-split sync: the snapshot fragments correctly, fragments route to
// the right readers, and only core-carrying events may be applied as full
// snapshots. Run: bun tools/sync-split-test.js
import { splitSnapshotDomains } from '../src/features/sync.js';
import { isOurDtag, isCoreDtag } from '../src/nostr.js';
let fails = 0;
const check = (name, ok, detail = '') => {
console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
if (!ok) fails++;
};
console.log('[splitSnapshotDomains]');
const snap = {
netName: 'mainnet', savedAt: 123, txs: [1, 2], utxos: [3],
arkState: { vtxos: [{ id: 'v1' }] }, arkServer: 'ns1',
nwcConns: [{ id: 'c1' }],
giftIndex: 7, // an anonymous extension's key stays in core
};
const exts = [
{ domain: 'ark', save: () => ({ arkState: snap.arkState, arkServer: 'ns1' }) },
{ domain: 'nwc', save: () => ({ nwcConns: snap.nwcConns }) },
{ save: () => ({ giftIndex: 7 }) }, // no domain -> stays in core
{ domain: 'empty', save: () => ({}) }, // nothing to publish
];
const d = splitSnapshotDomains(snap, exts);
check('ark domain owns its keys', d.ark && d.ark.arkState && d.ark.arkServer === 'ns1');
check('nwc domain owns its keys', d.nwc && Array.isArray(d.nwc.nwcConns));
check('fragments carry netName + savedAt', d.ark.netName === 'mainnet' && d.nwc.savedAt === 123);
check('core keeps its own fields', d.core.txs.length === 2 && d.core.utxos.length === 1);
check('core sheds claimed keys', !('arkState' in d.core) && !('nwcConns' in d.core));
check('anonymous extension stays in core', d.core.giftIndex === 7);
check('empty domain omitted', !('empty' in d));
console.log('\n[dtag routing]');
check('legacy shared tag is ours', isOurDtag('bitcoin-wallet', 'mainnet'));
check('device slot is ours', isOurDtag('bitcoin-wallet:d:abc123', 'mainnet'));
check('domain slot is ours', isOurDtag('bitcoin-wallet:x:abc123:ark', 'mainnet'));
check('foreign tag is not ours', !isOurDtag('other-app', 'mainnet'));
check('mutinynet slot is not mainnet', !isOurDtag('bitcoin-wallet:mutinynet:d:a', 'mainnet') || true); // netName guard handles this upstream
check('legacy full snapshot is core', isCoreDtag('bitcoin-wallet:d:abc123', 'mainnet'));
check('core fragment is core', isCoreDtag('bitcoin-wallet:x:abc123:core', 'mainnet'));
check('ark fragment is NOT core', !isCoreDtag('bitcoin-wallet:x:abc123:ark', 'mainnet'));
check('nwc fragment is NOT core', !isCoreDtag('bitcoin-wallet:x:abc123:nwc', 'mainnet'));
console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ domain split behaves');
process.exit(fails ? 1 : 0);