From c5198b39b83cc6295e03854119848f7f6569493a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 20 Aug 2026 07:40:43 -0600 Subject: [PATCH] Add nostr-zap-integration skill --- .agents/skills/nostr-zap-integration/SKILL.md | 279 +++++++++++++ .../nostr-zap-integration/evals/evals.json | 56 +++ .../references/nutzap-flow.md | 332 ++++++++++++++++ .../references/zap-flow.md | 370 ++++++++++++++++++ skills-lock.json | 6 + 5 files changed, 1043 insertions(+) create mode 100644 .agents/skills/nostr-zap-integration/SKILL.md create mode 100644 .agents/skills/nostr-zap-integration/evals/evals.json create mode 100644 .agents/skills/nostr-zap-integration/references/nutzap-flow.md create mode 100644 .agents/skills/nostr-zap-integration/references/zap-flow.md diff --git a/.agents/skills/nostr-zap-integration/SKILL.md b/.agents/skills/nostr-zap-integration/SKILL.md new file mode 100644 index 0000000..aee52cf --- /dev/null +++ b/.agents/skills/nostr-zap-integration/SKILL.md @@ -0,0 +1,279 @@ +--- +name: nostr-zap-integration +description: Implement or debug NIP-57 zaps or NIP-61 nutzaps when the task involves zap requests, receipts, LNURL-pay, zap splits, Cashu tokens, or recipient payment configuration. +--- + +# Nostr Zap Integration + +## Overview + +Build correct Lightning Zap and Nutzap flows for Nostr applications. This skill +covers the full NIP-57 lifecycle (LNURL discovery, zap request construction, +invoice handling, zap receipt validation) and the NIP-61 Cashu alternative +(nutzap configuration, P2PK token minting, nutzap publishing and redemption). + +## When to Use + +- The task involves NIP-57 zaps or NIP-61 nutzaps in a Nostr application. +- The user needs zap requests, receipts, LNURL-pay integration, zap splits, recipient config, or Cashu token flow tied to Nostr events. +- The problem is payment interoperability between Nostr and Lightning/Cashu, not general wallet construction. +- The request is about end-to-end zap behavior or zap validation. + +**Do NOT use when:** + +- The task is generic Lightning, LNURL, or Cashu work with no Nostr zap context. +- The work is relay protocol or general Nostr event creation. +- The request is bech32 encoding or other peripheral concerns unrelated to zap flows. + + +## Response format + +Always structure the final response with these top-level sections, in this order: + +1. **Summary** — state the task, scope, and main conclusion in 1-3 sentences. +2. **Decision / Approach** — state the key classification, assumptions, or chosen path. +3. **Artifacts** — provide the primary deliverable(s) for this skill. Use clear subheadings for multiple files, commands, JSON payloads, queries, or documents. +4. **Validation** — state checks performed, important risks, caveats, or unresolved questions. +5. **Next steps** — list concrete follow-up actions, or write `None` if nothing remains. + +Rules: +- Do not omit a section; write `None` when a section does not apply. +- If files are produced, list each file path under **Artifacts** before its contents. +- If commands, JSON, SQL, YAML, or code are produced, put each artifact in fenced code blocks with the correct language tag when possible. +- Keep section names exactly as written above so output stays predictable across skills. + +## Workflow + +### 1. Determine the Payment Path + +Ask: "Is this a Lightning Zap (NIP-57) or a Nutzap (NIP-61)?" + +| Path | When to Use | Key Kinds | +| -------------- | --------------------------------------- | ----------- | +| Lightning Zap | Recipient has lud16/lud06, LNURL server | 9734, 9735 | +| Nutzap (Cashu) | Recipient has kind:10019, trusted mints | 10019, 9321 | + +If unsure, check the recipient's profile (kind:0) for `lud16`/`lud06` fields +(Lightning path) or query for their kind:10019 event (Nutzap path). + +### 2. Lightning Zap Flow (NIP-57) + +Follow the steps in [references/zap-flow.md](references/zap-flow.md) for the +complete implementation. Summary: + +#### Step 2a: Discover the LNURL Endpoint + +```typescript +// From lud16 (e.g., "bob@example.com") +const [name, domain] = lud16.split("@"); +const url = `https://${domain}/.well-known/lnurlp/${name}`; +const res = await fetch(url); +const lnurlPayData = await res.json(); + +// Verify Nostr support +if (!lnurlPayData.allowsNostr || !lnurlPayData.nostrPubkey) { + throw new Error("Recipient does not support Nostr zaps"); +} +``` + +**Critical checks on the LNURL response:** + +- `allowsNostr` MUST be `true` +- `nostrPubkey` MUST be a valid 32-byte hex public key +- Save `callback`, `minSendable`, `maxSendable` for later use + +#### Step 2b: Construct the Zap Request (kind:9734) + +```json +{ + "kind": 9734, + "content": "Optional zap comment", + "tags": [ + ["relays", "wss://relay1.example.com", "wss://relay2.example.com"], + ["amount", "21000"], + ["lnurl", "lnurl1dp68gurn8ghj7..."], + ["p", ""], + ["e", ""], + ["k", ""] + ] +} +``` + +**Required tags:** `relays` (list of relay URLs), `p` (recipient pubkey). +**Recommended tags:** `amount` (millisats as string), `lnurl` (bech32-encoded). +**Optional tags:** `e` (event being zapped), `a` (addressable event coordinate), +`k` (kind of zapped event as string). + +**Critical:** The zap request is NOT published to relays. It is sent to the +LNURL callback URL. + +#### Step 2c: Send to Callback and Get Invoice + +```typescript +const zapRequestEncoded = encodeURIComponent(JSON.stringify(signedZapRequest)); +const url = + `${callback}?amount=${amountMsats}&nostr=${zapRequestEncoded}&lnurl=${lnurlBech32}`; +const { pr: invoice } = await fetch(url).then((r) => r.json()); +``` + +#### Step 2d: Pay the Invoice + +Pass the bolt11 invoice to a Lightning wallet for payment. After payment, the +recipient's LNURL server creates and publishes the zap receipt (kind:9735). + +#### Step 2e: Validate Zap Receipts + +See [references/zap-flow.md](references/zap-flow.md) for full validation logic. +The three critical checks: + +1. Receipt `pubkey` MUST match the recipient's LNURL `nostrPubkey` +2. Invoice amount in `bolt11` tag MUST match `amount` in the zap request +3. `SHA256(description)` SHOULD match the bolt11 description hash + +### 3. Nutzap Flow (NIP-61) + +Follow the steps in [references/nutzap-flow.md](references/nutzap-flow.md) for +the complete implementation. Summary: + +#### Step 3a: Fetch Recipient's Nutzap Configuration (kind:10019) + +```json +{ + "kind": 10019, + "tags": [ + ["relay", "wss://relay1.example.com"], + ["relay", "wss://relay2.example.com"], + ["mint", "https://mint.example.com", "sat"], + ["mint", "https://othermint.example.com", "usd", "sat"], + ["pubkey", ""] + ] +} +``` + +**Critical:** The `pubkey` tag value MUST NOT be the user's main Nostr pubkey. +It is a separate key used exclusively for P2PK locking. + +#### Step 3b: Mint P2PK-Locked Tokens + +1. Choose a mint from the recipient's `mint` tags +2. Mint or swap tokens P2PK-locked to the recipient's `pubkey` value +3. Prefix the pubkey with `"02"` for nostr-cashu compatibility +4. Include DLEQ proofs (NUT-12) + +#### Step 3c: Publish the Nutzap (kind:9321) + +```json +{ + "kind": 9321, + "content": "Optional comment", + "tags": [ + ["proof", ""], + ["unit", "sat"], + ["u", "https://mint.example.com"], + ["e", "", ""], + ["k", ""], + ["p", ""] + ] +} +``` + +Publish to the relays listed in the recipient's kind:10019 `relay` tags. + +#### Step 3d: Receiving Nutzaps + +Recipients query for kind:9321 events p-tagging them, filtered by trusted mint +URLs (`#u`). Upon receiving, swap the tokens into their wallet and publish a +kind:7376 redemption event. + +### 4. Zap Splits + +When an event has `zap` tags, distribute the zap across recipients: + +```json +["zap", "", "", ""] +``` + +Weights are relative. Calculate percentages: + +```typescript +const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0); +for (const tag of zapTags) { + const weight = Number(tag[3] || 0); + const pct = weight / totalWeight; + const recipientAmount = Math.floor(totalAmount * pct); + // Create separate zap request for each recipient +} +``` + +Recipients without a weight value get weight 0 (no zap). If no weights are +present on any tag, divide equally. + +## Checklist + +- [ ] Identified payment path (Lightning vs Nutzap) +- [ ] For Lightning: LNURL endpoint discovered and verified (`allowsNostr`, + `nostrPubkey`) +- [ ] For Lightning: Zap request (kind:9734) has required tags (`relays`, `p`) +- [ ] For Lightning: Zap request sent to callback URL, NOT published to relays +- [ ] For Lightning: Amount in millisats, within `minSendable`/`maxSendable` +- [ ] For Lightning: Zap receipt validation checks all three criteria +- [ ] For Nutzap: Recipient's kind:10019 fetched and parsed +- [ ] For Nutzap: Tokens minted at one of recipient's listed mints +- [ ] For Nutzap: P2PK pubkey prefixed with "02" and is NOT the main Nostr key +- [ ] For Nutzap: kind:9321 published to recipient's specified relays +- [ ] For splits: Weights calculated correctly, separate zap per recipient + +## Common Mistakes + +| Mistake | Why It Breaks | Fix | +| ---------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | +| Publishing kind:9734 to relays | Zap requests are sent to LNURL callback, never published | Send via HTTP GET to callback URL | +| Amount in satoshis instead of millisats | NIP-57 uses millisats (1 sat = 1000 msats) | Multiply sats by 1000 for the `amount` tag | +| Using recipient's Nostr pubkey for P2PK | NIP-61 requires a SEPARATE key for P2PK locking | Use the `pubkey` from kind:10019, never the main key | +| Missing `relays` tag on zap request | LNURL server won't know where to publish the receipt | Always include at least one relay in `relays` tag | +| Not validating receipt pubkey | Fake zap receipts from wrong keys accepted | Receipt pubkey MUST match LNURL `nostrPubkey` | +| Sending nutzap to unlisted mint | Recipient may never see it; tokens could be lost | Only use mints from recipient's kind:10019 `mint` tags | +| Missing "02" prefix on P2PK pubkey | Cashu P2PK expects compressed pubkey format | Always prefix with "02" for nostr-cashu compat | +| Not checking `allowsNostr` on LNURL | Server may not support Nostr zaps at all | Verify `allowsNostr: true` before constructing zap | +| Treating zap receipt as proof of payment | Receipts can be forged by rogue LNURL servers | Trust the receipt author, not the receipt itself | + +## Quick Reference + +| Operation | Kind | Key Tags | Published? | +| ------------- | ----- | -------------------------------------- | --------------------- | +| Zap request | 9734 | `relays`, `p`, `amount`, `lnurl`, `e` | NO (HTTP only) | +| Zap receipt | 9735 | `p`, `P`, `bolt11`, `description`, `e` | YES (by LNURL server) | +| Nutzap config | 10019 | `relay`, `mint`, `pubkey` | YES (replaceable) | +| Nutzap send | 9321 | `proof`, `u`, `unit`, `p`, `e` | YES | +| Nutzap redeem | 7376 | `e` (9321 ref), `p` (sender) | YES (encrypted) | + +## Key Principles + +1. **Zap requests are HTTP-only** — Kind:9734 events are NEVER published to + relays. They are signed, JSON-encoded, URI-encoded, and sent as a query + parameter to the LNURL callback URL. This is the most common mistake. + +2. **Validate the full chain** — A valid zap receipt requires matching the + receipt pubkey to the LNURL `nostrPubkey`, matching the invoice amount to the + request amount, and verifying the description hash. Skipping any check allows + forged zaps. + +3. **Nutzap keys are separate** — The P2PK pubkey in kind:10019 MUST be a + different key from the user's main Nostr identity key. Using the same key + would allow anyone to spend received tokens. Always prefix with "02". + +4. **Amounts are in millisatoshis** — NIP-57 uses millisats everywhere (1 sat = + 1000 msats). The `amount` tag, `minSendable`, `maxSendable`, and invoice + amounts are all in millisats. + +5. **Trust boundaries matter** — Zap receipts are NOT cryptographic proofs of + payment. They prove that a LNURL server claims payment was received. The + trust is in the LNURL server operator, not in the protocol itself. + +## Optimization Notes + +- Preserve the user's requested output shape exactly and do not substitute generic advice for concrete artifacts. +- Include exact commands, code structures, protocol fields, tags, parameters, file paths, or deliverable sections when the task asks for them. +- Make safety gates explicit before irreversible, destructive, externally visible, or compliance-sensitive actions. +- For multi-step work, present steps in execution order and include validation or rollback checks where relevant. +- Avoid overfitting to a single eval example: express lessons as reusable rules, not as task-specific answers. diff --git a/.agents/skills/nostr-zap-integration/evals/evals.json b/.agents/skills/nostr-zap-integration/evals/evals.json new file mode 100644 index 0000000..9c3713f --- /dev/null +++ b/.agents/skills/nostr-zap-integration/evals/evals.json @@ -0,0 +1,56 @@ +{ + "skill_name": "nostr-zap-integration", + "evals": [ + { + "id": 1, + "name": "send-lightning-zap", + "prompt": "I'm building a Nostr client and need to implement sending a zap to a user. The recipient's profile has lud16 set to 'alice@getalby.com'. I want to zap 21 sats on their latest note (event id: 'abc123def456...', kind 1). My pubkey is '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322' and the recipient's pubkey is '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'. Show me the complete implementation including LNURL discovery, zap request construction, and sending to the callback.", + "expected_output": "A complete implementation showing: LNURL endpoint discovery from lud16, verification of allowsNostr and nostrPubkey, construction of a kind:9734 zap request with correct tags (relays, amount in millisats, p, e, lnurl), sending the signed event to the callback URL as a query parameter (NOT publishing to relays), and receiving the bolt11 invoice.", + "files": [], + "assertions": [ + "Output contains 'kind: 9734' or 'kind:9734' or '9734' for the zap request event", + "Output contains 'allowsNostr' to check that the LNURL endpoint supports Nostr zaps", + "Output contains 'nostrPubkey' to verify the LNURL endpoint's signing key", + "Output contains '21000' (21 sats converted to millisats) in the amount tag", + "Output sends the zap request to the 'callback' URL (not publishing to relays)", + "The zap request event tags array has a 'relays' entry with relay URLs", + "Output sends zap request via HTTP GET to the callback URL with query parameters", + "Output constructs the LNURL discovery URL using '.well-known/lnurlp'" + ] + }, + { + "id": 2, + "name": "validate-zap-receipts", + "prompt": "I need to implement zap receipt validation for my Nostr client. When I fetch kind:9735 events, I need to verify they are legitimate. Write me a validation function that takes a zap receipt event and the expected LNURL nostrPubkey, and returns whether the zap is valid along with any errors. Cover all the validation checks from NIP-57 including pubkey matching, amount verification, and description hash checking. Also show me how to extract the sender pubkey and zap comment from a valid receipt.", + "expected_output": "A comprehensive validation function that checks: (1) receipt pubkey matches LNURL nostrPubkey, (2) parses the description tag as JSON to get the embedded zap request, (3) verifies the zap request is kind 9734, (4) checks invoice amount matches request amount, (5) verifies SHA256 of description matches bolt11 description hash. Also extracts sender pubkey from the embedded zap request's pubkey field and comment from its content field.", + "files": [], + "assertions": [ + "Output works with 'kind: 9735' or 'kind:9735' zap receipt events", + "Output validates that the receipt pubkey matches the LNURL nostrPubkey", + "Output parses the 'description' tag as JSON to get the embedded zap request", + "Output checks the 'bolt11' tag for invoice amount verification", + "Output verifies amount in bolt11 matches amount in the zap request", + "Output contains 'SHA256' or 'sha256' for hashing the description to verify against bolt11 description hash", + "Output verifies the embedded zap request is kind 9734", + "Output extracts the sender pubkey from the embedded zap request event" + ] + }, + { + "id": 3, + "name": "nutzap-sending", + "prompt": "I want to implement Nutzap (NIP-61) sending in my Nostr client as an alternative to Lightning zaps. The recipient has published a kind:10019 event. Show me the complete flow: fetching their nutzap config, minting P2PK-locked Cashu tokens at one of their trusted mints, and publishing the kind:9321 nutzap event. Also explain how the recipient would verify and redeem the nutzap. Make sure to handle the P2PK pubkey correctly for nostr-cashu compatibility.", + "expected_output": "Complete implementation showing: (1) fetching and parsing kind:10019 with relay, mint, and pubkey tags, (2) verifying the P2PK pubkey is NOT the user's main Nostr key, (3) minting tokens P2PK-locked with '02' prefix on the pubkey, (4) constructing kind:9321 with proof, u, unit, p tags, (5) publishing to the relays from kind:10019, (6) recipient verification checking mint is trusted and proofs are locked to correct key, (7) redemption via token swap and kind:7376 event.", + "files": [], + "assertions": [ + "Output fetches 'kind: 10019' or 'kind:10019' for the nutzap configuration", + "Output constructs 'kind: 9321' or 'kind:9321' nutzap events", + "Output contains 'P2PK' for locking the Cashu tokens", + "Output contains '02' prefix for the P2PK pubkey (compressed public key format)", + "Output warns that the P2PK pubkey must NOT be the user's main Nostr pubkey", + "The nutzap event has 'proof' tags carrying Cashu token data", + "The mint URL is fetched from the recipient's kind:10019 event", + "Output explains redemption including 'kind: 7376' or 'kind:7376' or token swap" + ] + } + ] +} diff --git a/.agents/skills/nostr-zap-integration/references/nutzap-flow.md b/.agents/skills/nostr-zap-integration/references/nutzap-flow.md new file mode 100644 index 0000000..6f878d4 --- /dev/null +++ b/.agents/skills/nostr-zap-integration/references/nutzap-flow.md @@ -0,0 +1,332 @@ +# NIP-61 Nutzap Flow + +Complete step-by-step reference for implementing Cashu-based Nutzaps in Nostr +applications. + +## Overview + +Nutzaps are an alternative to Lightning Zaps that use Cashu ecash tokens. The +payment itself is the receipt — no LNURL server needed. Tokens are P2PK-locked +to a recipient-specified public key and published as Nostr events. + +## Protocol Flow + +``` +Sender Cashu Mint Relays + | | | + |-- Fetch kind:10019 -------|--------------------->| + |<-- {mints, pubkey, relays}| | + | | | + |-- Mint P2PK token ------->| | + |<-- {proofs} --------------| | + | | | + |-- Publish kind:9321 ------|--------------------->| + | | | + | Recipient | + | |-- Fetch kind:9321 ------>| + | |<-- {proofs} -------------| + | | | + | |-- Swap token ----------->| + | |<-- {new proofs} ---------| + | | | + | |-- Publish kind:7376 ---->| +``` + +## Step 1: Fetch Recipient's Configuration (kind:10019) + +Query for the recipient's nutzap informational event: + +```typescript +const filter = { + kinds: [10019], + authors: [recipientPubkey], +}; +``` + +### Parse the Configuration + +```typescript +interface NutzapConfig { + relays: string[]; + mints: { url: string; units: string[] }[]; + p2pkPubkey: string; +} + +function parseNutzapConfig(event: NostrEvent): NutzapConfig { + const relays = event.tags + .filter((t) => t[0] === "relay") + .map((t) => t[1]); + + const mints = event.tags + .filter((t) => t[0] === "mint") + .map((t) => ({ + url: t[1], + units: t.slice(2), // Additional elements are supported units + })); + + const pubkeyTag = event.tags.find((t) => t[0] === "pubkey"); + if (!pubkeyTag) throw new Error("No pubkey tag in kind:10019"); + + return { + relays, + mints, + p2pkPubkey: pubkeyTag[1], + }; +} +``` + +### Validation Checks + +- At least one `relay` tag must be present +- At least one `mint` tag must be present +- `pubkey` tag MUST be present +- `pubkey` value MUST NOT equal the event's `.pubkey` (the user's main key) +- Mints SHOULD support NUT-11 (P2PK) and NUT-12 (DLEQ proofs) + +## Step 2: Mint P2PK-Locked Tokens + +### Choose a Mint + +Select a mint from the recipient's `mint` tags. Check that it supports the +desired unit (e.g., "sat"): + +```typescript +function selectMint(config: NutzapConfig, unit: string = "sat"): string { + const compatible = config.mints.filter( + (m) => m.units.length === 0 || m.units.includes(unit), + ); + if (compatible.length === 0) { + throw new Error(`No mints support unit: ${unit}`); + } + return compatible[0].url; +} +``` + +### Mint or Swap Tokens + +Use a Cashu library (e.g., `@cashu/cashu-ts`) to mint tokens P2PK-locked to the +recipient's pubkey: + +```typescript +import { CashuMint, CashuWallet } from "@cashu/cashu-ts"; + +async function mintNutzapTokens( + mintUrl: string, + amountSats: number, + recipientP2pkPubkey: string, +): Promise { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + + // CRITICAL: Prefix pubkey with "02" for nostr<>cashu compatibility + const lockPubkey = recipientP2pkPubkey.startsWith("02") + ? recipientP2pkPubkey + : `02${recipientP2pkPubkey}`; + + // Mint tokens with P2PK lock + const { proofs } = await wallet.mintTokens(amountSats, { + p2pkPubkey: lockPubkey, + includeDleq: true, // NUT-12: Include DLEQ proofs + }); + + return proofs; +} +``` + +**Critical rules:** + +- Always prefix the P2PK pubkey with `"02"` (compressed key format) +- Always request DLEQ proofs (NUT-12) for verifiability +- Only use mints listed in the recipient's kind:10019 +- The mint URL in the nutzap MUST match EXACTLY as listed in kind:10019 + +## Step 3: Construct and Publish the Nutzap (kind:9321) + +```typescript +function buildNutzap(params: { + senderPubkey: string; + recipientPubkey: string; // Nostr identity pubkey, NOT P2PK key + proofs: CashuProof[]; + mintUrl: string; + unit?: string; + eventId?: string; + eventKind?: number; + relayHint?: string; + comment?: string; +}): Omit { + const tags: string[][] = []; + + // Add each proof as a separate tag + for (const proof of params.proofs) { + tags.push(["proof", JSON.stringify(proof)]); + } + + tags.push(["u", params.mintUrl]); + tags.push(["unit", params.unit || "sat"]); + tags.push(["p", params.recipientPubkey]); + + if (params.eventId) { + const eTag = ["e", params.eventId]; + if (params.relayHint) eTag.push(params.relayHint); + tags.push(eTag); + } + + if (params.eventKind !== undefined) { + tags.push(["k", params.eventKind.toString()]); + } + + return { + kind: 9321, + content: params.comment || "", + tags, + pubkey: params.senderPubkey, + created_at: Math.floor(Date.now() / 1000), + }; +} +``` + +### Tag Reference for kind:9321 + +| Tag | Required | Format | Notes | +| ------- | -------- | -------------------------------- | ------------------------------ | +| `proof` | YES | `["proof", ""]` | One or more proof tags | +| `u` | YES | `["u", ""]` | EXACT match to kind:10019 | +| `unit` | NO | `["unit", "sat"]` | Default: "sat" if omitted | +| `p` | YES | `["p", ""]` | Recipient's Nostr identity key | +| `e` | NO | `["e", "", ""]` | Event being nutzapped | +| `k` | NO | `["k", ""]` | Kind of nutzapped event | + +### Publish to Correct Relays + +Publish the kind:9321 event to the relays listed in the recipient's kind:10019 +`relay` tags. Failure to publish to these relays means the recipient may never +see the nutzap. + +## Step 4: Receiving Nutzaps + +### Query for Incoming Nutzaps + +```typescript +function buildNutzapFilter( + myPubkey: string, + trustedMints: string[], + since?: number, +): NostrFilter { + return { + kinds: [9321], + "#p": [myPubkey], + "#u": trustedMints, // Only from mints we trust + ...(since ? { since } : {}), + }; +} +``` + +### Process and Redeem + +```typescript +async function redeemNutzap( + nutzapEvent: NostrEvent, + wallet: CashuWallet, +): Promise { + // Extract proofs from the event + const proofTags = nutzapEvent.tags.filter((t) => t[0] === "proof"); + const proofs = proofTags.map((t) => JSON.parse(t[1])); + + // Verify mint URL matches a trusted mint + const mintUrl = nutzapEvent.tags.find((t) => t[0] === "u")?.[1]; + if (!mintUrl) throw new Error("Missing mint URL"); + + // Swap tokens into our wallet (this claims them) + const newProofs = await wallet.receive(proofs); + + return newProofs; +} +``` + +### Record Redemption (kind:7376) + +After successfully swapping tokens, publish a kind:7376 event to record the +redemption: + +```typescript +function buildRedemptionRecord(params: { + nutzapEventId: string; + nutzapRelayHint?: string; + senderPubkey: string; + amount: string; + unit: string; + newTokenEventId?: string; + newTokenRelayHint?: string; +}): Omit { + // Content is NIP-44 encrypted + const contentTags = [ + ["direction", "in"], + ["amount", params.amount], + ["unit", params.unit], + ]; + + if (params.newTokenEventId) { + const tag = ["e", params.newTokenEventId]; + if (params.newTokenRelayHint) tag.push(params.newTokenRelayHint); + tag.push("created"); + contentTags.push(tag); + } + + return { + kind: 7376, + content: nip44Encrypt(JSON.stringify(contentTags)), // NIP-44 encrypted + tags: [ + ["e", params.nutzapEventId, params.nutzapRelayHint || "", "redeemed"], + ["p", params.senderPubkey], + ], + }; +} +``` + +## Step 5: Verifying Nutzaps (Observer) + +Clients displaying nutzap counts or amounts should verify: + +```typescript +function verifyNutzap( + nutzap: NostrEvent, + recipientConfig: NostrEvent, // kind:10019 +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + const config = parseNutzapConfig(recipientConfig); + + // 1. Check mint is in recipient's trusted list + const mintUrl = nutzap.tags.find((t) => t[0] === "u")?.[1]; + if (!mintUrl || !config.mints.some((m) => m.url === mintUrl)) { + errors.push("Mint not in recipient's trusted mint list"); + } + + // 2. Check proofs are locked to the correct pubkey + const proofTags = nutzap.tags.filter((t) => t[0] === "proof"); + for (const proofTag of proofTags) { + try { + const proof = JSON.parse(proofTag[1]); + const secret = JSON.parse(proof.secret); + if (secret[0] === "P2PK") { + const lockedTo = secret[1].data; + const expectedKey = config.p2pkPubkey.startsWith("02") + ? config.p2pkPubkey + : `02${config.p2pkPubkey}`; + if (lockedTo !== expectedKey) { + errors.push("Proof not locked to recipient's P2PK pubkey"); + } + } + } catch { + errors.push("Invalid proof format"); + } + } + + // 3. Verify DLEQ proofs (offline verification) + // This requires the mint's keyset - implementation depends on Cashu library + + return { valid: errors.length === 0, errors }; +} +``` + +All verification can be done offline (given the mint's keyset and the +recipient's kind:10019), making it fast and scalable. diff --git a/.agents/skills/nostr-zap-integration/references/zap-flow.md b/.agents/skills/nostr-zap-integration/references/zap-flow.md new file mode 100644 index 0000000..54f7fc5 --- /dev/null +++ b/.agents/skills/nostr-zap-integration/references/zap-flow.md @@ -0,0 +1,370 @@ +# NIP-57 Lightning Zap Flow + +Complete step-by-step reference for implementing Lightning Zaps in Nostr +applications. + +## Protocol Flow Overview + +``` +Sender Client LNURL Server Relays Lightning + | | | | + |-- GET lnurlp/user --->| | | + |<-- {allowsNostr, ...}-| | | + | | | | + |-- Sign kind:9734 ---->| | | + | (zap request) | | | + | | | | + |-- GET callback?nostr= | | | + |<-- {pr: bolt11} ------| | | + | | | | + |-- Pay invoice --------|-------------------|------> | + | | | | + | |-- kind:9735 ----->| | + | | (zap receipt) | | + | | | | + |<-- Fetch kind:9735 ---|-------------------| | +``` + +## Step 1: Discover the LNURL Endpoint + +### From lud16 (Lightning Address) + +```typescript +function getLnurlPayUrl(lud16: string): string { + const [name, domain] = lud16.split("@"); + return `https://${domain}/.well-known/lnurlp/${name}`; +} + +// Example: "bob@walletofsatoshi.com" +// → "https://walletofsatoshi.com/.well-known/lnurlp/bob" +``` + +### From zap tag on an event + +If the event has `zap` tags, use those instead of the author's profile: + +```typescript +function getZapRecipients(event: NostrEvent): ZapRecipient[] { + const zapTags = event.tags.filter((t) => t[0] === "zap"); + if (zapTags.length === 0) return []; // Fall back to event author + + const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0); + + return zapTags.map((tag) => ({ + pubkey: tag[1], + relay: tag[2], + weight: Number(tag[3] || 0), + percentage: totalWeight > 0 ? Number(tag[3] || 0) / totalWeight : 0, + })); +} +``` + +### Verify Nostr Support + +```typescript +interface LnurlPayResponse { + callback: string; + maxSendable: number; // millisats + minSendable: number; // millisats + metadata: string; + allowsNostr?: boolean; + nostrPubkey?: string; // 32-byte hex +} + +async function verifyNostrZapSupport( + lnurlPayUrl: string, +): Promise { + const res = await fetch(lnurlPayUrl); + const data: LnurlPayResponse = await res.json(); + + if (!data.allowsNostr) { + throw new Error("LNURL endpoint does not support Nostr zaps"); + } + if (!data.nostrPubkey || data.nostrPubkey.length !== 64) { + throw new Error("Invalid or missing nostrPubkey"); + } + + return data; +} +``` + +## Step 2: Construct the Zap Request (kind:9734) + +### Required Structure + +```typescript +interface ZapRequest { + kind: 9734; + content: string; // Optional message + tags: string[][]; + pubkey: string; // Sender's pubkey + created_at: number; + id: string; + sig: string; +} + +function buildZapRequest(params: { + senderPubkey: string; + recipientPubkey: string; + amountMsats: number; + relays: string[]; + lnurl?: string; + eventId?: string; + eventKind?: number; + addressableCoord?: string; // "kind:pubkey:d-tag" + comment?: string; +}): Omit { + const tags: string[][] = [ + ["relays", ...params.relays], + ["amount", params.amountMsats.toString()], + ["p", params.recipientPubkey], + ]; + + if (params.lnurl) tags.push(["lnurl", params.lnurl]); + if (params.eventId) tags.push(["e", params.eventId]); + if (params.addressableCoord) tags.push(["a", params.addressableCoord]); + if (params.eventKind !== undefined) { + tags.push(["k", params.eventKind.toString()]); + } + + return { + kind: 9734, + content: params.comment || "", + tags, + pubkey: params.senderPubkey, + created_at: Math.floor(Date.now() / 1000), + }; +} +``` + +### Tag Reference + +| Tag | Required | Format | Notes | +| -------- | ----------- | ------------------------------------ | --------------------- | +| `relays` | YES | `["relays", "wss://r1", "wss://r2"]` | NOT nested arrays | +| `p` | YES | `["p", ""]` | Exactly one | +| `amount` | Recommended | `["amount", "21000"]` | Millisats as string | +| `lnurl` | Recommended | `["lnurl", "lnurl1..."]` | Bech32-encoded | +| `e` | Optional | `["e", ""]` | When zapping an event | +| `a` | Optional | `["a", "30023:pubkey:d-tag"]` | Addressable events | +| `k` | Optional | `["k", "1"]` | Kind of zapped event | + +### Validation Rules for Zap Requests + +The LNURL server validates incoming zap requests: + +1. Valid Nostr signature +2. Has tags +3. Exactly one `p` tag +4. Zero or one `e` tags +5. Has a `relays` tag +6. If `amount` tag exists, it MUST equal the `amount` query parameter +7. If `a` tag exists, it MUST be a valid event coordinate +8. Zero or one `P` tags + +## Step 3: Send to Callback URL + +```typescript +async function requestInvoice( + callback: string, + signedZapRequest: ZapRequest, + amountMsats: number, + lnurl?: string, +): Promise { + const params = new URLSearchParams({ + amount: amountMsats.toString(), + nostr: JSON.stringify(signedZapRequest), + }); + + if (lnurl) params.set("lnurl", lnurl); + + const res = await fetch(`${callback}?${params.toString()}`); + const data = await res.json(); + + if (data.status === "ERROR") { + throw new Error(`LNURL error: ${data.reason}`); + } + + return data.pr; // bolt11 invoice +} +``` + +**Critical:** The zap request is JSON-encoded, then sent as a query parameter. +It is NOT published to any relay. + +## Step 4: Pay the Invoice + +Pass the bolt11 invoice string to a Lightning wallet or payment library. This +step is outside the Nostr protocol — use whatever Lightning integration your +application supports (WebLN, NWC, direct LND/CLN API, etc.). + +## Step 5: Zap Receipt Creation (Server-Side) + +After the invoice is paid, the recipient's LNURL server creates a kind:9735 +event: + +```typescript +function buildZapReceipt(params: { + serverPubkey: string; // The LNURL server's nostrPubkey + zapRequest: ZapRequest; + bolt11: string; + preimage?: string; + paidAt: number; +}): Omit { + const zapReq = params.zapRequest; + const recipientPubkey = zapReq.tags.find((t) => t[0] === "p")?.[1]; + const senderPubkey = zapReq.pubkey; + const eventId = zapReq.tags.find((t) => t[0] === "e")?.[1]; + const aTag = zapReq.tags.find((t) => t[0] === "a"); + + const tags: string[][] = [ + ["p", recipientPubkey!], + ["P", senderPubkey], + ["bolt11", params.bolt11], + ["description", JSON.stringify(zapReq)], + ]; + + if (eventId) tags.push(["e", eventId]); + if (aTag) tags.push(aTag); + if (params.preimage) tags.push(["preimage", params.preimage]); + + // Amount from the zap request + const amount = zapReq.tags.find((t) => t[0] === "amount")?.[1]; + if (amount) tags.push(["amount", amount]); + + return { + kind: 9735, + content: "", + tags, + pubkey: params.serverPubkey, + created_at: params.paidAt, + }; +} +``` + +The receipt is published to the relays specified in the zap request's `relays` +tag. + +## Step 6: Validate Zap Receipts (Client-Side) + +```typescript +interface ZapValidationResult { + valid: boolean; + errors: string[]; + zapRequest?: ZapRequest; + amountMsats?: number; + senderPubkey?: string; + comment?: string; +} + +function validateZapReceipt( + receipt: NostrEvent, + expectedNostrPubkey: string, +): ZapValidationResult { + const errors: string[] = []; + + // 1. Check receipt pubkey matches LNURL nostrPubkey + if (receipt.pubkey !== expectedNostrPubkey) { + errors.push( + `Receipt pubkey ${receipt.pubkey} does not match expected ${expectedNostrPubkey}`, + ); + } + + // 2. Parse the embedded zap request + const descriptionTag = receipt.tags.find((t) => t[0] === "description"); + if (!descriptionTag) { + errors.push("Missing description tag"); + return { valid: false, errors }; + } + + let zapRequest: ZapRequest; + try { + zapRequest = JSON.parse(descriptionTag[1]); + } catch { + errors.push("Invalid JSON in description tag"); + return { valid: false, errors }; + } + + if (zapRequest.kind !== 9734) { + errors.push(`Zap request kind is ${zapRequest.kind}, expected 9734`); + } + + // 3. Verify amount matches + const bolt11Tag = receipt.tags.find((t) => t[0] === "bolt11"); + const requestAmount = zapRequest.tags.find((t) => t[0] === "amount")?.[1]; + + if (bolt11Tag && requestAmount) { + const invoiceAmount = decodeBolt11Amount(bolt11Tag[1]); + if (invoiceAmount !== Number(requestAmount)) { + errors.push( + `Invoice amount ${invoiceAmount} does not match request amount ${requestAmount}`, + ); + } + } + + // 4. Verify description hash (SHOULD check) + if (bolt11Tag) { + const descHash = sha256(descriptionTag[1]); + const bolt11DescHash = extractDescriptionHash(bolt11Tag[1]); + if (bolt11DescHash && descHash !== bolt11DescHash) { + errors.push("Description hash mismatch"); + } + } + + return { + valid: errors.length === 0, + errors, + zapRequest, + amountMsats: requestAmount ? Number(requestAmount) : undefined, + senderPubkey: zapRequest.pubkey, + comment: zapRequest.content || undefined, + }; +} +``` + +### Fetching Zap Receipts + +```typescript +// Zaps on a specific event +const filter = { kinds: [9735], "#e": [eventId] }; + +// Zaps on a user profile +const filter = { kinds: [9735], "#p": [pubkey] }; +``` + +## Zap Splits Implementation + +When an event has multiple `zap` tags: + +```typescript +function calculateZapSplits( + event: NostrEvent, + totalAmountMsats: number, +): { pubkey: string; relay: string; amountMsats: number }[] { + const zapTags = event.tags.filter((t) => t[0] === "zap"); + if (zapTags.length === 0) return []; + + const totalWeight = zapTags.reduce((sum, t) => sum + Number(t[3] || 0), 0); + + if (totalWeight === 0) { + // No weights: divide equally + const perRecipient = Math.floor(totalAmountMsats / zapTags.length); + return zapTags.map((t) => ({ + pubkey: t[1], + relay: t[2], + amountMsats: perRecipient, + })); + } + + return zapTags + .filter((t) => Number(t[3] || 0) > 0) // Skip zero-weight recipients + .map((t) => ({ + pubkey: t[1], + relay: t[2], + amountMsats: Math.floor(totalAmountMsats * Number(t[3]) / totalWeight), + })); +} +``` + +Each split recipient gets a separate zap request → callback → invoice → payment +cycle. diff --git a/skills-lock.json b/skills-lock.json index b48e906..518ef9d 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -18,6 +18,12 @@ "sourceType": "github", "skillPath": "skills/nostr/SKILL.md", "computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e" + }, + "nostr-zap-integration": { + "source": "accolver/skill-maker", + "sourceType": "github", + "skillPath": "nostr-zap-integration/SKILL.md", + "computedHash": "5c0c9a97fb74b6b620340bdc522fc3e813dae34a2af87cfda764733d2632229c" } } }