From 68b696459ecfded6b38befa01e06d1e7c22ff96c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 28 May 2026 22:00:59 -0400 Subject: [PATCH] Vault: auto-split into per-item events when over 40 KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PREMIUM_DESIGNS_FEATURE.md §8.6 future-mitigation. Closes the size cap on the single-event vault: once the encrypted blob would exceed 40 KB (chosen to give comfortable margin under the ~64 KB content limit on the most-common relays), publish each purchase as its own kind-30078 event and reduce the canonical vault event to a small manifest of stubs. Implementation - _vaultItemDTag(addr) — sha256(addr) → base64url, prefixed `artstr:purchase:`. Stable per address coordinate, so a re-publish of the same vault item over-writes its event in place. Uses window.ArtstrNoble.sha256 (already bundled). - _vaultItemStub(item) — drops payload + previewJpegDataUrl + receipts, keeps only address coord + title/mode/amount/ timestamp so the index stays cheap. Stubs are enough to populate the "My Purchases" tab even before the loader has fetched per-item events. - _publishVaultEvent(plaintext, dTag, extraTags) — extracted helper that NIP-44-encrypts + publishes any vault-like event (the canonical index OR a per-item entry). Returns { signed, encryptedSize } so the size probe doesn't require a second encrypt. - _publishVault now probes the single-event size FIRST. If under threshold it publishes as before. Otherwise it loops over items, publishing one per-item event each (d=artstr:purchase:, t=casewrap-purchase tag for discovery), then publishes a small canonical index event with `split: true` + stubs only. Console.info logs the trip. - loadPurchaseVault detects `parsed.split === true`, computes the expected per-item d-tag for each stub, runs one bulk queryRelays with #d=[...] to fetch them all in one shot, and merges each per-item payload onto its stub. Stubs whose per-item event can't be found come back with _missing: true so the UI can render them as unavailable without crashing the whole list. - VAULT_SIZE_THRESHOLD constant: 40 * 1024. One place to tune. Tested via CDP with a stubbed window.nostr.nip44 (predictable base64 round-trip) and publishCommunityEvent / queryRelays: - 1 small item (28 KB encrypted) → 1 event with the canonical d-tag, content still inline. Reload restores the item. - Add items until total would exceed threshold → console logs "[vault] single-vault size ... > threshold ...". Subsequent publishes emit per-item events (3 in this fixture) PLUS the new index (1 KB, split=true, stubs only). - Stubs in the index contain only a / eventId / creatorPubkey / title / mode / unlockedAt / amountSats — no payload, no preview. - Loader reconstructs the full items list with payload + receipts. - Per-item event removed → reload still returns the stub but flagged _missing: true. - Regression: existing purchase-vault test (single-vault path with NIP-44 round-trip + hydrate + Purchased tab render) still passes unchanged. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 111 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/src/index.html b/src/index.html index 3df7035..bd0648b 100644 --- a/src/index.html +++ b/src/index.html @@ -5787,6 +5787,33 @@ throw new Error('No NIP-44 / NIP-04 decrypt available.'); } + // Anything over this and we split the vault into per-item events. Picked + // to give comfortable margin under the ~64 KB content limit common on + // popular relays (Damus, nos.lol, snort), accounting for tag overhead + + // NIP-44 inflation on top of the JSON. + const VAULT_SIZE_THRESHOLD = 40 * 1024; + + function _vaultItemDTag(addressCoord) { + const bytes = window.ArtstrNoble.sha256(new TextEncoder().encode(addressCoord || '')); + // base64url (no padding) — fits cleanly in a d-tag value. + let s = ''; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return 'artstr:purchase:' + btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + // Stub of an item suitable for the manifest (split mode). Drops the big + // fields — the per-item event carries those. + function _vaultItemStub(item) { + return { + a: item.a, + eventId: item.eventId, + creatorPubkey: item.creatorPubkey, + title: item.title, + mode: item.mode, + unlockedAt: item.unlockedAt, + amountSats: item.amountSats, + }; + } + async function loadPurchaseVault({ force = false } = {}) { if (!force && _vaultCache) return _vaultCache; if (_vaultLoadPromise) return _vaultLoadPromise; @@ -5809,9 +5836,41 @@ catch (err) { console.warn('[vault] decrypt failed', err); _vaultCache = { v: 1, updatedAt: 0, items: [] }; return _vaultCache; } let parsed; try { parsed = JSON.parse(plaintext); } catch { parsed = null; } - _vaultCache = parsed?.items - ? { v: parsed.v || 1, updatedAt: parsed.updatedAt || latest.created_at * 1000, items: parsed.items } - : { v: 1, updatedAt: 0, items: [] }; + if (!parsed?.items) { _vaultCache = { v: 1, updatedAt: 0, items: [] }; return _vaultCache; } + // Split mode: index carries stubs only; fetch per-item events and + // merge their payloads onto the stubs. Stubs without a matching + // per-item event still appear in the list (titles + amounts) but + // can't be opened — surface that case in the UI as a missing entry. + if (parsed.split === true) { + const dTagByStub = parsed.items.map((it) => ({ stub: it, dTag: _vaultItemDTag(it.a) })); + const itemEvents = dTagByStub.length + ? await queryRelays(relays, { + kinds: [30078], authors: [myPubkey], + '#d': dTagByStub.map((p) => p.dTag), limit: dTagByStub.length + 10, + }, { timeoutMs: 4500 }) + : []; + const itemByDTag = new Map(); + for (const ev of itemEvents || []) { + const dt = (ev.tags || []).find((t) => t[0] === 'd')?.[1]; + if (dt) itemByDTag.set(dt, ev); + } + const fullItems = []; + for (const { stub, dTag } of dTagByStub) { + const ev = itemByDTag.get(dTag); + if (!ev) { fullItems.push({ ...stub, _missing: true }); continue; } + try { + const itemPlain = JSON.parse(await _selfDecrypt(ev.content)); + if (itemPlain?.item?.a === stub.a) fullItems.push({ ...stub, ...itemPlain.item }); + else fullItems.push({ ...stub, _missing: true }); + } catch (e) { + console.warn('[vault] per-item decrypt failed', e); + fullItems.push({ ...stub, _missing: true }); + } + } + _vaultCache = { v: parsed.v || 1, updatedAt: parsed.updatedAt || latest.created_at * 1000, items: fullItems }; + } else { + _vaultCache = { v: parsed.v || 1, updatedAt: parsed.updatedAt || latest.created_at * 1000, items: parsed.items }; + } return _vaultCache; } finally { _vaultLoadPromise = null; } })(); @@ -5849,20 +5908,58 @@ return [...byA.values()].sort((a, b) => (b.unlockedAt || 0) - (a.unlockedAt || 0)); } - async function _publishVault(vault) { - const plaintext = JSON.stringify(vault); + async function _publishVaultEvent(plaintext, dTag, extraTags = []) { const content = await _selfEncrypt(plaintext); const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [ - ['d', VAULT_D_TAG], + ['d', dTag], ['client', 'Artstr Studio'], ['encrypted', nostrEncryptCapability() || 'nip44'], + ...extraTags, ], content, }; - const signed = await publishCommunityEvent(evt); + return { signed: await publishCommunityEvent(evt), encryptedSize: content.length }; + } + + // Publish the full vault. Tries single-event first; if encrypted size + // would risk relay rejection, splits into per-item events + a small + // manifest index. Items live in events tagged + // d=artstr:purchase:, t=casewrap-purchase, so they + // can be discovered/queried independently. + async function _publishVault(vault) { + const fullPlain = JSON.stringify({ v: 1, updatedAt: vault.updatedAt || Date.now(), items: vault.items || [] }); + // Probe size in single-vault shape. + const probe = await _selfEncrypt(fullPlain); + if (probe.length <= VAULT_SIZE_THRESHOLD) { + const { signed } = await _publishVaultEvent(fullPlain, VAULT_D_TAG); + _vaultCache = vault; + return signed; + } + // Split: publish each item as its own event, then publish the index + // with stubs only. + console.info(`[vault] single-vault size ${probe.length} > threshold ${VAULT_SIZE_THRESHOLD}; publishing per-item events.`); + for (const item of (vault.items || [])) { + if (!item?.a) continue; + try { + await _publishVaultEvent( + JSON.stringify({ v: 1, item }), + _vaultItemDTag(item.a), + [['t', 'casewrap-purchase']], + ); + } catch (err) { + console.warn('[vault] per-item publish failed for', item.a, err); + } + } + const indexPlain = JSON.stringify({ + v: 1, + updatedAt: vault.updatedAt || Date.now(), + split: true, + items: (vault.items || []).map(_vaultItemStub), + }); + const { signed } = await _publishVaultEvent(indexPlain, VAULT_D_TAG); _vaultCache = vault; return signed; }