8.7 KiB
Zap-Gated Premium Templates Feature Plan
Overview
Creators can mark a template as "premium" and set a minimum zap amount. Other users must send a Lightning zap (via NWC) before the import button unlocks. This creates a voluntary monetization layer for high-effort designs, with no payment processor, no platform fee, and no backend — just Nostr zap receipts (kind 9735) checked on the client.
Complexity
Moderate — Estimated 4–6 hours Depends on ZAPS_NWC_FEATURE.md being implemented first. The zap payment flow reuses that infrastructure; this feature adds the gating logic on top.
Prerequisite
ZAPS_NWC_FEATURE.md must be implemented first. Zap-gated templates require the full NWC + NIP-57 zap flow to already work.
How It Works
- Creator publishes a template tagged as premium with a minimum zap amount
- In the community feed, premium templates show a ⚡ lock icon instead of Import
- User clicks the lock → zap modal opens pre-filled with the minimum amount
- After payment, app listens for a kind 9735 zap receipt on relays
- Receipt is verified (correct amount, correct template event, correct sender)
- Import button unlocks for this session; receipt cached in
localStorage
Nostr Event Tags for Premium Templates
Creator adds two extra tags when publishing a kind 30078 template event:
{
"tags": [
["zap-gate", "true"],
["zap-min", "100"]
]
}
| Tag | Value | Meaning |
|---|---|---|
zap-gate |
"true" |
This template requires a zap to unlock |
zap-min |
"100" |
Minimum amount in sats |
These are custom tags — no NIP defines them, but Nostr's open tagging allows this. Any client that doesn't understand them will simply ignore them.
Key Components
1. Premium Tag Detection
function isZapGated(event) {
return event.tags?.some(t => t[0] === 'zap-gate' && t[1] === 'true');
}
function getZapMin(event) {
const tag = event.tags?.find(t => t[0] === 'zap-min');
return tag ? parseInt(tag[1], 10) : 21;
}
2. Local Unlock Cache
After a successful zap, cache the unlock so the user doesn't need to re-pay on every page load:
const UNLOCK_CACHE_KEY = 'casewrap-unlocked-templates';
function markUnlocked(eventId) {
const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}');
cache[eventId] = { unlockedAt: Date.now() };
localStorage.setItem(UNLOCK_CACHE_KEY, JSON.stringify(cache));
}
function isUnlockedLocally(eventId) {
const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}');
return !!cache[eventId];
}
3. Zap Receipt Verification (kind 9735)
After payment, subscribe to kind 9735 events on relays and verify the receipt:
async function waitForZapReceipt(templateEventId, senderPubkey, minAmountSats, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Zap receipt timeout')), timeoutMs);
const sub = subscribeRelays(relays, {
kinds: [9735],
'#e': [templateEventId],
}, (receiptEvent) => {
const bolt11Tag = receiptEvent.tags.find(t => t[0] === 'bolt11');
const pTag = receiptEvent.tags.find(t => t[0] === 'P'); // sender pubkey
// Verify sender matches (P tag in 9735 is the zapper's pubkey)
if (pTag?.[1] !== senderPubkey) return;
// Decode amount from bolt11 (or from 'amount' tag if present)
const amountSats = decodeBolt11Amount(bolt11Tag?.[1]) / 1000;
if (amountSats < minAmountSats) return;
clearTimeout(timer);
sub.close();
resolve(receiptEvent);
});
});
}
Note: decodeBolt11Amount needs to parse the bolt11 invoice to extract the amount.
A minimal bolt11 parser (~2 KB) is available from the light-bolt11-decoder package
or can be inlined for just the amount field.
4. Full Unlock Flow
async function unlockPremiumTemplate(event) {
const minSats = getZapMin(event);
// Show zap modal pre-filled with min amount, locked to minimum
const zapResult = await showZapModal({
recipientPubkey: event.pubkey,
eventId: event.id,
minAmount: minSats,
message: `Unlocking: ${templateTitle(event)}`,
});
if (!zapResult) return; // user cancelled
showStatus('Waiting for zap receipt…');
try {
await waitForZapReceipt(event.id, state.community.myPubkey, minSats);
markUnlocked(event.id);
showStatus('Template unlocked! ✅');
renderCommunityDetail(event); // re-render to show Import button
} catch (err) {
showStatus('Receipt not received — try again or contact the creator.');
}
}
5. Creator UI — Marking a Template as Premium
Add a "Premium template" toggle to the Publish panel:
┌──────────────────────────────────────┐
│ Publish Template │
│ │
│ ☐ Premium template (zap to unlock) │
│ Minimum: [100] sats │
│ │
│ [Publish] │
└──────────────────────────────────────┘
When checked, the zap-gate and zap-min tags are added to the published event.
UI Changes
Feed Card — Premium Lock Icon
Replace "Import" button with a locked state for gated templates:
[⚡ Unlock (100 sats)] ← not yet paid
[Import] ← after unlock
Show a small ⭐ or ⚡ badge on the card thumbnail to signal premium status.
Template Detail Panel
- Show "Premium template — zap to unlock" banner for gated templates
- Show minimum amount prominently: "⚡ 100 sats to unlock"
- After unlock: green "Unlocked ✅" badge replaces the banner
Own Templates
Creator sees their own premium templates as unlocked by default (no self-payment needed):
function isUnlocked(event) {
return !isZapGated(event)
|| isUnlockedLocally(event.id)
|| event.pubkey === state.community.myPubkey;
}
Edge Cases
| Scenario | Handling |
|---|---|
| Creator's wallet doesn't publish kind 9735 receipt | Timeout after 30s; show "receipt not detected" message; user can retry |
| User zaps less than minimum | Amount check in receipt verification rejects it |
| User clears localStorage | Must re-pay; no cross-device unlock sync |
| Creator updates (replaces) the template event | Old unlock cache entry becomes stale; detect via changed event ID |
Performance Considerations
isUnlockedLocally()is a synchronous localStorage read — runs on every card render- Kind 9735 subscription is opened only after payment — no background polling
- Receipt subscription times out and closes after 30s regardless of outcome
Limitations
- Unlock is per-device / per-browser (localStorage). No cross-device sync in Phase 1.
- Receipt verification is client-side trust: a determined user could fake a cache entry in localStorage. This is appropriate for a community platform — not for high-value goods.
- Creators must have a functional Lightning address (lud16) for zaps to work
- If the creator's wallet doesn't publish kind 9735 receipts, the unlock flow times out (some wallets are inconsistent about this)
Future Enhancements
- Cross-device unlock sync: store verified receipt event ID in user's NIP-51 kind 10001 (pin list) so it syncs across clients
- Time-limited access:
zap-expirestag unlocks template for N days only - Subscription model: zap a creator's profile once to unlock all their premium templates
- Revenue stats for creators: count kind 9735 receipts referencing their templates
- Escrow / refund: out of scope (no smart contracts on Lightning without additional infrastructure)
Testing Checklist
isZapGated()correctly identifies events withzap-gate: truetaggetZapMin()returns correct sat amount fromzap-mintag- Premium feed cards show "⚡ Unlock" button instead of "Import"
- Zap modal opens pre-filled with minimum amount
- Amount field rejects values below the minimum
- After successful zap, app subscribes to kind 9735 on relays
- Valid receipt with correct sender and amount triggers unlock
- Receipt with wrong sender is ignored
- Receipt with insufficient amount is ignored
markUnlocked()persists to localStorage and survives page reload- Unlocked template shows Import button on reload (from cache)
- Creator's own premium templates are pre-unlocked (no self-payment)
- 30-second timeout shows helpful error message
- Publish panel "Premium template" toggle adds correct tags to event
- Non-premium templates are unaffected by this feature