From 50b85a5ce55bc6096993453f0ea564603d8803e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 12:52:07 +0000 Subject: [PATCH] feat: add NIP-61 nutzap support to NIP-29 groups Fetch and render nutzap events (kind 9321) in NIP-29 relay groups using the same visual styling as lightning zaps. Nutzaps are P2PK locked Cashu token transfers defined in NIP-61. - Add nutzap filter subscription in loadMessages - Combine chat and nutzap observables with RxJS combineLatest - Add nutzapToMessage helper to parse NIP-61 event structure - Extract amount by summing proof amounts from proof tag JSON - Add nutzapUnit metadata field for future multi-currency support --- src/lib/chat/adapters/nip-29-adapter.ts | 125 ++++++++++++++++++++---- src/types/chat.ts | 2 + 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 569113b..f576fef 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -1,4 +1,4 @@ -import { Observable } from "rxjs"; +import { Observable, combineLatest } from "rxjs"; import { map, first } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; @@ -316,32 +316,38 @@ export class Nip29Adapter extends ChatProtocolAdapter { // kind 9: chat messages // kind 9000: put-user (admin adds user) // kind 9001: remove-user (admin removes user) - const filter: Filter = { + const chatFilter: Filter = { kinds: [9, 9000, 9001], "#h": [groupId], limit: options?.limit || 50, }; + // Filter for nutzaps (kind 9321) targeting this group + const nutzapFilter: Filter = { + kinds: [9321], + "#h": [groupId], + limit: options?.limit || 50, + }; + if (options?.before) { - filter.until = options.before; + chatFilter.until = options.before; + nutzapFilter.until = options.before; } if (options?.after) { - filter.since = options.after; + chatFilter.since = options.after; + nutzapFilter.since = options.after; } - // Start a persistent subscription to the group relay - // This will feed new messages into the EventStore in real-time + // Start persistent subscriptions for both chat and nutzaps pool - .subscription([relayUrl], [filter], { - eventStore, // Automatically add to store + .subscription([relayUrl], [chatFilter], { + eventStore, }) .subscribe({ next: (response) => { if (typeof response === "string") { - // EOSE received console.log("[NIP-29] EOSE received for messages"); } else { - // Event received console.log( `[NIP-29] Received message: ${response.id.slice(0, 8)}...`, ); @@ -349,13 +355,42 @@ export class Nip29Adapter extends ChatProtocolAdapter { }, }); - // Return observable from EventStore which will update automatically - return eventStore.timeline(filter).pipe( - map((events) => { - console.log(`[NIP-29] Timeline has ${events.length} messages`); - return events - .map((event) => this.eventToMessage(event, conversation.id)) - .sort((a, b) => a.timestamp - b.timestamp); // Oldest first for flex-col-reverse + pool + .subscription([relayUrl], [nutzapFilter], { + eventStore, + }) + .subscribe({ + next: (response) => { + if (typeof response === "string") { + console.log("[NIP-29] EOSE received for nutzaps"); + } else { + console.log( + `[NIP-29] Received nutzap: ${response.id.slice(0, 8)}...`, + ); + } + }, + }); + + // Combine chat messages and nutzaps from EventStore + const chatMessages$ = eventStore.timeline(chatFilter); + const nutzapMessages$ = eventStore.timeline(nutzapFilter); + + return combineLatest([chatMessages$, nutzapMessages$]).pipe( + map(([chatEvents, nutzapEvents]) => { + const chatMsgs = chatEvents.map((event) => + this.eventToMessage(event, conversation.id), + ); + + const nutzapMsgs = nutzapEvents.map((event) => + this.nutzapToMessage(event, conversation.id), + ); + + const allMessages = [...chatMsgs, ...nutzapMsgs]; + console.log( + `[NIP-29] Timeline has ${chatMsgs.length} messages, ${nutzapMsgs.length} nutzaps`, + ); + + return allMessages.sort((a, b) => a.timestamp - b.timestamp); }), ); } @@ -628,4 +663,60 @@ export class Nip29Adapter extends ChatProtocolAdapter { event, }; } + + /** + * Helper: Convert nutzap event (kind 9321) to Message + * NIP-61 nutzaps are P2PK-locked Cashu token transfers + */ + private nutzapToMessage(event: NostrEvent, conversationId: string): Message { + // Sender is the event author + const sender = event.pubkey; + + // Recipient is the p-tag value + const pTag = event.tags.find((t) => t[0] === "p"); + const recipient = pTag?.[1] || ""; + + // Amount is sum of proof amounts from the proof tag + // proof tag format: ["proof", ""] + const proofTag = event.tags.find((t) => t[0] === "proof"); + let amount = 0; + if (proofTag?.[1]) { + try { + const proofs = JSON.parse(proofTag[1]); + if (Array.isArray(proofs)) { + amount = proofs.reduce( + (sum: number, proof: { amount?: number }) => + sum + (proof.amount || 0), + 0, + ); + } + } catch { + // Invalid proof JSON, amount stays 0 + } + } + + // Unit defaults to "sat" per NIP-61 + const unitTag = event.tags.find((t) => t[0] === "unit"); + const unit = unitTag?.[1] || "sat"; + + // Comment is in the content field + const comment = event.content || ""; + + return { + id: event.id, + conversationId, + author: sender, + content: comment, + timestamp: event.created_at, + type: "zap", // Render the same as zaps + protocol: "nip-29", + metadata: { + encrypted: false, + zapAmount: amount, // In the unit specified (usually sats) + zapRecipient: recipient, + nutzapUnit: unit, // Store unit for potential future use + }, + event, + }; + } } diff --git a/src/types/chat.ts b/src/types/chat.ts index bb9198d..711333d 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -93,6 +93,8 @@ export interface MessageMetadata { // Zap-specific metadata (for type: "zap" messages) zapAmount?: number; // Amount in sats zapRecipient?: string; // Pubkey of zap recipient + // NIP-61 nutzap-specific metadata + nutzapUnit?: string; // Unit for nutzap amount (sat, usd, eur, etc.) } /**