From ef1a901314cb667bc8a7c7ff5f080550edef2815 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 6 Jul 2026 10:44:35 -0700 Subject: Slim ark state for sync so it fits the relay's 64KB event cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-device ark sync silently failed: the full snapshot (deep-genesis vtxo bytes + done-action signing hex) was ~153KB and relay.coinos.io rejects events over 64KB, so no publish ever landed and other devices never saw the balance. The synced ark state is now slimmed — spent vtxos drop their signed bytes (kept only for spendable/pending, which a device might spend/exit), done board/offboard/exit actions keep just the fields history labels need (hex stripped), in-flight actions and movements beyond the last 300 are dropped. Local storage keeps the full state; only the published copy is slim. ~150KB -> well under 64KB. Co-Authored-By: Claude Fable 5 --- src/features/ark.js | 34 ++++++++++++++++++++++++++++------ src/features/sync.js | 3 +-- src/nostr.js | 5 ++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/features/ark.js b/src/features/ark.js index d0f17f2..f22d7be 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -37,10 +37,33 @@ export function installArkWallet(wallet) { }); } +// A slim projection of ark state for the sync snapshot — the sync relay caps +// events at 64 KB and the full state (deep-genesis vtxo bytes + done-action +// hex) blew past it, so publishes silently failed and nothing synced. Another +// device needs, per vtxo: id/amount/state (+ bytes ONLY for spendable/pending, +// which it might spend/exit); the movement log (capped) for ark history; and +// done board/offboard/exit actions (stripped of hex) so on-chain rows still +// label. In-flight actions and their signing material stay device-local. +export function slimArkForSync(s) { + if (!s) return null; + const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; + return { + v: s.v, 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), + actions: (s.actions || []) + .filter((a) => a.step === 'done' && ['board', 'offboard', 'exit'].includes(a.type)) + .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }), + movements: (s.movements || []).slice(-300), + gifts: s.gifts, + }; +} + // Merge two ark states so devices can't clobber each other through the sync -// snapshot: vtxos union by id with 'spent' winning (a spend on any device -// sticks), actions/movements/gifts union by id, counters take the max (so two -// devices never reuse a key index). +// snapshot: additive union of vtxos (a device keeps its own full copy, gains +// ones it lacks), actions/movements/gifts union by id, counters take the max +// (so two devices never reuse a key index). export function mergeArkStates(a, b) { if (!a) return b; if (!b) return a; @@ -91,7 +114,7 @@ export function arkFeature(ctx) { mergeAlways: true, // load() is a commutative merge — apply older snapshots too save: () => { const s = wallet.loadArkState(); - return s ? { arkState: s } : {}; + return s ? { arkState: slimArkForSync(s) } : {}; }, load: (d) => { if (!d.arkState) return; @@ -182,8 +205,7 @@ export function arkFeature(ctx) { // an idle device never shared its coins — another device would never see // them. Publishing on connect guarantees every device's ark balance // reaches the relay (and gets merged) without needing a transaction first. - console.log('[arkdbg] connected, vtxos=', (mgr.state && mgr.state.vtxos || []).length, 'net=', getNetwork()); - if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); console.log('[arkdbg] on-connect saveCache called'); } catch (e) { console.log('[arkdbg] saveCache threw', e.message); } } + if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} } const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); // Reconcile once on connect: a vtxo synced in from another device (or diff --git a/src/features/sync.js b/src/features/sync.js index 9d361aa..1f3414d 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -111,9 +111,8 @@ export function installSyncWallet(wallet) { // 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); - console.log('[syncdbg] cacheSaved scheduling publish', dtag, 'hasArk?', !!snap.arkState, 'sk?', !!wallet.nostr.sk); clearTimeout(wallet._nostrPubTimer); - wallet._nostrPubTimer = setTimeout(() => { console.log('[syncdbg] publishing now', dtag); wallet.nostr.publish(snap, dtag); }, 2500); + wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap, dtag), 2500); }); wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) }); } diff --git a/src/nostr.js b/src/nostr.js index a1d6cc4..e73aa5d 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -163,9 +163,8 @@ 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 (e) { console.log('[pubdbg] finalize threw', e.message); return; } - const res = await Promise.allSettled(pool.publish(this.relays, evt)); - console.log('[pubdbg] publish', dtag, 'bytes', evt.content.length, '->', JSON.stringify(res.map((r) => r.status === 'fulfilled' ? 'ok' : ('rej:' + String(r.reason).slice(0, 80))))); + } catch { return; } + await Promise.allSettled(pool.publish(this.relays, evt)); } // Deliver an encrypted DM (a locked gift's claim code) as a NIP-17 gift wrap -- cgit v1.2.3