Files
welshman/docs/domain/zaps.md
T
hodlbod 5b8fef5b23
tests / tests (push) Failing after 5m8s
Fix NIP conformance in domain kinds; add domain docs/skill
NIP fixes:
- RelayMembers (13534): use NIP-43 `member` tags (not `p`) and set the required
  NIP-70 `-` protected tag.
- Profile (kind 0): remove display-name support entirely (getter, setter, display()
  fallback, and the search weight).
- Comment (1111): A/a tags now carry a real address, not the event id.
- BlossomServerList (10063): normalize server URLs with normalizeUrl (HTTP), not
  normalizeRelayUrl (which forced wss://).
- HandlerRecommendation (31989): fix inverted removeRecommendation filter; add
  setSupportedKind()/supportedKind() for the NIP-89 d-tag.
- Report (1984): place the report-type string on the e tag (note reports) or p tag
  (profile reports); always emit the p tag.

Docs/skills:
- Add @welshman/domain docs (docs/domain/) and the welshman-domain skill.
- Prune @welshman/util docs/skill of the moved Profile/List/Handler/Encryptable
  helpers; register domain in the sidebar, index, and skills README.
- Apply accuracy fixes to the @welshman/app docs/skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsMjvv7krpZeHK1Njeneru
2026-06-20 14:55:21 +00:00

3.7 KiB

Zaps

The lightning-zap flow (NIP-57) and zap goals (NIP-75) are modeled by three kinds: ZapRequest (what a sender publishes to ask for a zap), ZapReceipt (what the recipient's LN service publishes as proof of payment), and ZapGoal (a fundraising target). All three are plain EventReader / EventBuilder subclasses — see Readers & Builders for the base pattern.

Zap request (kind 9734)

The zap request you send to a recipient's LNURL callback. The comment is the event content; everything else is tags.

import {ZapRequest, ZapRequestBuilder} from "@welshman/domain"

const req = await ZapRequest.fromEvent(event)
req.amount()       // millisats as an int, or undefined ("amount" tag)
req.lnurl()        // string | undefined
req.recipient()    // p-tag value
req.eventId()      // e-tag value (the zapped event)
req.urls()         // the "relays" tag, sliced past the key
req.comment()      // event.content

const template = await new ZapRequestBuilder()
  .setAmount(21000)                         // millisats
  .setRecipient(recipientPubkey)
  .setLnurl(lnurl)
  .setEventId(zappedNoteId)
  .setUrls(["wss://relay.example"])         // ["relays", ...urls]
  .setComment("great post")
  .toTemplate()

buildTags always emits a relays tag (bare if you never called setUrls).

Zap receipt (kind 9735)

The receipt is generated by the recipient's lightning service, so it is effectively read-only in practice. parse decodes the embedded zap request out of the description tag into plain, which the getters read through.

import {ZapReceipt} from "@welshman/domain"

const receipt = await ZapReceipt.fromEvent(event)
receipt.bolt11()          // the invoice
receipt.invoiceAmount()   // amount parsed from bolt11, or undefined on parse failure
receipt.request()         // the embedded zap-request event (TrustedEvent | undefined)
receipt.sender()          // request.pubkey
receipt.recipient()       // p-tag value
receipt.eventId()         // e-tag value
receipt.comment()         // the embedded request's content
receipt.preimage()        // string | undefined

The important method is verify(zapper), which validates the receipt against a Zapper (the recipient's LNURL zapper info from @welshman/util). It checks that the request is present, that the invoice amount matches the requested amount, that the sender is not the zapper itself, and that the recipient / lnurl / nostr pubkey are consistent. It returns a boolean.

import type {Zapper} from "@welshman/util"

const ok: boolean = receipt.verify(zapper)

A ZapReceiptBuilder exists (setBolt11, setDescription, setRecipient, setEventId, setPreimage) for completeness — e.g. tests or a service generating receipts — but you rarely construct these by hand.

Zap goal (kind 9041)

A fundraising target. The title is the content; the goal amount and relays are tags.

import {ZapGoal, ZapGoalBuilder} from "@welshman/domain"

const goal = await ZapGoal.fromEvent(event)
goal.title()      // event.content (or "")
goal.summary()    // "summary" tag value
goal.amount()     // millisats as an int, default 0
goal.urls()       // "relays" tag values

const template = await new ZapGoalBuilder()
  .setTitle("Fund the relay")
  .setSummary("keeps the lights on")
  .setAmount(1000000)                       // millisats
  .setUrls(["wss://relay.example"])         // one ["relays", url] per url
  .toTemplate()

validate() requires a title (throws ZapGoal requires a title), and buildTags always emits an amount tag (defaulting to ["amount", "0"]).

See also