diff --git a/src/index.html b/src/index.html
index 6c1da66..859c062 100644
--- a/src/index.html
+++ b/src/index.html
@@ -5767,7 +5767,10 @@
if (!Array.isArray(gatedEvents) || !gatedEvents.length) return;
const myPubkey = normalizePubkey(state.community?.myPubkey || '');
if (!myPubkey) return;
- const relays = splitRelayList(state.community?.relays || '');
+ // Query receipts on the wider vault relay union so a session that
+ // narrowed its relay list (or runs on a different domain) can
+ // still recover prior purchases.
+ const relays = _vaultRelayUnion();
const freshEvents = gatedEvents.filter((e) => !isUnlockedLocally(e.id));
if (!freshEvents.length) return;
@@ -5797,25 +5800,95 @@
}, { timeoutMs: 4000 }).catch(() => []));
}
const receiptSets = await Promise.all(queries);
+ // Bucket the full receipt events by address / event-id so the
+ // decrypt-and-back-fill step below can attach real evidence to
+ // the vault entry, not just "yes, paid".
+ const receiptsByAddress = new Map();
+ const receiptsByEventId = new Map();
const seenAddresses = new Set();
const seenEventIds = new Set();
for (const receipts of receiptSets) {
- for (const ev of receipts || []) {
- const aTag = (ev.tags || []).find((t) => t[0] === 'a' && t[1]);
- if (aTag) seenAddresses.add(aTag[1]);
- const eTag = (ev.tags || []).find((t) => t[0] === 'e' && t[1]);
- if (eTag) seenEventIds.add(eTag[1]);
+ for (const r of receipts || []) {
+ const aTag = (r.tags || []).find((t) => t[0] === 'a' && t[1]);
+ const eTag = (r.tags || []).find((t) => t[0] === 'e' && t[1]);
+ if (aTag) {
+ seenAddresses.add(aTag[1]);
+ const arr = receiptsByAddress.get(aTag[1]) || [];
+ arr.push(r);
+ receiptsByAddress.set(aTag[1], arr);
+ }
+ if (eTag) {
+ seenEventIds.add(eTag[1]);
+ const arr = receiptsByEventId.get(eTag[1]) || [];
+ arr.push(r);
+ receiptsByEventId.set(eTag[1], arr);
+ }
}
}
for (const ev of freshEvents) {
if (isUnlockedLocally(ev.id)) continue;
const addr = addressByEvent.get(ev.id);
- if (addr && seenAddresses.has(addr)) {
- markUnlocked(ev.id, { source: 'relay-fastpath-a', address: addr });
- continue;
+ const matchedByAddress = !!(addr && seenAddresses.has(addr));
+ const matchedByEventId = seenEventIds.has(ev.id);
+ if (!matchedByAddress && !matchedByEventId) continue;
+
+ // The soft-gate KDF is deterministic from public material +
+ // the static client pepper, so a valid receipt is sufficient
+ // to re-derive and decrypt locally — no creator-online step,
+ // no surviving vault required. Run the decrypt now so the
+ // card unlocks fully (payload + preview), not just the flag.
+ let envelope = null;
+ let decryptedPayload = null;
+ try {
+ envelope = JSON.parse(ev.content || '{}');
+ if (isPremiumEvent(ev)) {
+ decryptedPayload = await Premium.decryptPayload(envelope, {
+ creatorPubkey: ev.pubkey,
+ dTag: premiumEventDTag(ev),
+ schemaVersion: envelope?.payloadSchemaVersion || 5,
+ });
+ }
+ } catch (err) {
+ console.warn('[zap-gated] fast-path decrypt failed for', ev.id, err);
}
- if (seenEventIds.has(ev.id)) {
- markUnlocked(ev.id, { source: 'relay-fastpath-e' });
+
+ markUnlocked(ev.id, {
+ source: matchedByAddress ? 'relay-fastpath-a' : 'relay-fastpath-e',
+ address: addr || '',
+ payload: decryptedPayload || undefined,
+ previewJpegDataUrl: envelope?.preview?.dataUrl || undefined,
+ });
+
+ // Opportunistically back-fill the vault so the next device /
+ // domain doesn't have to redo this receipt scan. Failures
+ // here are non-fatal — the local cache still holds the
+ // re-derived payload and the receipts remain on relays.
+ if (decryptedPayload && isPremiumEvent(ev)) {
+ const matchedReceipts =
+ (matchedByAddress ? (receiptsByAddress.get(addr) || []) : (receiptsByEventId.get(ev.id) || []));
+ const totalSats = matchedReceipts.reduce((sum, r) => {
+ const amtTag = (r.tags || []).find((t) => t[0] === 'amount' && t[1]);
+ const msats = amtTag ? Number(amtTag[1]) || 0 : 0;
+ return sum + Math.round(msats / 1000);
+ }, 0);
+ const vaultItem = {
+ a: addr || '',
+ eventId: ev.id,
+ creatorPubkey: ev.pubkey,
+ title: decryptedPayload?.meta?.title || '',
+ mode: decryptedPayload?.templateMode || '',
+ unlockedAt: Date.now(),
+ amountSats: totalSats,
+ previewJpegDataUrl: envelope?.preview?.dataUrl || '',
+ payload: decryptedPayload,
+ payment: {
+ mode: 'recovered-from-receipt',
+ receipts: matchedReceipts.map((r) => ({ id: r.id, kind: r.kind })),
+ },
+ };
+ upsertPurchaseVaultItem(vaultItem).catch((err) => {
+ console.warn('[vault] back-fill from receipt failed for', ev.id, err);
+ });
}
}
} catch (err) {
@@ -5898,7 +5971,6 @@
return;
}
}
- if (statusToast?.dismiss) statusToast.dismiss();
markUnlocked(eventId, {
creatorSats: result.creatorSats,
platformSats: result.platformSats,
@@ -5908,9 +5980,17 @@
payload: decryptedPayload || undefined,
previewJpegDataUrl: row.premium?.previewJpegDataUrl || undefined,
});
- // Persist to the Nostr-synced vault in the background so the unlock
- // survives localStorage.clear() and shows up on other devices.
+ // Persist to the Nostr-synced vault BEFORE telling the user
+ // the unlock is complete. The local cache flip above keeps the
+ // UI snappy, but a fire-and-forget publish was masking real
+ // durability failures: a vault that never persisted left the
+ // user with a localhost-only unlock that wouldn't show up on
+ // any other device or domain.
+ let vaultSynced = !row.premium || !decryptedPayload;
+ let vaultErr = null;
if (row.premium && decryptedPayload) {
+ if (statusToast?.dismiss) statusToast.dismiss();
+ statusToast = toast('Unlocked. Syncing to your purchase vault…', { duration: 30000 });
const vaultItem = _buildVaultItemForUnlock({
row,
totalSats: min,
@@ -5920,9 +6000,23 @@
platformPreimage: result.platformPreimage,
payload: decryptedPayload,
});
- upsertPurchaseVaultItem(vaultItem).catch((err) => console.warn('[vault] upsert failed', err));
+ try {
+ await upsertPurchaseVaultItem(vaultItem);
+ vaultSynced = true;
+ } catch (err) {
+ vaultErr = err;
+ console.warn('[vault] upsert failed', err);
+ }
+ }
+ if (statusToast?.dismiss) statusToast.dismiss();
+ if (vaultSynced) {
+ toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
+ } else {
+ toastError(
+ `Unlocked "${row.title || 'template'}" on this device, but vault sync failed (${vaultErr?.message || 'no relay accepted the event'}). Your purchase is saved locally; other devices won’t see it until sync recovers.`,
+ { duration: 12000 }
+ );
}
- toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
// Re-render the feed in place — the card will now show Import.
if (typeof _currentFeedRerender === 'function') {
try { _currentFeedRerender(); } catch {}
@@ -5977,6 +6071,26 @@
function _supportsVault() { return !!nostrEncryptCapability(); }
+ // Vault and zap-receipt traffic uses a wider relay set than ordinary
+ // feed queries: the user's configured relays unioned with the
+ // app-default list (DEFAULT_NOSTR_RELAYS). This keeps purchases
+ // visible across browsers / devices / domains even when the user
+ // edits or narrows their relay list. NIP-65 write relays could be
+ // folded in here later without changing any call sites.
+ function _vaultRelayUnion() {
+ const seen = new Set();
+ const out = [];
+ const add = (r) => {
+ const norm = normalizeRelayHealthRelay(r);
+ if (!norm || seen.has(norm)) return;
+ seen.add(norm);
+ out.push(norm);
+ };
+ for (const r of splitRelayList(state.community?.relays || '')) add(r);
+ for (const r of DEFAULT_NOSTR_RELAYS) add(r);
+ return out;
+ }
+
async function _selfEncrypt(plaintext) {
const method = nostrEncryptCapability();
if (!method) throw new Error('Your Nostr extension does not support encryption (NIP-44 or NIP-04).');
@@ -6035,7 +6149,7 @@
}
_vaultLoadPromise = (async () => {
try {
- const relays = splitRelayList(state.community?.relays || '');
+ const relays = _vaultRelayUnion();
const events = await queryRelays(relays, {
kinds: [30078], authors: [myPubkey], '#d': [VAULT_D_TAG], limit: 5,
}, { timeoutMs: 4500 });
@@ -6132,7 +6246,11 @@
],
content,
};
- return { signed: await publishCommunityEvent(evt), encryptedSize: content.length };
+ const signed = await signNostrEvent(evt);
+ // Publish to the union (user relays + defaults) so the vault is
+ // reachable from any device / domain that shares the login.
+ await publishToRelays(signed, _vaultRelayUnion());
+ return { signed, encryptedSize: content.length };
}
// Publish the full vault. Tries single-event first; if encrypted size
@@ -6182,8 +6300,10 @@
const current = await loadPurchaseVault({ force: true });
const mergedItems = _vaultMerge(current.items || [], [item]);
const next = { v: 1, updatedAt: Date.now(), items: mergedItems };
- try { return await _publishVault(next); }
- catch (err) { console.warn('[vault] publish failed', err); return null; }
+ // Propagate publish failures to the caller so the UI can show a
+ // real error instead of silently leaving the user's local state
+ // drifted from the relay-synced source of truth.
+ return await _publishVault(next);
}
function _buildVaultItemForUnlock({ row, totalSats, creatorSats, platformSats, creatorPreimage, platformPreimage, payload }) {