Files
client/plans/nostr-entities-and-zaps.md
2026-04-17 16:52:51 -04:00

10 KiB

Nostr Entity Decoding & Zaps Implementation Plan

Overview

Two features for post.html and the post-interactions module:

  1. Nostr Entity Decoding — Detect and render nostr: prefixed bech32 entities (nprofile, nevent, naddr, note, npub) embedded in post content as clickable cards/links
  2. Zaps — Full NIP-57 zap flow: resolve LNURL from recipient profile, create zap request, fetch bolt11 invoice, pay via Cashu wallet

Key NDK Source References

The NDK codebase already has robust implementations we can leverage:

  • Entity regex: content-tagger.ts/@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g
  • Entity decoding: entity.tsgetEntity() decodes nip19 and returns NDK objects
  • LNURL resolution: ln.tsgetNip57ZapSpecFromLud() handles lud16→endpoint and lud06→endpoint
  • Zap request creation: nip57.tsgenerateZapRequest() builds and signs kind 9734
  • Invoice fetching: index.tsgetLnInvoice() calls LNURL callback with zap request
  • Zap invoice parsing: invoice.tszapInvoiceFromEvent() parses kind 9735 with bolt11 decoder
  • NDK bundle exports: All of these are exported from ndk-core.bundle.js: getNip57ZapSpecFromLud, generateZapRequest, zapInvoiceFromEvent, NDKZapper, getBolt11Amount

Feature 1: Nostr Entity Decoding

Current State

  • htmlFormatText() only handles HTTP URLs and image extensions
  • renderPostItem() calls htmlFormatText() to render post content
  • window.NostrTools.nip19.decode() is available globally via nostr.bundle.js and supports all bech32 entity types: npub, note, nprofile, nevent, naddr, nrelay
  • No current handling of nostr: prefixed strings in post content

Example Input

Check out nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyd968gmewwp6kyqpqjqhfpx394z4l3zr4zs77zgztsjpy4z6q48q6npxdakv5elyrhaws5cavn4

Entity Types to Handle

Prefix NIP-19 Type Decoded Data Render As
nostr:npub1... npub { type: 'npub', data: hexPubkey } Profile link with name/avatar
nostr:nprofile1... nprofile { type: 'nprofile', data: { pubkey, relays } } Profile link with name/avatar
nostr:note1... note { type: 'note', data: hexEventId } Embedded post card
nostr:nevent1... nevent { type: 'nevent', data: { id, relays, author, kind } } Embedded post card
nostr:naddr1... naddr { type: 'naddr', data: { identifier, pubkey, kind, relays } } Link to addressable event

Implementation Plan

flowchart TD
    A[Post content string] --> B[htmlFormatText]
    B --> C{Regex: nostr:n...1...}
    C -->|Match| D[nip19.decode]
    D --> E{Entity type?}
    E -->|npub/nprofile| F[Render profile mention link]
    E -->|note/nevent| G[Render embedded post placeholder]
    E -->|naddr| H[Render addressable event link]
    C -->|No match| I[Existing URL/image handling]
    F --> J[Async: fetch profile, update DOM]
    G --> K[Async: fetch event, render inline card]

Step 1: Enhance htmlFormatText() in utilities.mjs

Add a regex pass for nostr: entities before the URL regex pass:

