mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-04 20:50:39 +00:00
Cleanup: drop superseded specs + refresh README
Removed three superseded spec files now that the work they described is either shipped (under newer specs) or formally replaced: docs/ZAP_GATED_TEMPLATES_FEATURE.md docs/ZAPS_NWC_FEATURE.md docs/ZAP_GATED_PREMIUM_FEATURE.md The history is preserved in git. The current canonical premium plan is docs/PREMIUM_DESIGNS_FEATURE.md (which absorbed the zap-gated+NWC infrastructure plus added the soft-gate KDF + encrypted envelopes + purchase vault). README's docs paragraph now lists every active spec by status — shipped / partial / drafted — and adds the transparency-iframe baseline stub that landed alongside the embed feature.
This commit is contained in:
1 parent
c1ac849219
commit
007ad1d1e2
4 files changed
+5
-1104
No files matched your search
@@ -165,7 +165,11 @@ Spec lives in `docs/PREMIUM_DESIGNS_FEATURE.md`.
|
||||
|
||||
- `src/index.html` — the complete single-file app. Open it directly in a browser, or serve it from any static host. Vercel rewrites in `vercel.json` map `/share/:id` and `/u/:npub` to the SPA entry.
|
||||
- `src/vendor/qrcode.min.js` — vendored QR encoder (used for Lightning tip invoices). The GIF89a encoder for pixel-art animation export is implemented inline in `index.html`.
|
||||
- `docs/` — feature specs. `PEN_TOOL_FEATURE.md` covers the shipped pen / pencil / vector tooling; `SLIDE_DECK_FEATURE.md` covers the shipped slide / deck / presenter system (Phases A–D; dual-window presenter is future); `PIXEL_ART_FEATURE.md` covers the shipped palette-indexed pixel-art system (Phases A–D); `PREMIUM_DESIGNS_FEATURE.md` covers the shipped premium-encrypted-designs system (NWC split-zap + soft-gate + purchase vault); the older `ZAP_GATED_PREMIUM_FEATURE.md` / `ZAP_GATED_TEMPLATES_FEATURE.md` / `ZAPS_NWC_FEATURE.md` carry "superseded" banners and remain for history. Badges, collections, private publishing (Phases 3–4), and Stacks are still unbuilt.
|
||||
- `src/transparency-iframe.html` — minimal transparent stub used by the embed transparency baseline tests.
|
||||
- `docs/` — feature specs.
|
||||
- **Shipped:** `PEN_TOOL_FEATURE.md` (pen / pencil / vector tooling), `SLIDE_DECK_FEATURE.md` (slide / deck / presenter, Phases A–D; dual-window presenter still future), `PIXEL_ART_FEATURE.md` (palette-indexed pixel art, Phases A–D), `PREMIUM_DESIGNS_FEATURE.md` (NWC split-zap + soft-gate + purchase vault), `LINKED_DESIGNS_FEATURE.md` (cross-reference imports + auto-update + attribution chain), `PPTX_IMPORT_FEATURE.md` (Phases 0–7 of PowerPoint deck import), `CHART_IMPORT_FEATURE.md` (Phases 1–4 of native OOXML chart rendering), `EXTERNAL_EMBED_FEATURE.md` (iframe-able read-only viewer with code generator).
|
||||
- **Partial:** `PRIVATE_PUBLISHING_FEATURE.md` (Phases 1+2 shipped; share-with-pubkeys + gift-wrap metadata privacy deferred).
|
||||
- **Drafted-but-unbuilt:** `STACKS_FEATURE.md`, `BADGES_FEATURE.md`, `COLLECTIONS_FEATURE.md`.
|
||||
- `TODO.md` — running list of deferred work and cleanup items.
|
||||
|
||||
## Schema
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
# Zaps via Nostr Wallet Connect (NWC) Feature Plan
|
||||
|
||||
> **⚠️ SUPERSEDED.** This draft is kept for historical context only.
|
||||
> NWC is no longer being built as a standalone feature — it's the
|
||||
> wallet substrate for `ZAP_GATED_PREMIUM_FEATURE.md` (which is the
|
||||
> canonical merged plan). General-purpose NWC zaps for the existing
|
||||
> tip flow remain a possible follow-up after that ships; see the
|
||||
> "Open questions" section of the merged doc.
|
||||
|
||||
## Overview
|
||||
Allow users to zap template creators (and specific templates) directly from the community
|
||||
feed using Nostr Wallet Connect (NIP-47). The user connects their Lightning wallet once via
|
||||
a connection string, then taps ⚡ on any template card to send sats to the creator. No
|
||||
backend required — NWC runs entirely over WebSockets through the user's own wallet relay.
|
||||
|
||||
## Complexity
|
||||
**Moderate–High** — Estimated 6–10 hours
|
||||
- NWC connection + pay_invoice: ~3 hours
|
||||
- NIP-57 zap request + LNURL flow: ~3 hours
|
||||
- UI (zap button, amount modal, settings): ~2 hours
|
||||
- Crypto dependency setup: ~1 hour
|
||||
|
||||
---
|
||||
|
||||
## Protocol Overview
|
||||
|
||||
Two NIPs work together here:
|
||||
|
||||
| NIP | Purpose |
|
||||
|-----|---------|
|
||||
| NIP-47 (NWC) | App requests a payment from the user's wallet over an encrypted relay channel |
|
||||
| NIP-57 | Defines what a "zap" is — a Lightning payment linked to a Nostr event or profile |
|
||||
|
||||
**The payment chain:**
|
||||
```
|
||||
User clicks ⚡
|
||||
→ app fetches creator's Lightning address from their cached kind 0 profile (lud16)
|
||||
→ app queries the creator's LNURL-pay endpoint for a callback URL
|
||||
→ app builds a NIP-57 zap request (kind 9734) and signs it via NIP-07
|
||||
→ app POSTs to the LNURL callback → gets back a bolt11 invoice
|
||||
→ app sends pay_invoice to user's wallet via NWC (NIP-47)
|
||||
→ wallet pays, publishes kind 9735 zap receipt to relays
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. NWC Wallet Connection (NIP-47)
|
||||
|
||||
#### Connection String Format
|
||||
Wallets (Alby, Mutiny, Zeus, Cashu) generate a URI the user pastes in once:
|
||||
```
|
||||
nostr+walletconnect://<walletPubkey>?relay=wss%3A%2F%2Frelay.example.com&secret=<appSecret>
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
|------|---------|
|
||||
| `walletPubkey` | Hex pubkey of the wallet service |
|
||||
| `relay` | WebSocket URL of the relay the wallet listens on |
|
||||
| `secret` | App-side private key (hex) — used to sign requests and decrypt responses |
|
||||
|
||||
#### Storage
|
||||
- Store connection string in `localStorage` key `casewrap-nwc-uri`
|
||||
- **Never log or expose the secret** — treat it like a password
|
||||
- Derive the app pubkey from the secret at runtime for signing
|
||||
|
||||
#### Core parse function
|
||||
```javascript
|
||||
function parseNWCUri(uri) {
|
||||
// Returns { walletPubkey, relay, secret } or null if invalid
|
||||
const url = new URL(uri.replace('nostr+walletconnect://', 'https://'));
|
||||
return {
|
||||
walletPubkey: url.hostname,
|
||||
relay: url.searchParams.get('relay'),
|
||||
secret: url.searchParams.get('secret'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. NWC pay_invoice Request (NIP-47)
|
||||
|
||||
NWC uses encrypted Nostr events to pass commands to the wallet. The app encrypts a JSON
|
||||
command as kind 23194, publishes it to the wallet relay, and waits for a kind 23195
|
||||
response from the wallet.
|
||||
|
||||
#### Encryption
|
||||
NWC uses **NIP-04 encryption** (ECDH shared secret → AES-128-CBC).
|
||||
- Shared secret = ECDH(appSecret, walletPubkey).x
|
||||
- This is the same cipher used elsewhere in Nostr but keyed with the NWC secret, NOT the
|
||||
user's NIP-07 key — so `window.nostr.nip04` cannot be used here.
|
||||
- Requires a small crypto library (see Crypto Dependencies section).
|
||||
|
||||
#### Request event (kind 23194)
|
||||
```json
|
||||
{
|
||||
"kind": 23194,
|
||||
"pubkey": "<appPubkey>",
|
||||
"tags": [["p", "<walletPubkey>"]],
|
||||
"content": "<nip04-encrypted JSON>",
|
||||
"created_at": <unix>,
|
||||
"id": "<sha256 of canonical form>",
|
||||
"sig": "<schnorr sig with appSecret>"
|
||||
}
|
||||
```
|
||||
|
||||
Decrypted content:
|
||||
```json
|
||||
{
|
||||
"method": "pay_invoice",
|
||||
"params": { "invoice": "lnbc..." }
|
||||
}
|
||||
```
|
||||
|
||||
#### Response event (kind 23195)
|
||||
Wallet sends this back, encrypted to appPubkey:
|
||||
```json
|
||||
{
|
||||
"result_type": "pay_invoice",
|
||||
"result": { "preimage": "<hex>" }
|
||||
}
|
||||
// OR on failure:
|
||||
{
|
||||
"result_type": "pay_invoice",
|
||||
"error": { "code": "INSUFFICIENT_BALANCE", "message": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
#### Core send function
|
||||
```javascript
|
||||
async function sendNWCPayment(bolt11) {
|
||||
// 1. Parse stored NWC URI
|
||||
// 2. Encrypt pay_invoice request with NIP-04 using appSecret
|
||||
// 3. Build, sign, and publish kind 23194 to wallet relay
|
||||
// 4. Open subscription for kind 23195 from walletPubkey, tagged with request event ID
|
||||
// 5. Wait up to 30s for response, decrypt, return result or throw error
|
||||
}
|
||||
```
|
||||
|
||||
Note: The app signs kind 23194 events with the NWC `secret` key — NOT the user's NIP-07 key.
|
||||
All NWC signing is done with the noble-secp256k1 library directly.
|
||||
|
||||
---
|
||||
|
||||
### 3. NIP-57 Zap Request + LNURL Flow
|
||||
|
||||
Before paying, the app must obtain a bolt11 invoice from the creator's Lightning address.
|
||||
This follows the LNURL-pay + NIP-57 protocol:
|
||||
|
||||
#### Step A — Resolve the creator's Lightning address (lud16)
|
||||
The creator's `lud16` field in their kind 0 profile (e.g. `[email protected]`) maps to a URL:
|
||||
```javascript
|
||||
function lud16ToUrl(lud16) {
|
||||
const [name, domain] = lud16.split('@');
|
||||
return `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
}
|
||||
```
|
||||
If the creator only has `lud06` (bech32 LNURL), decode it:
|
||||
```javascript
|
||||
function decodeLNURL(lud06) {
|
||||
// bech32-decode lud06 → UTF-8 URL string
|
||||
}
|
||||
```
|
||||
|
||||
#### Step B — Fetch LNURL-pay metadata
|
||||
GET the resolved URL. Check `allowsNostr: true` to confirm zap support:
|
||||
```json
|
||||
{
|
||||
"callback": "https://getalby.com/lnurlp/alice/callback",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 100000000000,
|
||||
"allowsNostr": true,
|
||||
"nostrPubkey": "<alby-service-pubkey>"
|
||||
}
|
||||
```
|
||||
|
||||
#### Step C — Build and sign the zap request (kind 9734)
|
||||
This event is attached to the payment so the recipient sees who zapped them and what for:
|
||||
```json
|
||||
{
|
||||
"kind": 9734,
|
||||
"pubkey": "<senderPubkey>",
|
||||
"tags": [
|
||||
["relays", "wss://relay.damus.io", "wss://nos.lol"],
|
||||
["amount", "21000"],
|
||||
["lnurl", "<lnurl>"],
|
||||
["p", "<recipientPubkey>"],
|
||||
["e", "<templateEventId>"]
|
||||
],
|
||||
"content": "Love this design! ⚡"
|
||||
}
|
||||
```
|
||||
Sign via `window.nostr.signEvent()` (NIP-07) — this DOES use the user's NIP-07 key.
|
||||
|
||||
#### Step D — Fetch the invoice
|
||||
```
|
||||
GET {callback}?amount=21000&nostr={encodeURIComponent(JSON.stringify(signedZapRequest))}&lnurl={lnurl}
|
||||
```
|
||||
Response: `{ "pr": "lnbc21n1p..." }`
|
||||
|
||||
#### Step E — Pay via NWC
|
||||
Pass the `pr` bolt11 string to `sendNWCPayment()`.
|
||||
|
||||
```javascript
|
||||
async function zapCreator(recipientPubkey, eventId, amountSats, message) {
|
||||
const profile = state.community.profiles[recipientPubkey];
|
||||
if (!profile?.lud16 && !profile?.lud06) throw new Error('Creator has no Lightning address');
|
||||
const lnurlInfo = await fetchLNURLPayInfo(profile.lud16 ?? profile.lud06);
|
||||
if (!lnurlInfo.allowsNostr) throw new Error('Creator wallet does not support zaps');
|
||||
const zapRequest = await buildAndSignZapRequest(recipientPubkey, eventId, amountSats * 1000, message);
|
||||
const invoice = await fetchZapInvoice(lnurlInfo, zapRequest, amountSats * 1000);
|
||||
return await sendNWCPayment(invoice);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Crypto Dependencies
|
||||
|
||||
NWC requires:
|
||||
- **Schnorr signing** (sign kind 23194 events with appSecret)
|
||||
- **NIP-04 encryption** (ECDH + AES-128-CBC)
|
||||
- **Event ID hashing** (SHA-256 of canonical serialization)
|
||||
|
||||
The app has no build tools, so load from CDN via ES module imports or `<script>` tags:
|
||||
|
||||
```html
|
||||
<!-- noble-secp256k1: Schnorr signing + ECDH -->
|
||||
<script type="module">
|
||||
import * as secp from 'https://esm.sh/@noble/secp256k1@2';
|
||||
import { sha256 } from 'https://esm.sh/@noble/hashes/sha256';
|
||||
window._secp = secp;
|
||||
window._sha256 = sha256;
|
||||
</script>
|
||||
```
|
||||
|
||||
These two packages together are ~20 KB minified and have no further dependencies.
|
||||
They are widely used across the Nostr ecosystem.
|
||||
|
||||
For bech32 decoding of `lud06` LNURL values, use:
|
||||
```html
|
||||
<script src="https://esm.sh/@scure/base@1"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. UI
|
||||
|
||||
#### Settings Panel — Wallet Connection
|
||||
Add a "Lightning Wallet" section to the existing settings/profile panel:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ⚡ Lightning Wallet │
|
||||
│ │
|
||||
│ [Paste NWC connection string… ] [Connect] │
|
||||
│ │
|
||||
│ Connected: Alby ([email protected]) │
|
||||
│ Balance: 12,450 sats [Disconnect] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- "Connect" parses and stores the NWC URI; optionally fires a `get_balance` request
|
||||
- Balance display is optional — costs one extra NWC round-trip; hide if user prefers
|
||||
- "Disconnect" clears localStorage key and hides zap buttons site-wide
|
||||
|
||||
#### Zap Button on Template Cards
|
||||
Add to the `reactionRow` in both `renderCommunity()` feed cards and `renderCommunityDetail()`:
|
||||
|
||||
```
|
||||
[👍 12] [💬 3] [🔁 5] [⚡ Zap]
|
||||
```
|
||||
|
||||
- Show ⚡ button only when the creator's profile has `lud16` or `lud06`
|
||||
- If NWC is not connected, clicking ⚡ opens the settings panel to prompt setup
|
||||
- Show "Zap creator" tooltip on hover
|
||||
|
||||
#### Zap Amount Modal
|
||||
Opens on ⚡ click:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ⚡ Zap [email protected] │
|
||||
│ │
|
||||
│ [ 21 ] [ 100 ] [ 500 ] │
|
||||
│ [1000 ] [ Custom… ] │
|
||||
│ │
|
||||
│ Message (optional): │
|
||||
│ [________________________] │
|
||||
│ │
|
||||
│ [Cancel] [Send Zap] │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- Preset amounts in sats (21 is the canonical "nice" zap amount in Nostr culture)
|
||||
- Custom amount field with min/max validation against LNURL `minSendable`/`maxSendable`
|
||||
- Message populates the `content` field of the kind 9734 zap request
|
||||
- "Send Zap" button shows spinner while in flight, then ✅ success or ❌ error message
|
||||
|
||||
---
|
||||
|
||||
## State Additions
|
||||
|
||||
```javascript
|
||||
// Add to state object:
|
||||
nwc: {
|
||||
uri: '', // raw connection string (from localStorage on load)
|
||||
connected: false, // true after successful parse + optional balance check
|
||||
balance: null, // sats, or null if not fetched / not supported
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
- LNURL fetch and NWC round-trip each take ~1–3 seconds — always show a loading state
|
||||
- NWC WebSocket opens on-demand per payment and closes immediately after — no persistent connection
|
||||
- Creator profile `lud16` is already in `state.community.profiles` — no extra relay query needed
|
||||
- `get_balance` is optional; skip it to save a round-trip on wallet connect
|
||||
- bech32/secp256k1 libs load once on page init; zap operations after that are <5ms local work
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
- Requires creator to have a Lightning address (`lud16` or `lud06`) in their Nostr profile
|
||||
- Requires creator's wallet/service to support NIP-57 (`allowsNostr: true`)
|
||||
- NWC secret gives partial spend authority — user should use a budget-limited NWC URI (Alby and Mutiny both support spending limits on NWC connections)
|
||||
- `get_balance` and multi-pay are optional NWC methods — not all wallets implement them
|
||||
- Zap receipts (kind 9735) are published by the recipient's wallet, not the sender — the app can listen for them but cannot guarantee delivery
|
||||
- No offline support — both LNURL fetch and NWC require live network
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
- Store the NWC URI in `localStorage` only — never send it to any server
|
||||
- Never log `uri` or `secret` to console
|
||||
- The NWC `secret` grants payment authority up to the wallet's configured spending limit — document this clearly in the UI so users understand what they're connecting
|
||||
- LNURL fetches go to third-party servers (the creator's wallet provider) — no PII is sent beyond the zap request event (which is a public Nostr event anyway)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
- Zap splits: send a percentage to both template creator and original asset uploader (NIP-57 supports multiple `p` tags)
|
||||
- Zap leaderboard on template detail: show top zappers (parse kind 9735 receipts)
|
||||
- Recurring/scheduled zaps for favorite creators
|
||||
- Show total sats zapped on each template card (sum kind 9735 receipts)
|
||||
- Cashu/eCash zaps for sub-sat amounts (separate NIP, future consideration)
|
||||
- WebLN fallback: if user has a WebLN browser extension instead of NWC, use `window.webln.sendPayment()` as a simpler alternative
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] NWC URI parses correctly (walletPubkey, relay, secret extracted)
|
||||
- [ ] Invalid / malformed NWC URI shows clear error in settings
|
||||
- [ ] `get_balance` request succeeds and displays balance (if wallet supports it)
|
||||
- [ ] Disconnect clears stored URI and hides zap buttons
|
||||
- [ ] ⚡ button only shows on cards where creator has lud16/lud06
|
||||
- [ ] ⚡ click on card without NWC connected opens wallet setup flow
|
||||
- [ ] LNURL fetch resolves correctly for standard lud16 addresses
|
||||
- [ ] LNURL fetch resolves correctly for bech32 lud06 addresses
|
||||
- [ ] `allowsNostr: false` shows friendly "creator wallet doesn't support zaps" message
|
||||
- [ ] Zap request (kind 9734) is signed via NIP-07 and includes correct tags
|
||||
- [ ] Invoice is fetched from LNURL callback with correct query params
|
||||
- [ ] NWC pay_invoice request is encrypted and published to wallet relay
|
||||
- [ ] Successful payment shows ✅ confirmation in modal
|
||||
- [ ] NWC error codes (INSUFFICIENT_BALANCE, etc.) show readable messages
|
||||
- [ ] Amount presets respect LNURL minSendable/maxSendable limits
|
||||
- [ ] Custom amount validates against min/max before sending
|
||||
- [ ] NWC connection secret is never logged to console
|
||||
- [ ] Page reload restores NWC connected state from localStorage
|
||||
@@ -1,470 +0,0 @@
|
||||
# Zap-Gated Premium Templates (with NWC + Platform Split) — Feature Plan
|
||||
|
||||
> **Status:** Phases 0–3 (noble crypto bundle, NWC client + wallet
|
||||
> settings, platform-fee plumbing, tag-only feed UI) shipped on the
|
||||
> `zap-gated` branch and are reused as-is. The tag-only gate they
|
||||
> produced turned out to be too leaky to ship as the final model —
|
||||
> the design payload itself is still publicly readable. The
|
||||
> canonical spec for the encryption layer + buyer purchase vault
|
||||
> that closes that gap is now **`PREMIUM_DESIGNS_FEATURE.md`**
|
||||
> (the original "static soft-gate + buyer vault" design). Read
|
||||
> that document for the architecture going forward; this file is
|
||||
> retained for the Phases 0–3 history and the NWC/tip integration
|
||||
> notes.
|
||||
>
|
||||
> This document also superseded the original
|
||||
> `ZAP_GATED_TEMPLATES_FEATURE.md` and `ZAPS_NWC_FEATURE.md`. They
|
||||
> remain in `docs/` with a "superseded" banner for historical
|
||||
> reference.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
### Product goal
|
||||
A creator can mark a template as "premium" with a minimum sat amount.
|
||||
Other users see a ⚡ lock on the feed card instead of Import. Tapping
|
||||
the lock pays the creator and the platform in a single user gesture,
|
||||
and unlocks the import button (cached locally so they don't pay again).
|
||||
|
||||
### Why the split is mandatory
|
||||
The platform fee is the entire revenue model for this feature. Every
|
||||
unlock MUST split: creator gets `(100% − fee)`, platform gets `fee%`.
|
||||
There is no creator-only fallback path.
|
||||
|
||||
### Non-goals (v1)
|
||||
- **Refunds.** If the platform invoice fails after the creator
|
||||
invoice settles, we surface the error and ask the user to retry
|
||||
the platform leg — no auto-refund.
|
||||
- **Tipping splits.** The existing LNURL tip flow still goes 100% to
|
||||
the creator. Splits are scoped to zap-gated unlocks only.
|
||||
- **Non-NWC payment paths.** v1 requires NWC to be configured. No
|
||||
bolt11 QR fallback (would re-introduce the two-invoice clunky UX
|
||||
this whole arc is meant to avoid).
|
||||
- **Re-distribution prevention.** A paid buyer can re-share the
|
||||
decryption key. The encryption protects against drive-by reading
|
||||
/ scraping, not against determined piracy. No client-side scheme
|
||||
closes this gap without a server.
|
||||
|
||||
### Content model (CORRECTED — what came after this doc)
|
||||
Phases 0–3 originally shipped a **tag-only** gate: the design content
|
||||
was published in plaintext in the kind-30078 event; Artstr's UI just
|
||||
refused to import it. Two problems surfaced in real-world testing:
|
||||
1. **Other clients can ignore the tag** and import freely.
|
||||
2. **Even inside Artstr** there are multiple import paths (feed
|
||||
card, preview pane Use/Fork, preview pane Save JSON) — gating
|
||||
only the feed-card button leaves the others as full bypasses.
|
||||
Patching every UI surface still doesn't address (1).
|
||||
|
||||
The canonical fix is the **static soft-gate + buyer purchase vault**
|
||||
model documented in **`PREMIUM_DESIGNS_FEATURE.md`**. That spec
|
||||
encrypts the design payload at publish time with a key derivable
|
||||
from public-plus-obfuscated material in the static client, gated
|
||||
behind valid split-zap receipts. Decryption keys are persisted in
|
||||
a buyer-owned NIP-44 encrypted kind-30078 vault for cross-device
|
||||
re-open. The spec also covers the watermarked preview and the
|
||||
honest "soft gate, not strong DRM" copy.
|
||||
|
||||
---
|
||||
|
||||
## Phased delivery
|
||||
|
||||
The phases build on each other. Each has an explicit ship gate so
|
||||
work can be paused between phases for review.
|
||||
|
||||
### Phase 0 — Crypto bundle *(~3–5h)*
|
||||
|
||||
The existing codebase signs/encrypts only via `window.nostr.*` (the
|
||||
user's NIP-07 extension). NWC requires signing kind-23194 events with
|
||||
the **NWC secret** (a different key, embedded in the wallet URI),
|
||||
which the extension can't do. So we need a bundled cryptography
|
||||
implementation.
|
||||
|
||||
**Deliverables**
|
||||
- Inline a minimal `@noble/secp256k1` + `@noble/hashes` subset
|
||||
(schnorr sign/verify, ECDH for NIP-04 shared-secret).
|
||||
- Inline a NIP-04 helper using `crypto.subtle` AES-CBC + the noble
|
||||
ECDH output.
|
||||
- Verify against the canonical NIP-04 test vectors in code comments.
|
||||
|
||||
**Ship gate**
|
||||
Round-trip encrypt/decrypt a known plaintext using the test-vector
|
||||
keys → produces the test-vector ciphertext.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — NWC connection *(~5–7h)*
|
||||
|
||||
**Protocol summary (NIP-47)**
|
||||
The wallet exposes a connection URI:
|
||||
```
|
||||
nostr+walletconnect://<wallet-service-pubkey>?relay=<wss-url>&secret=<hex>[&lud16=<addr>]
|
||||
```
|
||||
- `wallet-service-pubkey` — the wallet's NIP-47 service pubkey (hex).
|
||||
- `relay` — a dedicated relay both ends listen on. May appear MORE than
|
||||
once; we publish/subscribe on all of them for redundancy.
|
||||
- `secret` — a 32-byte hex privkey that the client uses to sign
|
||||
requests (this is NOT the user's identity key). May be URL-encoded.
|
||||
- `lud16` — optional Lightning address some wallets (incl. CoinOS)
|
||||
include for convenience; we cache it but don't depend on it.
|
||||
|
||||
**Capability discovery (kind-13194)**
|
||||
The wallet publishes a kind-13194 "info" event listing supported
|
||||
methods (e.g. `pay_invoice get_balance get_info lookup_invoice`).
|
||||
On connect we subscribe to that event and verify `pay_invoice` is in
|
||||
the list — otherwise we surface "This wallet doesn't support
|
||||
payments via NWC" before the user is allowed to proceed.
|
||||
|
||||
**Request lifecycle**
|
||||
1. Build a kind-23194 event:
|
||||
`content` = NIP-04(`{"method":"pay_invoice","params":{"invoice":"..."}}`)
|
||||
encrypted to the wallet service pubkey, with the NWC secret as
|
||||
sender; tag `["p", walletServicePubkey]`.
|
||||
2. Sign with the NWC secret (schnorr).
|
||||
3. Publish to the NWC relay.
|
||||
4. Subscribe `{ kinds: [23195], '#p': [<client pubkey>], '#e': [<request id>] }`.
|
||||
5. Decrypt the response with the NWC secret; parse JSON. Possible
|
||||
results: `{ "result_type": "pay_invoice", "result": { "preimage": "..." } }`
|
||||
or `{ "error": { "code": "...", "message": "..." } }`.
|
||||
|
||||
**Deliverables**
|
||||
- `parseNwcUri(uri)` → `{ servicePubkey, relays[], secret, lud16? }` or
|
||||
null. Handles multiple `relay` params and URL-encoded secrets.
|
||||
- `nwcConnect(uri)` — store creds, open WS to each relay, fetch the
|
||||
kind-13194 capability list, send a `get_info` request to retrieve
|
||||
the wallet's alias + pubkey.
|
||||
- `nwcPayInvoice(bolt11, { timeoutMs })` → resolves to
|
||||
`{ preimage }` or rejects with `{ code, message }`. Common codes
|
||||
worth special-casing: `INSUFFICIENT_BALANCE`,
|
||||
`QUOTA_EXCEEDED` / `BUDGET_EXCEEDED` (wallet's per-connection cap
|
||||
hit), `PAYMENT_FAILED`, `NOT_FOUND` (invoice mistyped), `INTERNAL`.
|
||||
- `nwcLookupInvoice(paymentHash)` → returns settlement state. Used
|
||||
during retry of a half-failed unlock so we never double-pay.
|
||||
- Credentials live in `localStorage` under
|
||||
`casewrap-nwc-connection` (single-record; new paste replaces old).
|
||||
Trade-off acknowledged in the threat-model section.
|
||||
|
||||
**UX (the Settings panel)**
|
||||
A new "Wallet" section in the Community settings, two-state:
|
||||
|
||||
*Not connected:*
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Lightning wallet — not connected │
|
||||
│ │
|
||||
│ To unlock premium templates, connect a │
|
||||
│ NIP-47 wallet. Recommended: CoinOS │
|
||||
│ (free, web-based, no install). │
|
||||
│ │
|
||||
│ [Set up with CoinOS →] │
|
||||
│ [ Paste connection string ▾ ] │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CoinOS button opens a short modal:
|
||||
> **1.** Sign up at [coinos.io](https://coinos.io) (no install).
|
||||
> **2.** Open the [Wallet Connect](https://coinos.io/settings/connect)
|
||||
> page directly, or browse to *Settings → Connect → Nostr Wallet
|
||||
> Connect*.
|
||||
> **3.** Set a budget that comfortably covers the unlocks you expect
|
||||
> — at least a few thousand sats. *(Hint inline.)*
|
||||
> **4.** Copy the connection string (`nostr+walletconnect://…`).
|
||||
> **5.** Paste below.
|
||||
> *[input]* *[Connect]*
|
||||
|
||||
The "Paste connection string" disclosure expands to the same input
|
||||
with no per-wallet instructions — works for Alby Hub, Mutiny,
|
||||
Phoenix, Cashu.me, Boltz, etc.
|
||||
|
||||
**Mobile note:** on a phone, the user copies the NWC URI from their
|
||||
wallet app (Alby Go, Mutiny, Cashu.me's "Connect" share sheet, etc.)
|
||||
and pastes into this same field. We don't try to detect / deep-link.
|
||||
|
||||
*Connected:*
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ ⚡ Connected to <wallet alias from │
|
||||
│ get_info, fall back: "Connected wallet">│
|
||||
│ Last test: <time> [Test] [Disconnect] │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If the wallet's kind-13194 info event does NOT list `pay_invoice`,
|
||||
we show a non-dismissable warning under the alias: *"This wallet
|
||||
doesn't support payments via NWC — unlocks will fail."*
|
||||
|
||||
**Ship gate**
|
||||
With CoinOS-issued URI: `get_info` returns the wallet's alias.
|
||||
`pay_invoice` against a 10-sat bolt11 from a different lud16 succeeds
|
||||
and returns a preimage.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Platform-fee plumbing *(~2–3h)*
|
||||
|
||||
**Constants** (single place to tune later)
|
||||
```js
|
||||
const PLATFORM_NPUB = 'npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx';
|
||||
const PLATFORM_FEE_BPS = 3000; // 30%. Adjust to lower the cut.
|
||||
```
|
||||
|
||||
**Deliverables**
|
||||
- `PLATFORM_PUBKEY_HEX` derived once via the existing bech32 helper.
|
||||
- On first need (lazy, then cached for the session): query relays for
|
||||
the platform npub's kind-0 → extract `lud16` → cache.
|
||||
- `splitZapAmount(totalSats, bps)` →
|
||||
`{ creatorSats, platformSats }`. Round so the sum equals total;
|
||||
on rounding ambiguity, platform takes the floor and creator the
|
||||
remainder (creator gets the extra millisat-equivalent — slightly
|
||||
creator-favouring, which feels right).
|
||||
- `payZapSplit({ creatorPubkey, creatorMetadata, eventId, totalSats })`
|
||||
→ orchestrates the two zaps, parallelizing every step we safely can:
|
||||
1. **Resolve in parallel**: creator's LNURL endpoint + platform's
|
||||
LNURL endpoint (from the cached platform `lud16`). Also at this
|
||||
step: validate each LNURL's `minSendable` ≤ that side's msat
|
||||
share; bail with a clear error if either fails.
|
||||
2. **Sign in parallel**: build both kind-9734 zap requests,
|
||||
`Promise.all(window.nostr.signEvent(...))` for both. The user's
|
||||
NIP-07 extension may prompt twice back-to-back, but they're
|
||||
queued in the same gesture rather than minutes apart.
|
||||
3. **Fetch in parallel**: `Promise.all(fetchLnurlInvoice(...))` for
|
||||
both bolt11s.
|
||||
4. **Pay creator first**, then platform (sequential — see "Why
|
||||
creator first" below).
|
||||
5. Mark unlocked as soon as the `pay_invoice` preimages return
|
||||
successfully — the preimage is cryptographic proof of payment
|
||||
(it hashes to the bolt11's payment hash). DO NOT block UX on
|
||||
kind-9735 receipt arrival; receipts are async verification that
|
||||
surface in a future "zap history" view.
|
||||
6. Subscribe to both kind-9735 receipts in the background and
|
||||
persist them once they arrive, for later auditability.
|
||||
Returns `{ creatorPreimage, platformPreimage }` (sync) and resolves
|
||||
to receipt ids later (async); throws with the failure stage so the
|
||||
UI can show a precise error.
|
||||
|
||||
**Why creator first, platform second:** if the creator leg succeeds
|
||||
and the platform leg fails, the creator at least got paid; we surface
|
||||
"Retry platform share" to the user. The reverse ordering would mean
|
||||
a half-failed unlock paid the platform but not the creator, which is
|
||||
the worst possible state.
|
||||
|
||||
**Retry protection (double-spend).** Before paying either leg on a
|
||||
retry, call `nwcLookupInvoice(paymentHash)` to see if the wallet
|
||||
already settled it. If so, skip that leg and reuse the preimage from
|
||||
the lookup response. Persist the bolt11 + paymentHash in
|
||||
`PARTIAL_UNLOCK_KEY` (Phase 4) so retries across page reloads can
|
||||
also do this check.
|
||||
|
||||
**Ship gate**
|
||||
Unit-test the split math on edge cases (1 sat → 0/1; 100 sats @ 3000
|
||||
bps → 70/30; 101 @ 3000 → 71/30). Then a live dual-zap end to end
|
||||
to two different lud16s and observe both receipts on relays.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Zap-gated templates *(~4–6h)*
|
||||
|
||||
**Data model**
|
||||
Add two tags to kind-30078 publish events:
|
||||
- `["zap-gate", "true"]`
|
||||
- `["zap-min", "<sats>"]` — minimum (and total) unlock amount.
|
||||
|
||||
Existing publish code reads `state.visibility`, `state.meta.*`, etc.
|
||||
We add `state.publish.zapGate` (boolean) and `state.publish.zapMin`
|
||||
(sats) alongside.
|
||||
|
||||
**Publish UI** — additions to the existing publish-confirm modal:
|
||||
- Checkbox "Premium template — viewers must zap to unlock"
|
||||
- Number input for minimum sats (default 100; enforce ≥ 10).
|
||||
- Inline note: "Of every unlock, the creator receives 70 sats and the
|
||||
Artstr platform receives 30 sats (30% fee)." Numbers re-render
|
||||
from the user's input + the fee constant.
|
||||
- **Pre-publish checks (all blocking)**:
|
||||
- Creator's kind-0 has a `lud16`. Block with "Set a Lightning
|
||||
address in your Nostr profile before publishing a premium
|
||||
template."
|
||||
- Creator's LNURL `minSendable` ≤ creator's split share. Block with
|
||||
"Your Lightning provider requires a minimum of M sats per payment;
|
||||
raise your unlock price to at least N sats." (computed from
|
||||
`creatorMin / (1 - bps/10000)`).
|
||||
- Platform `lud16` resolved successfully, and its `minSendable` ≤
|
||||
platform's split share. Same logic, different recipient. (Very
|
||||
unlikely to fail in practice but we check defensively.)
|
||||
|
||||
**Feed card** — when `isZapGated(event)`:
|
||||
- Replace the Import button with a ⚡ "Unlock for N sats" card.
|
||||
- The card preview still renders (already does in feed); only the
|
||||
CTA changes.
|
||||
- If `isUnlockedLocally(event.id)` → show normal Import.
|
||||
|
||||
**Relay fast-path (the "already paid" check).** Zap receipts live on
|
||||
relays forever. Before charging the user (or even showing the lock),
|
||||
once per session, batch-query
|
||||
`{ kinds: [9735], '#e': [<all gated event ids in feed>], '#P': [<myPubkey>] }`
|
||||
and locally mark every event with a matching receipt as unlocked.
|
||||
This means:
|
||||
- A user who cleared `localStorage` (cache wipe / new browser /
|
||||
private window) is restored to "unlocked" automatically — no
|
||||
re-payment, no manual recovery.
|
||||
- Cross-device unlock works for free.
|
||||
- A half-failed unlock that eventually settled mid-flow gets
|
||||
detected as fully unlocked next session.
|
||||
|
||||
If a matching receipt exists for the creator leg but NOT the
|
||||
platform leg, surface "Resume unlock — platform share still owed"
|
||||
instead of either Import or full Lock.
|
||||
|
||||
**Unlock flow** (when the lock card is clicked):
|
||||
1. If NWC not connected → open the wallet setup modal first.
|
||||
2. Otherwise open a confirm modal:
|
||||
```
|
||||
Unlock "<title>" for N sats?
|
||||
• Creator (<short pubkey>): 70 sats
|
||||
• Artstr platform fee: 30 sats
|
||||
[Cancel] [Unlock]
|
||||
```
|
||||
3. On confirm: `payZapSplit(...)` with a streaming status line
|
||||
("Paying creator…", "Paying platform fee…", "Waiting for
|
||||
receipts…").
|
||||
4. On both receipts received: `markUnlocked(eventId)`, close modal,
|
||||
re-render the feed card with Import enabled.
|
||||
5. On any failure: show error + targeted retry button (e.g. "Retry
|
||||
platform share" if creator leg succeeded).
|
||||
|
||||
**Local cache**
|
||||
```js
|
||||
const UNLOCK_CACHE_KEY = 'casewrap-unlocked-templates';
|
||||
// shape: { [eventId]: { unlockedAt, creatorReceiptId, platformReceiptId } }
|
||||
```
|
||||
Receipt ids are kept so a future "Show my zap history" view can
|
||||
reconstruct the unlock log.
|
||||
|
||||
**Ship gate**
|
||||
1. Publish a premium template from account A.
|
||||
2. From account B, open the feed, click the lock, complete the
|
||||
unlock, see the Import button, import the template.
|
||||
3. Reload the page → import is still unlocked (localStorage cache).
|
||||
4. Manually clear the cache key in DevTools, then reload → the relay
|
||||
fast-path finds the existing kind-9735 receipts and restores the
|
||||
unlock without re-paying.
|
||||
5. Open the same feed in a fresh private-window tab signed in as the
|
||||
same account B → same outcome (cross-device unlock via relay
|
||||
fast-path).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — End-to-end + edge cases *(~3–4h)*
|
||||
|
||||
> Note: the encryption + purchase-vault work now lives in
|
||||
> `PREMIUM_DESIGNS_FEATURE.md` (the static soft-gate spec). Phase 4
|
||||
> below was scoped against the original tag-only model. Run it
|
||||
> *after* the soft-gate work lands so the E2E matrix exercises the
|
||||
> real encrypted flow, not the tag-only intermediate state.
|
||||
|
||||
**Test matrix**
|
||||
- CoinOS source wallet + creator with Strike lud16 + platform with
|
||||
Wallet of Satoshi lud16.
|
||||
- Wallet disconnects mid-pay (kill the WebSocket between calls):
|
||||
surfaced as a retry-able error.
|
||||
- Platform invoice fails (e.g. node offline): "Creator was paid;
|
||||
retry the platform share." Retry-only button reuses the existing
|
||||
creator preimage; no double-charge.
|
||||
- Creator has no `lud16`: blocked at publish time (already in Phase
|
||||
3), and on the consumer side blocked at unlock time with a clear
|
||||
"Creator can't receive payments yet" message.
|
||||
- Creator's LNURL `minSendable` > their share at the chosen
|
||||
`zap-min`: blocked at publish time with a corrective hint.
|
||||
- Wallet budget exhausted (`QUOTA_EXCEEDED`/`BUDGET_EXCEEDED`):
|
||||
"Your wallet's NWC budget is used up — raise it in your wallet's
|
||||
settings, then retry."
|
||||
- Wallet doesn't support `pay_invoice` (capability list missing it):
|
||||
flagged at connect time; lock card shows "Connected wallet can't
|
||||
pay — connect a different wallet."
|
||||
- NWC not configured on consumer side: redirect to wallet setup.
|
||||
- Two consumers unlocking the same template simultaneously: each
|
||||
unlock is independent; both succeed.
|
||||
- Cache wipe / new device: relay fast-path restores unlocked status
|
||||
without re-payment.
|
||||
|
||||
**Idempotency / re-attempt**
|
||||
Track every leg's bolt11 + payment hash + preimage (if known) via
|
||||
`localStorage`:
|
||||
```js
|
||||
const PARTIAL_UNLOCK_KEY = 'casewrap-partial-unlocks';
|
||||
// shape: {
|
||||
// [eventId]: {
|
||||
// creator?: { bolt11, paymentHash, preimage? },
|
||||
// platform?: { bolt11, paymentHash, preimage? },
|
||||
// startedAt
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
On any retry — same session or a future session — first call
|
||||
`nwcLookupInvoice(paymentHash)` for each leg whose `preimage` is
|
||||
missing; if the wallet says it's settled, update the cache and skip
|
||||
the pay step. Only then proceed to pay any genuinely-unpaid leg.
|
||||
|
||||
When the user opens the app and there's any record in
|
||||
`PARTIAL_UNLOCK_KEY` older than a few minutes, surface a top-bar
|
||||
banner: *"You have an unfinished unlock for «N» templates — [Resume]
|
||||
[Discard]."* This prevents the user from forgetting a half-paid
|
||||
unlock indefinitely.
|
||||
|
||||
**Ship gate**
|
||||
Every test-matrix row passes by hand on a deployed staging URL.
|
||||
Results documented in the corresponding commit message (this repo
|
||||
pushes straight to main; no PR workflow).
|
||||
|
||||
---
|
||||
|
||||
## Reused infrastructure (no new code)
|
||||
|
||||
Already in the codebase from the existing LNURL tip flow:
|
||||
- `bech32Decode` (npub → hex).
|
||||
- `lud16ToLnurlpUrl`, `resolveLnurlPayUrl`.
|
||||
- `fetchLnurlInvoice(callback, msats)`.
|
||||
- `buildZapRequestEvent({...})` (kind-9734).
|
||||
- `parseBolt11Msats`, `parseZapReceiptAmount`.
|
||||
- `ingestZapReceipt`, `startTipPaymentWatch`.
|
||||
- `zapTotalForEvent`, `zapTotalForAuthor`.
|
||||
- The existing tip-modal styling (we extend, not duplicate).
|
||||
|
||||
---
|
||||
|
||||
## Threat model
|
||||
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Non-Artstr client implements `zap-gate` without the split | Accepted. Revenue model assumes Artstr is the primary publishing client. |
|
||||
| User edits `localStorage` to fake an unlock | Cosmetic — they can already pirate the rendered design from the feed preview. The gate exists to align incentives, not to enforce DRM. |
|
||||
| MITM on the NWC relay | The NIP-04 encryption uses ECDH between client + wallet service pubkeys, so the relay sees ciphertext only. Standard NIP-47 security. |
|
||||
| Platform npub kind-0 lookup fails | Retry on next attempt; cache the resolved `lud16` for the session. If still failing after retries, the unlock errors out — no creator-only fallback (matches the "split is mandatory" goal). |
|
||||
| Replay an old zap receipt to fake an unlock | Receipts must reference the specific template's event id (`#e`) AND the sender pubkey matches the local user. Old receipts for other events are rejected. |
|
||||
| NWC secret exfiltration via XSS | Same risk surface as the NIP-07 extension key. Acknowledged. The secret lives in `localStorage` (not session-only) so reconnect doesn't require re-pasting. |
|
||||
| Platform pubkey exposed in every premium-unlock receipt on public relays | Intentional — receipts ARE the revenue audit trail. Worth noting because if we ever switch to a "stealth" revenue model this assumption changes. |
|
||||
| Wallet budget exhausted mid-flow (creator paid, platform fails on budget) | Detected by NWC error code; surfaced as a precise retry prompt ("raise budget, then retry platform share"). Creator preimage is preserved so the retry only pays the platform leg. |
|
||||
| Wallet returns a successful preimage but the LSP never publishes the kind-9735 receipt | Preimage IS proof; we unlock locally. Receipt is async verification only. Other clients querying the relay won't see this user as a "verified zapper" but that's a cosmetic concern, not a correctness one. |
|
||||
| Receipt fast-path returns a forged event | Relays don't sign events themselves — the kind-9735 receipt must be signed by the LSP's zapper pubkey advertised in the LNURL response. We verify the signature + the `bolt11` tag matches a legitimate invoice for the event id. (Same verification the existing tip flow does.) |
|
||||
|
||||
---
|
||||
|
||||
## Open questions to revisit later
|
||||
|
||||
- **Lower the fee.** 30% is the launch number. We may want a tiered
|
||||
model (e.g. 10% above 10k-sat unlocks) once usage data shows
|
||||
whether high-value unlocks are common. Single-constant design today
|
||||
makes this a one-line edit.
|
||||
- **Pay-above-minimum unlocks.** v1 locks the paid amount to exactly
|
||||
`zap-min` (still split 70/30). A future polish would allow the
|
||||
user to pay extra — bonus goes entirely to the creator (no extra
|
||||
platform cut on the tip portion). Encourages support without
|
||||
feeling like price-gouging.
|
||||
- **Receiving-side NWC.** Out of scope here. If we ever want creators
|
||||
to *receive* via NWC instead of LNURL, that's a separate spec.
|
||||
- **General zaps via NWC.** Existing tip flow still shows bolt11/QR.
|
||||
Migrating that to NWC once it's wired here is a small follow-up
|
||||
(4–5h, reuses everything Phases 0–2 build).
|
||||
- **Currency display.** All amounts are in sats. Showing live USD/EUR
|
||||
next to the sats price would help creators set sensible prices, but
|
||||
introduces an FX dependency. Deferred.
|
||||
@@ -1,261 +0,0 @@
|
||||
# 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 with `ZAPS_NWC_FEATURE.md` and 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
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user