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]>
13 KiB
Zap-Gated Premium Templates (with NWC + Platform Split) — Feature Plan
Status: planned, no code yet. This document supersedes the original
ZAP_GATED_TEMPLATES_FEATURE.mdandZAPS_NWC_FEATURE.md— both of those described pieces of this feature in isolation. The two have been merged here because, in this product, they're a single arc: the platform-fee split is the whole reason for shipping zap-gated templates, and NWC is what makes the split tolerable as a UX.
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)
- Non-Artstr clients. Gating is client-side; another client could
in principle implement the
zap-gatetag without the platform split. The revenue model relies on Artstr being the primary client for the audience that publishes premium templates. - 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).
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 (~2–3h)
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/hashessubset (schnorr sign/verify, ECDH for NIP-04 shared-secret). - Inline a NIP-04 helper using
crypto.subtleAES-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>
wallet-service-pubkey— the wallet's NIP-47 service pubkey.relay— a dedicated relay both ends listen on.secret— a 32-byte hex privkey that the client uses to sign requests (this is NOT the user's identity key).
Request lifecycle
- 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]. - Sign with the NWC secret (schnorr).
- Publish to the NWC relay.
- Subscribe
{ kinds: [23195], '#p': [<client pubkey>], '#e': [<request id>] }. - 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, relay, secret }or null.nwcConnect(uri)— store creds, open WS, send aget_infoping to verify the wallet responds.nwcPayInvoice(bolt11, { timeoutMs, hintMsats })→ resolves to{ preimage }or rejects with{ code, message }.- Credentials live in
localStorageundercasewrap-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. Visit coinos.io and sign up. 2. Open Settings → Wallet Connect. 3. Copy the connection string (
nostr+walletconnect://…). 4. 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.
Connected:
┌────────────────────────────────────────────┐
│ ⚡ Connected to <walletDomain or short> │
│ Last test: <time> [Test] [Disconnect] │
└────────────────────────────────────────────┘
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)
const PLATFORM_NPUB = 'npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx';
const PLATFORM_FEE_BPS = 3000; // 30%. Adjust to lower the cut.
Deliverables
PLATFORM_PUBKEY_HEXderived 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 two zap requests in sequence:- Build + sign kind-9734 to creator for
creatorSats. - Fetch creator's bolt11 via existing
fetchLnurlInvoice. - Pay creator via
nwcPayInvoice. - Build + sign kind-9734 to platform for
platformSats. - Fetch platform's bolt11.
- Pay platform via
nwcPayInvoice. - Await both kind-9735 receipts on relays
(reuse existing
startTipPaymentWatchpattern). Returns{ creatorReceipt, platformReceipt }or throws with the failure stage so the UI can show a precise error.
- Build + sign kind-9734 to creator for
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.
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 check: creator must have a
lud16in their kind-0; if not, block with "Set a Lightning address in your Nostr profile before publishing a premium template."
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.
Unlock flow (when the lock card is clicked):
- If NWC not connected → open the wallet setup modal first.
- Otherwise open a confirm modal:
Unlock "<title>" for N sats? • Creator (<short pubkey>): 70 sats • Artstr platform fee: 30 sats [Cancel] [Unlock] - On confirm:
payZapSplit(...)with a streaming status line ("Paying creator…", "Paying platform fee…", "Waiting for receipts…"). - On both receipts received:
markUnlocked(eventId), close modal, re-render the feed card with Import enabled. - On any failure: show error + targeted retry button (e.g. "Retry platform share" if creator leg succeeded).
Local cache
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 Publish a premium template from account A. From account B, open the feed, click the lock, complete the unlock, see the Import button, import the template. Reload the page → import is still unlocked (cache survives). Manually clear the cache key in DevTools → lock returns; second unlock works without re-paying (uses the cached zap receipts to fast-path verification before paying — see "Idempotency").
Phase 4 — End-to-end + edge cases (~3–4h)
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 receipt.
- 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. - NWC not configured on consumer side: redirect to wallet setup.
- Two consumers unlocking the same template simultaneously: each unlock is independent; both succeed.
Idempotency / re-attempt
Track which legs paid via localStorage:
const PARTIAL_UNLOCK_KEY = 'casewrap-partial-unlocks';
// shape: { [eventId]: { creatorReceiptId?, platformReceiptId? } }
On retry after partial failure, skip already-paid legs.
Ship gate Every test-matrix row passes by hand on the deployed staging build, with screenshots attached to the PR.
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. |
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.
- 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).