Files
Artstr-Studio/docs/ZAP_GATED_PREMIUM_FEATURE.md
T
thePR0M3TH3ANandClaude Opus 4.7 7da0c9d97c Adopt static soft-gate + buyer purchase vault spec as canonical premium model
In live testing, the tag-only gate shipped in Phases 0–3 turned out
to be too leaky: the design payload was published in plaintext, three
separate import paths (feed-card Use/Fork, preview-pane Use/Fork,
preview-pane Save JSON) bypassed the lock, and any non-Artstr client
could ignore the tag entirely. Adam pulled back the original detailed
spec he wrote earlier — the static soft-gate + buyer purchase vault
model — which solves the protection gap WITHOUT requiring a creator-
online bot (the route I was about to build).

Key idea: the AES key derives deterministically from
HKDF-SHA256(softGatePepperForEpoch(epoch), publicDesignSalt, info =
"artstr-softgate-v1" + epoch + creatorPubkey + premiumAddress +
schemaVersion). The pepper lives in the static client (obfuscated,
not secret). The client refuses to derive the key until valid kind-
9735 receipts confirm the split zap. Same key for every buyer; no
creator-online requirement; cross-device unlock via a NIP-44 self-
encrypted purchase vault (kind-30078, d=artstr:purchase-vault:v1).

Files:

  - docs/PREMIUM_DESIGNS_FEATURE.md (new, canonical) — the full
    soft-gate + split-zap + purchase-vault spec. Sections 1–17 are
    the original spec verbatim. Section 18 maps the shipped Phases
    0–3 to the spec's implementation-plan items (10.0–10.6) so the
    next contributor sees what's done. Section 19 lists the
    remaining work in build order: soft-gate crypto primitive →
    watermarked preview → premium publish event → premium consume
    → receipt fast-path by address → purchase vault → My Purchases
    UI → hardening + copy.

  - docs/ZAP_GATED_PREMIUM_FEATURE.md — banner now points at
    PREMIUM_DESIGNS_FEATURE.md as the canonical going-forward doc.
    The obsolete Phases 5/6 (DM-delivery key flow) I sketched
    earlier today are removed; the "Content model (CORRECTED)"
    section is trimmed and points at the new spec. The file is
    kept for Phases 0–3 history, the NWC/tip integration notes,
    and the threat model that still applies.

  - docs/ZAP_GATED_TEMPLATES_FEATURE.md and ZAPS_NWC_FEATURE.md
    keep their "superseded" banners pointing at
    ZAP_GATED_PREMIUM_FEATURE.md (which itself now points at
    PREMIUM_DESIGNS_FEATURE.md) — chain of provenance preserved.

  - TODO.md: drafted-but-unbuilt entry rewritten to describe the
    final shape (encrypted + vault + honest soft-gate framing) and
    point at the new spec; references the older docs for the
    shipped foundation.

No code changes yet — the spec adoption is the gate. Next commits
implement the soft-gate crypto primitive, the watermarked preview,
and the premium publish path.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-28 15:20:31 -04:00

471 lines
22 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 (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.