Files
Artstr-Studio/docs/ZAP_GATED_TEMPLATES_FEATURE.md
T
2026-05-15 21:13:49 -04:00

255 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
1. Creator publishes a template tagged as premium with a minimum zap amount
2. In the community feed, premium templates show a ⚡ lock icon instead of Import
3. User clicks the lock → zap modal opens pre-filled with the minimum amount
4. After payment, app listens for a kind 9735 zap receipt on relays
5. Receipt is verified (correct amount, correct template event, correct sender)
6. 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:
```json
{
"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
```javascript
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:
```javascript
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:
```javascript
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
```javascript
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):
```javascript
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-expires` tag 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 with `zap-gate: true` tag
- [ ] `getZapMin()` returns correct sat amount from `zap-min` tag
- [ ] 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