mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-01 19:20:40 +00:00
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:
1 parent
99b386db54
commit
68b696459e
1 file changed
+104
-7
+104
-7
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user