feat(market): migrate order DMs to NIP-17 (NIP-44 + NIP-59 gift wrap)

The nostrmarket LNbits extension was refactored to NIP-17 messaging
(refactor/nip17-messaging branch, PR #2). Customers must send orders
as kind 1059 gift wraps so the merchant's _handle_gift_wrap() handler
can process them; kind 4 NIP-04 events are now ignored by the backend.

Changes:
- nostrmarketService.publishOrder(): replace nip04.encrypt + finalizeEvent
  (kind 4) with nip59.wrapEvent producing kind 1059. The order JSON sits
  in an unsigned kind 14 rumor, sealed (kind 13) with the customer's key,
  wrapped (kind 1059) with an ephemeral key.
- useMarket.handleOrderDM(): unwrap incoming kind 1059 via nip59.unwrapEvent
  instead of nip04.decrypt. Read sender pubkey from rumor.pubkey (the gift
  wrap's pubkey is ephemeral).
- useMarket.registerMarketMessageHandler(): bypass chat-service and
  subscribe directly to {kinds: [1059], '#p': [userPubkey]}. The chat
  service still uses NIP-04 - when it migrates to NIP-17 it can take
  over routing again via setMarketMessageHandler.

nostr-tools v2.10.4 (already a dep) provides the NIP-44/NIP-59 APIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-03 12:28:45 +02:00
commit 121f5cc342
2 changed files with 71 additions and 98 deletions

View file

@ -3,7 +3,7 @@ import { useMarketStore } from '../stores/market'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import type { NostrmarketService } from '../services/nostrmarketService' import type { NostrmarketService } from '../services/nostrmarketService'
import { nip04 } from 'nostr-tools' import { nip59 } from 'nostr-tools'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
@ -54,19 +54,29 @@ export function useMarket() {
throw new Error('AuthService not available. Make sure base module is installed.') throw new Error('AuthService not available. Make sure base module is installed.')
} }
// Register market DM handler with chat service (if available) // Subscribe to incoming order gift wraps (NIP-17 / kind 1059) addressed to the user.
//
// The chat service still runs on NIP-04 (kind 4); when it migrates to NIP-17 it
// can take over routing of order DMs the way it does today via setMarketMessageHandler.
// Until then the market subscribes directly so order flows aren't dependent on chat.
const registerMarketMessageHandler = () => { const registerMarketMessageHandler = () => {
try { try {
// Try to get the chat service (it might not be available if chat module isn't loaded) const userPubkey = authService.user.value?.pubkey || auth.currentUser.value?.pubkey
const chatService = (globalThis as any).chatService if (!userPubkey) {
if (chatService && chatService.setMarketMessageHandler) { console.log('🛒 No user pubkey available; skipping order gift-wrap subscription')
chatService.setMarketMessageHandler(handleOrderDM) return
console.log('🛒 Registered market message handler with chat service')
} else {
console.log('🛒 Chat service not available, market will use its own DM subscription')
} }
const unsubscribe = relayHub.subscribe({
id: `market-orders-${userPubkey.slice(0, 16)}`,
filters: [{ kinds: [1059], '#p': [userPubkey] }],
onEvent: (event: any) => handleOrderDM(event)
})
console.log('🎁 Subscribed to order gift wraps (kind 1059)')
// unsubscribe is currently not retained; market lifecycle owns this
void unsubscribe
} catch (error) { } catch (error) {
console.log('🛒 Could not register with chat service:', error) console.warn('🛒 Failed to subscribe to order gift wraps:', error)
} }
} }
@ -423,46 +433,39 @@ export function useMarket() {
return null return null
} }
// Handle incoming order DMs (payment requests, status updates) // Convert hex string to Uint8Array (browser-compatible)
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
//
// The outer event's pubkey is an ephemeral key (NIP-59); the real merchant
// pubkey is on the unwrapped rumor. Content is JSON with a `type` field
// (1 = payment request, 2 = order status update).
const handleOrderDM = async (event: any) => { const handleOrderDM = async (event: any) => {
try { try {
console.log('🔔 Received order-related DM:', event.id, 'from:', event.pubkey.slice(0, 8)) console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')')
// Check both injected auth service AND global auth composable const userPrivkey =
const hasAuthService = authService.user.value?.prvkey authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey
const hasGlobalAuth = auth.currentUser.value?.prvkey
const userPrivkey = hasAuthService ? authService.user.value.prvkey : auth.currentUser.value?.prvkey if (!userPrivkey) {
const userPubkey = hasAuthService ? authService.user.value.pubkey : auth.currentUser.value?.pubkey console.warn('Cannot unwrap gift wrap: no user private key available')
if (!userPrivkey || !userPubkey) {
console.warn('Cannot decrypt DM: no user private key available', {
hasAuthService: !!hasAuthService,
hasGlobalAuth: !!hasGlobalAuth,
authServicePrivkey: !!authService.user.value?.prvkey,
globalAuthPrivkey: !!auth.currentUser.value?.prvkey
})
return return
} }
console.log('🔐 Market DM decryption auth check:', { const prvkeyBytes = hexToUint8Array(userPrivkey)
hasAuthService: !!hasAuthService, const rumor = nip59.unwrapEvent(event, prvkeyBytes)
hasGlobalAuth: !!hasGlobalAuth, console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...')
usingAuthService: !!hasAuthService,
userPubkey: userPubkey.substring(0, 10) + '...'
})
console.log('🔓 Attempting to decrypt DM with private key available') const messageData = JSON.parse(rumor.content)
// Decrypt the DM content
const decryptedContent = await nip04.decrypt(userPrivkey, event.pubkey, event.content)
console.log('🔓 Decrypted DM content:', decryptedContent)
// Parse the decrypted content as JSON
const messageData = JSON.parse(decryptedContent)
console.log('📨 Parsed message data:', messageData) console.log('📨 Parsed message data:', messageData)
// Handle different types of messages
switch (messageData.type) { switch (messageData.type) {
case 1: // Payment request case 1: // Payment request
console.log('💰 Processing payment request for order:', messageData.id) console.log('💰 Processing payment request for order:', messageData.id)
@ -478,7 +481,7 @@ export function useMarket() {
console.log('❓ Unknown message type:', messageData.type) console.log('❓ Unknown message type:', messageData.type)
} }
} catch (error) { } catch (error) {
console.error('Failed to handle order DM:', error) console.error('Failed to handle order gift wrap:', error)
} }
} }

