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;
}