/nostr:(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+/gi

For each match:

  • Call window.NostrTools.nip19.decode(entityWithoutNostrPrefix)
  • Replace with an HTML placeholder element containing a data-nostr-entity attribute and the bech32 string
  • Profile mentions (npub/nprofile): render as <a> link to post.html?npub=...&profile=true&post=false
  • Event references (note/nevent): render as a <div class="nostr-embed-placeholder"> with data-event-id
  • Addressable events (naddr): render as <a> link

Step 2: Post-render async hydration in post-interactions.mjs

After contentEl.innerHTML = htmlFormatText(...):

  • Query all .nostr-embed-placeholder elements
  • For profile mentions: fetch profile via fetchProfile(), update display name and avatar
  • For event embeds: fetch the referenced event via fetchEventsFromAllRelays(), render as an inline mini-post card

Step 3: CSS for embedded entities

Add styles for:

  • .nostr-mention — inline profile mention with avatar thumbnail
  • .nostr-embed — embedded post card with border, content preview, author

Feature 2: Zaps (NIP-57)

Current State

  • handleZapClick() shows "Coming soon" placeholder
  • extractZapAmount() returns 0 — needs BOLT11 parsing
  • extractZapComment() works correctly
  • Zap receipt processing (kind 9735) already works in the interaction state tracker
  • walletPayInvoice() exists in init-ndk.mjs — pays bolt11 via Cashu wallet
  • light-bolt11-decoder is bundled inside ndk-core.bundle.js but not directly exported
  • NDK bundle includes NDKZapper class with full NIP-57 flow, but it operates on NDK instances (worker-side)
  • Profile lud16 field is available from kind 0 metadata

Zap Flow (NIP-57)

sequenceDiagram
    participant U as User
    participant PI as post-interactions.mjs
    participant P as Profile Cache
    participant LN as LNURL Server
    participant W as Cashu Wallet
    participant R as Relays

    U->>PI: Click Z button
    PI->>PI: Show amount selector popup
    U->>PI: Select amount
    PI->>P: Get recipient lud16/lud06
    P-->>PI: lud16 = user@domain.com
    PI->>LN: GET https://domain.com/.well-known/lnurlp/user
    LN-->>PI: LNURL-pay metadata with callback, allowsNostr, nostrPubkey
    PI->>PI: Create kind 9734 zap request event
    PI->>PI: Sign zap request via window.nostr.signEvent
    PI->>LN: GET callback?amount=X&nostr=zapRequestJSON&lnurl=...
    LN-->>PI: bolt11 invoice
    PI->>W: walletPayInvoice with bolt11
    W-->>PI: Payment result
    PI->>PI: Update Z icon to active state
    Note over R: Zap receipt kind 9735 arrives via subscription
    R-->>PI: Zap receipt event
    PI->>PI: Update zap count display

Implementation Steps

Step 1: LNURL Resolution

Port the pattern from getNip57ZapSpecFromLud() to a main-thread helper:

  • Parse lud16 format: user@domainhttps://domain/.well-known/lnurlp/user
  • Also handle lud06 (bech32 encoded LNURL) — NDK uses bech32.decode() from @scure/base
  • Fetch the LNURL-pay endpoint metadata
  • Validate response: check allowsNostr: true, nostrPubkey field
  • Return { callback, minSendable, maxSendable, nostrPubkey, allowsNostr }

Note: The NDK bundle exports getNip57ZapSpecFromLud but it requires an NDK instance. Since our NDK runs in a worker, we'll implement a lightweight version on the main thread that just does the HTTP fetch.

Step 2: Zap Request Creation (kind 9734)

Port the pattern from generateZapRequest():

  • Build kind 9734 event with tags:
    • ['relays', ...relayUrls] (up to 4 relays)
    • ['amount', amountInMillisats.toString()]
    • ['lnurl', callback]
    • ['e', postId] (the event being zapped)
    • ['p', postPubkey] (the recipient)
  • Set content to optional comment
  • Sign via window.nostr.signEvent()

Step 3: Fetch Bolt11 Invoice

Port the pattern from getLnInvoice():

  • Build URL: callback?amount=X&nostr=JSON.stringify(signedZapRequest)
  • Fetch and parse response for { pr: bolt11Invoice }

Step 4: Payment via Cashu Wallet

Update handleZapClick():

  • Show amount selector UI (preset amounts: 21, 100, 500, 1000, 5000 sats)
  • Resolve LNURL from recipient profile
  • Create and sign zap request
  • Fetch bolt11 invoice
  • Call walletPayInvoice(bolt11) from init-ndk.mjs
  • Handle success/failure states in UI

Step 5: Fix extractZapAmount()

Port the pattern from zapInvoiceFromEvent() which uses light-bolt11-decoder:

  • The NDK bundle includes getSatoshisAmountFromBolt11() which parses the HRP
  • For our main-thread code, implement a simple HRP parser:
    • Parse the prefix after lnbc to extract amount and multiplier
    • Multipliers: m = milli (0.001), u = micro (0.000001), n = nano, p = pico

Step 6: Zap Amount Selector UI

Create a small popup/modal:

  • Preset buttons: 21, 100, 500, 1K, 5K
  • Custom amount input field
  • Optional comment field
  • Send button
  • Shows wallet balance from Cashu
  • Styled consistently with existing .divPostItem design

Files to Modify

File Changes
www/js/utilities.mjs Enhance htmlFormatText() with nostr: entity regex and rendering
www/js/post-interactions.mjs Add async entity hydration after render; implement full zap flow in handleZapClick(); fix extractZapAmount(); add zap amount selector UI
www/css/client.css Add styles for .nostr-mention, .nostr-embed, .zap-selector
www/post.html Import walletPayInvoice from init-ndk.mjs; pass to interactions init

Dependencies

  • window.NostrTools.nip19.decode() — already available
  • walletPayInvoice() — already available in init-ndk.mjs
  • fetchEventsFromAllRelays() — already available
  • fetchCachedProfile() / fetchProfile() — already available
  • window.nostr.signEvent() — available via NIP-07 signer
  • LNURL resolution requires CORS-friendly lightning address servers (standard for most)

Risks & Considerations

  • CORS on LNURL servers: Most lightning address providers support CORS, but some may not. Consider a fallback message.
  • Cashu wallet balance: User needs sufficient balance. Show balance in zap selector and handle insufficient funds gracefully.
  • Recursive embeds: A nostr:nevent could reference a post that itself contains nostr: entities. Limit embed depth to 1 level.
  • Performance: Fetching referenced events for embeds should be non-blocking. Show placeholder first, hydrate async.
  • BOLT11 parsing: The simple HRP-based parser handles most invoices but edge cases exist. The light-bolt11-decoder in the NDK bundle is more robust but not directly accessible from the main thread.