fixes and improvements

This commit is contained in:
thePR0M3TH3AN committed 2026-05-15 21:13:49 -04:00
1 parent 5c56a2d599
commit d39b672bf5
10 files changed
+2412 -102

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
# 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