Vault: auto-split into per-item events when over 40 KB

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:<sha>, 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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-28 22:00:59 -04:00
1 parent 99b386db54
commit 68b696459e
1 file changed
+104 -7
+104 -7
View File
@@ -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:<sha256_b64url(addr)>, 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;
}