The original ZAP_GATED_TEMPLATES_FEATURE.md said "no platform fee, no
backend" — that's exactly the opposite of what we actually want to
ship. ZAPS_NWC_FEATURE.md was scoped as a general tip upgrade,
listing splits as a "future enhancement." For our purposes the two
are a single arc, with NWC being the only thing that gives the
mandatory creator/platform split a tolerable UX.
ZAP_GATED_PREMIUM_FEATURE.md is the new canonical plan:
- 5 phases (0 crypto bundle, 1 NWC, 2 platform-fee plumbing,
3 zap-gated UI, 4 E2E + edge cases). ~16-23h total.
- 30% platform fee, single-constant for easy adjustment later.
- PLATFORM_NPUB hard-coded; lud16 resolved dynamically from the
npub's kind-0 each session (so the lightning address can change
later without a code update).
- CoinOS recommended via a guided "Set up with CoinOS" path in
the wallet-connection modal; generic "paste any NIP-47 URI"
fallback for Alby Hub, Mutiny, Phoenix, Cashu.me, etc.
- Idempotent retry of partial unlocks via localStorage receipt
tracking — creator-leg pays first so a half-failed unlock at
least pays the creator.
- Explicit threat model + non-goals (no creator-only fallback,
no refunds, no bolt11-QR fallback path).
The two old specs are marked superseded with a banner at the top so
historical context is preserved without future contributors getting
crossed wires. TODO.md drops them from the drafted-but-unbuilt list
and points to the merged doc; it also fixes the stale Private
Publishing entry — Phases 1 & 2 already shipped in 3e6e89e, only
Phases 3 & 4 remain.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
9.1 KiB
Zap-Gated Premium Templates Feature Plan
⚠️ SUPERSEDED. This draft is kept for historical context only. The current plan lives in
ZAP_GATED_PREMIUM_FEATURE.md, which merges this spec withZAPS_NWC_FEATURE.mdand adds the mandatory creator/platform fee split that's the actual reason for shipping the feature. Read the merged doc; everything below is the earlier draft before that scope was set.
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