View file

@ -1,4 +1,4 @@
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' import { type EventTemplate, nip59 } from 'nostr-tools'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import type { Order } from '@/modules/market/stores/market' import type { Order } from '@/modules/market/stores/market'
@ -159,12 +159,17 @@ export class NostrmarketService extends BaseService {
// Stall and product publishing is now handled by LNbits API endpoints // Stall and product publishing is now handled by LNbits API endpoints
/** /**
* Publish an order event (kind 4 encrypted DM) to nostrmarket * Publish an order as a NIP-59 gift-wrapped (kind 1059) event to nostrmarket.
*
* The order JSON is placed in an unsigned kind 14 rumor, sealed (kind 13)
* with the customer's key, and wrapped (kind 1059) with an ephemeral key.
* Only the merchant can decrypt the wrap; the public event reveals nothing
* about the sender.
*/ */
async publishOrder(order: Order, merchantPubkey: string): Promise<string> { async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
const { prvkey } = this.getAuth() const { prvkey } = this.getAuth()
// Convert order to nostrmarket format - exactly matching the specification // Convert order to nostrmarket format - matches NIP-15 customer order spec
const orderData = { const orderData = {
type: 0, // DirectMessageType.CUSTOMER_ORDER type: 0, // DirectMessageType.CUSTOMER_ORDER
id: order.id, id: order.id,
@ -175,72 +180,37 @@ export class NostrmarketService extends BaseService {
contact: { contact: {
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown', name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
email: order.contactInfo?.email || '' email: order.contactInfo?.email || ''
// Remove phone field - not in nostrmarket specification
}, },
// Only include address if it's a physical good and address is provided
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? { ...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
address: order.contactInfo.address address: order.contactInfo.address
} : {}), } : {}),
shipping_id: order.shippingZone?.id || 'online' shipping_id: order.shippingZone?.id || 'online'
} }
// Encrypt the message using NIP-04 const rumorTemplate: Partial<EventTemplate> = {
console.log('🔐 NIP-04 encryption debug:', { kind: 14,
prvkeyType: typeof prvkey, tags: [['p', merchantPubkey]],
prvkeyIsString: typeof prvkey === 'string', content: JSON.stringify(orderData),
prvkeyLength: prvkey.length,
prvkeySample: prvkey.substring(0, 10) + '...',
merchantPubkeyType: typeof merchantPubkey,
merchantPubkeyLength: merchantPubkey.length,
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
})
let encryptedContent: string
try {
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
console.log('🔐 NIP-04 encryption successful:', {
encryptedContentLength: encryptedContent.length,
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
})
} catch (error) {
console.error('🔐 NIP-04 encryption failed:', error)
throw error
}
const eventTemplate: EventTemplate = {
kind: 4, // Encrypted DM
tags: [['p', merchantPubkey]], // Recipient (merchant)
content: encryptedContent, // Use encrypted content
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
console.log('🔧 finalizeEvent debug:', {
prvkeyType: typeof prvkey,
prvkeyIsString: typeof prvkey === 'string',
prvkeyLength: prvkey.length,
prvkeySample: prvkey.substring(0, 10) + '...',
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
eventTemplate
})
// Convert hex string to Uint8Array properly
const prvkeyBytes = this.hexToUint8Array(prvkey) const prvkeyBytes = this.hexToUint8Array(prvkey)
console.log('🔧 prvkeyBytes debug:', { const giftWrap = nip59.wrapEvent(rumorTemplate, prvkeyBytes, merchantPubkey)
prvkeyBytesType: typeof prvkeyBytes,
prvkeyBytesLength: prvkeyBytes.length, console.log('🎁 Order gift-wrapped (NIP-17):', {
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array orderId: order.id,
giftWrapId: giftWrap.id,
kind: giftWrap.kind,
merchantPubkey: merchantPubkey.substring(0, 10) + '...'
}) })
const event = finalizeEvent(eventTemplate, prvkeyBytes) const result = await this.relayHub.publishEvent(giftWrap)
const result = await this.relayHub.publishEvent(event)
console.log('Order published to nostrmarket:', { console.log('Order published to nostrmarket:', {
orderId: order.id, orderId: order.id,
eventId: result, eventId: giftWrap.id,
merchantPubkey, merchantPubkey,
content: orderData, content: orderData
encryptedContent: encryptedContent.substring(0, 50) + '...'
}) })
return result.success.toString() return result.success.toString()