From 57289fd3f87554e9981ddc2c63c3bba805d599bd Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 9 Sep 2026 23:12:13 -0700 Subject: Phone boot: remember opened DM wraps across boots; supersede stuck relay-only sync records. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote-signer (bunker) wallet re-opened the relay's last ~400 gift wraps on EVERY boot — the seen-set lived in memory only — and each wrap costs two NIP-46 round trips with a schnorr sign+verify apiece on the client. On the Pixel that was ~4s of a 15s pegged boot. Opened (and given-up) wrap ids are now kept in the messages feature state, bounded to 1500 id prefixes, and loaded before the inbox subscription. The outbox also replaces a record whose route changed even when its digest matches: the 874KB relay-only ark record that could never publish would otherwise have stayed in front of its Blossom successor forever. Verified live: the wallet's 633KB ark domain uploaded to blossom.coinos.io and its pointer was acknowledged by relay.coinos.io on the next boot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE --- src/features/messages.js | 34 +++++++++++++++++++++++++++++----- src/sync-outbox.js | 5 ++++- 2 files changed, 33 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 6e4c781..0a828bb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -245,6 +245,29 @@ export function messagesFeature(ctx) { const pendingDirect = new Map(); // rumor id -> { bundle, from } const profiles = new Map(); // pubkey -> profile | null while loading const seenWraps = new Set(); + // Wraps this device already opened (or gave up on), remembered ACROSS + // boots: relays re-serve their last ~400 wraps on every subscribe, and a + // remote-signer wallet paid two NIP-46 round trips (sign + verify each) + // per wrap to rediscover DMs it already had — ~4s of a phone's boot. + // Keyed by an id prefix (64 bits is plenty for "skip this one"). + const WRAPS_MAX = 1500; + const wrapKey = (id) => String(id).slice(0, 16); + let wrapLog = null, wrapsTimer = 0; + function loadSeenWraps() { + if (wrapLog) return; + wrapLog = (st().wraps || []).slice(-WRAPS_MAX); + for (const k of wrapLog) seenWraps.add(k); + } + function rememberWrap(id) { + const k = wrapKey(id); + if (!wrapLog) wrapLog = []; + if (!seenWraps.has(k)) wrapLog.push(k); + seenWraps.add(k); + clearTimeout(wrapsTimer); + wrapsTimer = setTimeout(() => { + try { const s = st(); s.wraps = wrapLog.slice(-WRAPS_MAX); wrapLog = s.wraps; save(s); } catch {} + }, 1500); + } let dmStarted = false; let allUnsubs = []; @@ -1399,9 +1422,9 @@ export function messagesFeature(ctx) { } async function handleInboxWrap(wrap) { - if (seenWraps.has(wrap.id)) return; + if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) return; seenWraps.add(wrap.id); - if (await openInboxWrap(wrap)) return; + if (await openInboxWrap(wrap)) { rememberWrap(wrap.id); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, tries: decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); @@ -1436,8 +1459,8 @@ export function messagesFeature(ctx) { // full-strength failures would evict messages that were never really // tried. Stop here — the slow retry picks the rest up. if (complete && !decryptorsComplete()) break; - if (await openInboxWrap(p.wrap)) pendingWraps.delete(id); - else if (complete && ++p.tries >= PENDING_TRIES) pendingWraps.delete(id); + if (await openInboxWrap(p.wrap)) { pendingWraps.delete(id); rememberWrap(id); } + else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); @@ -1464,6 +1487,7 @@ export function messagesFeature(ctx) { const pks = dmSubKeys(); if (!pks.length) return; dmStarted = true; + loadSeenWraps(); // warm threads from the local cache. A self-thread of our own messages is // the residue of the old sent-copy misthreading (each welcome DM landed // under our own key) — sweep it rather than resurrect it; the cached @@ -3463,7 +3487,7 @@ export function messagesFeature(ctx) { rooms.clear(); threads.clear(); pendingDirect.clear(); - seenWraps.clear(); // the next account must decrypt wraps this one couldn't + seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't clearTimeout(drainTimer); pendingWraps.clear(); dmStarted = false; diff --git a/src/sync-outbox.js b/src/sync-outbox.js index 2062d9e..80042d1 100644 --- a/src/sync-outbox.js +++ b/src/sync-outbox.js @@ -52,7 +52,10 @@ export class SyncOutbox { const key = PREFIX + pubkey + ':' + dtag; let previous; try { previous = JSON.parse(this.storage.getItem(key)); } catch {} - if (previous?.digest === digest) { this.wake(); return; } + // Same content, same route: nothing to do. A record that changed route + // (an oversized domain now bound for Blossom, or back inline) is replaced + // even for identical content — the stuck relay-only copy must go. + if (previous?.digest === digest && !!previous.blob === !!blob) { this.wake(); return; } // A pending, never-sent event can be replaced within the same second. // Once attempted, use a later timestamp: Nostr resolves equal timestamps // by event id, which can otherwise leave the OLD snapshot in the relay. -- cgit v1.2.3