13 KiB
Zaps via Nostr Wallet Connect (NWC) Feature Plan
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
localStoragekeycasewrap-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
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.nip04cannot be used here. - Requires a small crypto library (see Crypto Dependencies section).
Request event (kind 23194)
{
"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:
{
"method": "pay_invoice",
"params": { "invoice": "lnbc..." }
}
Response event (kind 23195)
Wallet sends this back, encrypted to appPubkey:
{
"result_type": "pay_invoice",
"result": { "preimage": "<hex>" }
}
// OR on failure:
{
"result_type": "pay_invoice",
"error": { "code": "INSUFFICIENT_BALANCE", "message": "..." }
}
Core send function
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:
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:
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:
{
"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:
{
"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().
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:
<!-- 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:
<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_balancerequest - 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
lud16orlud06 - 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
contentfield of the kind 9734 zap request - "Send Zap" button shows spinner while in flight, then ✅ success or ❌ error message
State Additions
// 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
lud16is already instate.community.profiles— no extra relay query needed get_balanceis 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 (
lud16orlud06) 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_balanceand 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
localStorageonly — never send it to any server - Never log
uriorsecretto console - The NWC
secretgrants 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
ptags) - 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_balancerequest 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: falseshows 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