14 KiB
NIP-61 Nutzaps — Implementation Plan
What is NIP-61?
NIP-61 defines Nutzaps — Cashu ecash-based zaps on Nostr. Instead of paying a Lightning invoice (NIP-57), the sender creates Cashu token proofs locked to the recipient's pubkey (P2PK) and publishes them as a kind 9321 event. The recipient redeems the proofs from the mint.
Key Differences from NIP-57 Zaps
| Aspect | NIP-57 Zaps | NIP-61 Nutzaps |
|---|---|---|
| Payment | Lightning invoice via LNURL | Cashu proofs in Nostr event |
| Requires | Recipient has lud16/lud06 | Recipient has kind 10019 mint list |
| Event kind | 9734 request + 9735 receipt | 9321 nutzap event |
| Custody | Lightning wallet provider | Self-custodied ecash |
| Privacy | LNURL server sees payment | Mint sees redemption, not sender |
| Offline | Recipient must have LN node | Proofs wait on relays |
NIP-61 Event Structure (kind 9321)
{
"kind": 9321,
"content": "optional comment",
"tags": [
["proof", "{...serialized proof JSON...}"],
["u", "https://mint.example.com"],
["e", "<event-id-being-zapped>"],
["p", "<recipient-pubkey>"],
["amount", "100"],
["unit", "sat"],
["alt", "This is a nutzap"]
]
}
Kind 10019 — CashuMintList (Recipient Signal)
A replaceable event published by users who want to receive nutzaps:
{
"kind": 10019,
"tags": [
["mint", "https://mint.minibits.cash/Bitcoin"],
["mint", "https://mint.coinos.io"],
["relay", "wss://relay.damus.io"],
["pubkey", "<optional-p2pk-pubkey>"]
]
}
Current State of the App
What Already Exists
-
NIP-60 Cashu Wallet — Full wallet on
cashu.htmlwithcashu-wallet.mjscontroller. Direct cashu-ts operations inndk-worker.js— send, receive, deposit, pay invoice, proof management. -
NIP-57 Zap Flow —
zaps.mjshandles LNURL resolution, zap request creation (kind 9734), invoice fetching.post-interactions.mjshashandleZapClick()that prompts for amount, creates invoice, pays viawalletPayInvoice(). -
NDK Bundle Includes NIP-61 Classes — The
ndk-entry.jsre-exports fromndk/wallet/srcandndk/core/srcwhich include:NDKNutzap— Kind 9321 event class with proof management, P2PK, validationNDKCashuMintList— Kind 10019 event classNDKNutzapMonitor— Monitors incoming nutzaps and auto-redeemsNDKZapper.zapNip61()— NIP-61 zap method in the zapper- Nutzap validation, proof spend state checking, grouping utilities
-
cashu-ts Exports —
CashuMint,CashuWallet,getDecodedToken,getEncodedTokenV4are all available in the bundle. -
Worker Message API — All wallet operations go through
init-ndk.mjs→ SharedWorker message protocol. The worker has direct access to cashu-ts and NDK.
What's Missing for NIP-61
- Sending nutzaps — No way to create P2PK-locked proofs and publish kind 9321 events
- Publishing kind 10019 — No way to advertise mint list for receiving nutzaps
- Receiving/redeeming nutzaps — No nutzap monitor or redemption flow
- Detecting recipient nutzap support — No check for kind 10019 before offering nutzap option
- UI integration — Zap button only does NIP-57; no nutzap option in the zap flow
Architecture
flowchart TD
subgraph Sending a Nutzap
A[User clicks Zap on a post] --> B{Recipient has kind 10019?}
B -->|Yes| C[Show nutzap option in zap modal]
B -->|No| D[NIP-57 Lightning zap only]
C --> E[User picks amount]
E --> F[Worker: create P2PK proofs via cashu-ts]
F --> G[Worker: build and sign kind 9321 event]
G --> H[Worker: publish to relays]
end
subgraph Receiving Nutzaps
I[Worker subscription: kind 9321 where p = me] --> J[Validate nutzap]
J --> K[Check proof spend state at mint]
K -->|Unspent| L[Redeem proofs via cashu-ts]
L --> M[Store proofs in wallet / kind 7375]
M --> N[Broadcast balance update]
K -->|Already spent| O[Mark as spent / ignore]
end
subgraph Publishing Mint List
P[cashu.html Settings] --> Q[User configures mints + relays]
Q --> R[Publish kind 10019 event]
end
Implementation Plan
Phase 1: Publish Kind 10019 Mint List
Enable the user to advertise their nutzap preferences so others can send them nutzaps.
Files modified:
www/ndk-worker.js— New handlerhandlePublishMintListwww/js/init-ndk.mjs— New exportwalletPublishMintList()www/js/cashu-wallet.mjs— New controller methodpublishMintList()www/cashu.html— UI toggle/button in settings section
Worker handler logic:
- Build a kind 10019 event with
minttags from the wallet's configured mints - Add
relaytags from the user's connected relays - Optionally add a
pubkeytag for P2PK (the wallet's cashu pubkey) - Sign and publish the event
Phase 2: Detect Recipient Nutzap Support
Before sending a nutzap, check if the recipient has published a kind 10019 event.
Files modified:
www/ndk-worker.js— New handlerhandleFetchMintListwww/js/init-ndk.mjs— New exportfetchRecipientMintList(pubkey)www/js/zaps.mjs— New functionresolveNutzapSpecForPubkey()
Worker handler logic:
- Fetch kind 10019 event for the given pubkey
- Parse mint URLs, relay URLs, and optional P2PK pubkey
- Return
{ mints, relays, p2pk }or null if no mint list found
Phase 3: Send Nutzaps (Kind 9321)
Create P2PK-locked Cashu proofs and publish them as a nutzap event.
Files modified:
www/ndk-worker.js— New handlerhandleWalletSendNutzapwww/js/init-ndk.mjs— New exportwalletSendNutzap()www/js/zaps.mjs— New functionsendNutzap()
Worker handler logic:
- Receive:
amount,recipientPubkey,recipientMints,recipientP2pk,eventId(optional),comment - Find a mint in common between sender wallet and recipient mint list
- If no common mint, attempt cross-mint transfer (send from our mint, swap into recipient mint)
- Create P2PK-locked proofs using cashu-ts
wallet.send()with P2PK spending conditions - Build proof tags with replay protection (
etag for event ID,Ptag for sender pubkey in proof secret) - Construct kind 9321 event with:
prooftags,u(mint),p(recipient),e(event),amount,unit,alt,commenttag - Sign via MessageBasedSigner and publish
- Update local proof store (remove spent proofs)
- Return nutzap event details and updated balance
P2PK proof creation (cashu-ts):
const { keep, send } = await cashuWallet.send(amount, proofs, {
pubkey: recipientP2pk, // Lock to recipient
tags: [
['e', eventId], // Replay protection
['P', senderPubkey] // Sender verification
]
});
Phase 4: Receive and Redeem Nutzaps
Monitor for incoming nutzaps and automatically redeem them.
Files modified:
www/ndk-worker.js— New subscription for kind 9321, new handlerhandleRedeemNutzap, nutzap processing logicwww/js/init-ndk.mjs— New eventsndkNutzapReceived, new exportwalletRedeemNutzap()www/js/cashu-wallet.mjs— Nutzap event listener, redemption status display
Worker logic:
- At startup (after wallet hydration), subscribe to
{ kinds: [9321], '#p': [currentPubkey] } - On new event: parse as NDKNutzap, validate (check proofs exist, single mint, single recipient)
- Check proof spend state at the mint via
cashuWallet.checkProofsStates() - If unspent: redeem P2PK proofs using the wallet's private key
- Add redeemed proofs to
directProofStore, publish updated kind 7375 events - Broadcast
nutzapReceivedmessage with amount, sender, comment - Broadcast updated balance
- Track redeemed nutzap IDs to avoid double-processing
P2PK redemption (cashu-ts):
const proofs = nutzap.proofs;
const redeemed = await cashuWallet.receive(proofs, {
privkey: walletPrivateKey // Needed to unlock P2PK proofs
});
Key consideration: P2PK redemption requires a private key. The current wallet uses window.nostr for signing (NIP-07/NIP-46), which doesn't expose the raw private key. Options:
- Option A: Generate a separate Cashu-specific keypair stored encrypted in kind 17375 wallet metadata. The
pubkeytag in kind 10019 advertises this key. This is the approach NDK'sNDKCashuMintList.p2pksupports. - Option B: Use the nostr private key directly (only works with NIP-07 extensions that expose it, which most don't).
- Option A is recommended — it's what the NIP-61 spec and NDK implementation expect.
Phase 5: UI Integration — Zap Modal Enhancement
Update the zap flow to offer nutzap as an option when the recipient supports it.
Files modified:
www/js/zaps.mjs— UpdatepromptZapDetails()to include nutzap togglewww/js/post-interactions.mjs— UpdatehandleZapClick()to check for nutzap support and route accordinglywww/css/client.css— Nutzap-specific styling (if needed beyond inline styles)
Flow change in handleZapClick():
- Before showing the zap modal, fetch recipient's kind 10019 mint list
- If mint list exists AND user has a Cashu wallet with balance:
- Show "Nutzap" toggle/option in the zap details modal
- Default to nutzap if both parties share a mint
- If nutzap selected: call
walletSendNutzap()instead of the NIP-57 flow - If NIP-57 selected (or no mint list): existing Lightning zap flow
Phase 6: Display Nutzaps in Post Interactions
Show received nutzaps alongside regular zaps in the post interaction counts.
Files modified:
www/js/post-interactions.mjs— Subscribe to kind 9321 events, parse nutzap amounts, include in zap totals
Changes:
- Add kind 9321 to the subscription filter alongside kind 9735
- When a kind 9321 event arrives, parse it as a nutzap: extract amount from proof tags, sender from pubkey, comment from content
- Add to the same
state.zapsarray (or a parallelstate.nutzapsarray) and include inzapTotal - Display nutzap indicator (e.g., different icon or label) to distinguish from Lightning zaps
Phase 7: Cashu Keypair Management
Generate and store a Cashu-specific P2PK keypair for nutzap reception.
Files modified:
www/ndk-worker.js— Keypair generation, encrypted storage in kind 17375www/js/cashu-wallet.mjs— Expose P2PK pubkey for kind 10019
Logic:
- When wallet is created or first nutzap setup: generate a secp256k1 keypair
- Store the private key encrypted (NIP-44) in the kind 17375 wallet event content
- The public key (with "02" prefix for Cashu) goes in the kind 10019
pubkeytag - On wallet load: decrypt and cache the private key for nutzap redemption
- The
NDKNutzapMonitoraccepts private keys viaaddPrivkey()— we feed it this key
Technical Considerations
Mint Compatibility
- Sender and recipient should share at least one mint for direct nutzaps
- If no common mint: cross-mint transfer is possible but adds complexity and fees
- The UI should indicate when mints are compatible vs. when a swap is needed
Proof Replay Protection
- Each proof's secret should contain an
etag matching the event being zapped - This prevents the same proof from being used in multiple nutzap events
- The
Ptag in the proof secret identifies the sender for verification
Privacy Benefits
- The mint only sees proof redemption, not the Nostr event or social context
- No LNURL server involved — no third party sees the payment
- Sender and recipient interact only through Nostr relays and the Cashu mint
Error Handling
- Mint offline: queue nutzap for retry, show pending state
- Proofs already spent: mark nutzap as invalid, notify user
- No common mint: offer to swap or fall back to NIP-57
- Insufficient balance: show balance and suggest deposit
Files Summary
| File | Changes |
|---|---|
www/ndk-worker.js |
handlePublishMintList, handleFetchMintList, handleWalletSendNutzap, handleRedeemNutzap, nutzap subscription, P2PK keypair management |
www/js/init-ndk.mjs |
walletPublishMintList(), fetchRecipientMintList(), walletSendNutzap(), walletRedeemNutzap(), ndkNutzapReceived event |
www/js/zaps.mjs |
resolveNutzapSpecForPubkey(), sendNutzap(), update promptZapDetails() with nutzap toggle |
www/js/cashu-wallet.mjs |
publishMintList(), nutzap event listeners, P2PK pubkey exposure |
www/js/post-interactions.mjs |
Subscribe to kind 9321, parse nutzap amounts, update zap totals, nutzap routing in handleZapClick() |
www/cashu.html |
Mint list publish button, nutzap reception settings, incoming nutzap display |
build/ndk-entry.js |
Verify NDKNutzap, NDKCashuMintList exports (likely already included via wildcard) |
Dependency Chain
flowchart LR
P7[Phase 7: Cashu Keypair] --> P1[Phase 1: Publish 10019]
P1 --> P4[Phase 4: Receive Nutzaps]
P2[Phase 2: Detect Support] --> P3[Phase 3: Send Nutzaps]
P3 --> P5[Phase 5: UI Integration]
P4 --> P6[Phase 6: Display in Posts]
P5 --> P6
Phases 1-2 and Phase 7 can be worked on in parallel. Phase 3 depends on Phase 2. Phase 4 depends on Phase 1 and 7. Phases 5-6 depend on everything else.