webapp/src/modules/market/services/nostrmarketService.ts

397 lines
12 KiB
TypeScript

import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
import { BaseService } from '@/core/base/BaseService'
import type { Order } from '@/modules/market/stores/market'
export interface NostrmarketStall {
id: string
name: string
description?: string
currency: string
shipping: Array<{
id: string
name: string
cost: number
countries: string[]
}>
}
export interface NostrmarketProduct {
id: string
stall_id: string
name: string
description?: string
images: string[]
categories: string[]
price: number
quantity: number
currency: string
}
// Note: Stall and Product publishing is handled by LNbits API endpoints
// NostrmarketService now only handles order DMs and status updates
export interface NostrmarketOrder {
id: string
items: Array<{
product_id: string
quantity: number
}>
contact: {
name: string
email?: string
phone?: string
}
address?: {
street: string
city: string
state: string
country: string
postal_code: string
}
shipping_id: string
}
export interface NostrmarketPaymentRequest {
type: 1
id: string
message?: string
payment_options: Array<{
type: string
link: string
}>
}
export interface NostrmarketOrderStatus {
type: 2
id: string
message?: string
paid?: boolean
shipped?: boolean
}
export class NostrmarketService extends BaseService {
// Service metadata
protected readonly metadata = {
name: 'NostrmarketService',
version: '1.0.0',
dependencies: ['RelayHub', 'AuthService']
}
/**
* Service-specific initialization (called by BaseService)
*/
protected async onInitialize(): Promise<void> {
this.debug('NostrmarketService initialized')
// Service doesn't need special initialization
}
/**
* Check if the service is ready for Nostr operations
*/
get isReady(): boolean {
try {
this.getAuth()
return true
} catch {
return false
}
}
/**
* Convert hex string to Uint8Array (browser-compatible)
*/
private 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.substr(i, 2), 16)
}
return bytes
}
private getAuth() {
// Use injected AuthService only
if (!this.authService?.isAuthenticated.value) {
throw new Error('User not authenticated')
}
const user = this.authService.user.value
const pubkey = user?.pubkey
const prvkey = user?.prvkey
if (!pubkey || !prvkey) {
this.debug('Auth check failed:', {
userExists: !!user,
hasPubkey: !!pubkey,
hasPrvkey: !!prvkey
})
throw new Error('Nostr keys not available. Please ensure your Nostr identity is configured in your profile.')
}
// Validate that we have proper hex strings
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`)
}
if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) {
throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`)
}
console.log('🔑 Key debug:', {
pubkey: pubkey.substring(0, 10) + '...',
prvkey: prvkey.substring(0, 10) + '...',
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey),
pubkeyLength: pubkey.length,
prvkeyLength: prvkey.length,
pubkeyType: typeof pubkey,
prvkeyType: typeof prvkey,
pubkeyIsString: typeof pubkey === 'string',
prvkeyIsString: typeof prvkey === 'string'
})
return {
pubkey,
prvkey
}
}
// Removed publishStall() and publishProduct() methods
// Stall and product publishing is now handled by LNbits API endpoints
/**
* Publish an order event (kind 4 encrypted DM) to nostrmarket
*/
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
const { prvkey } = this.getAuth()
// Convert order to nostrmarket format - exactly matching the specification
const orderData = {
type: 0, // DirectMessageType.CUSTOMER_ORDER
id: order.id,
items: order.items.map(item => ({
product_id: item.productId,
quantity: item.quantity
})),
contact: {
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
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 ? {
address: order.contactInfo.address
} : {}),
shipping_id: order.shippingZone?.id || 'online'
}
// Encrypt the message using NIP-04
console.log('🔐 NIP-04 encryption debug:', {
prvkeyType: typeof prvkey,
prvkeyIsString: typeof prvkey === 'string',
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)
}
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)
console.log('🔧 prvkeyBytes debug:', {
prvkeyBytesType: typeof prvkeyBytes,
prvkeyBytesLength: prvkeyBytes.length,
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
})
const event = finalizeEvent(eventTemplate, prvkeyBytes)
const result = await this.relayHub.publishEvent(event)
console.log('Order published to nostrmarket:', {
orderId: order.id,
eventId: result,
merchantPubkey,
content: orderData,
encryptedContent: encryptedContent.substring(0, 50) + '...'
})
return result.success.toString()
}
/**
* Handle incoming payment request from merchant (type 1)
*/
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
console.log('Received payment request from merchant:', {
orderId: paymentRequest.id,
message: paymentRequest.message,
paymentOptions: paymentRequest.payment_options
})
// Find the Lightning payment option
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
if (!lightningOption) {
console.error('No Lightning payment option found in payment request')
return
}
// Update the order in the store with payment request
const { useMarketStore } = await import('../stores/market')
const marketStore = useMarketStore()
const order = Object.values(marketStore.orders).find(o =>
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
)
if (order) {
// Update order with payment request details
const updatedOrder = {
...order,
paymentRequest: lightningOption.link,
paymentStatus: 'pending' as const,
status: 'pending' as const, // Ensure status is pending for payment
updatedAt: Math.floor(Date.now() / 1000),
items: [...order.items], // Convert readonly to mutable
shippingZone: order.shippingZone ? {
...order.shippingZone,
countries: order.shippingZone.countries ? [...order.shippingZone.countries] : undefined
} : order.shippingZone
}
// Generate QR code for the payment request
try {
const QRCode = await import('qrcode')
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
updatedOrder.qrCodeLoading = false
updatedOrder.qrCodeError = null
} catch (error) {
console.error('Failed to generate QR code:', error)
updatedOrder.qrCodeError = 'Failed to generate QR code'
updatedOrder.qrCodeLoading = false
}
marketStore.updateOrder(order.id, updatedOrder)
console.log('Order updated with payment request:', {
orderId: paymentRequest.id,
paymentRequest: lightningOption.link.substring(0, 50) + '...',
status: updatedOrder.status,
paymentStatus: updatedOrder.paymentStatus,
hasQRCode: !!updatedOrder.qrCodeDataUrl
})
// Debug: Check if the order was actually updated in the store
const verifyOrder = Object.values(marketStore.orders).find(o =>
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
)
console.log('Verified order in store after update:', {
found: !!verifyOrder,
hasPaymentRequest: !!verifyOrder?.paymentRequest,
paymentStatus: verifyOrder?.paymentStatus
})
} else {
console.warn('Payment request received for unknown order:', paymentRequest.id)
}
}
/**
* Handle incoming order status update from merchant (type 2)
*/
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
console.log('Received order status update from merchant:', {
orderId: statusUpdate.id,
message: statusUpdate.message,
paid: statusUpdate.paid,
shipped: statusUpdate.shipped
})
const { useMarketStore } = await import('../stores/market')
const marketStore = useMarketStore()
const order = Object.values(marketStore.orders).find(o =>
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
)
if (order) {
// Create the updated order object with all status updates
const updatedOrder = {
...order,
updatedAt: Math.floor(Date.now() / 1000),
items: [...order.items], // Convert readonly to mutable
shippingZone: order.shippingZone ? {
...order.shippingZone,
countries: order.shippingZone.countries ? [...order.shippingZone.countries] : undefined
} : order.shippingZone
}
// Update payment status
if (statusUpdate.paid !== undefined) {
updatedOrder.paid = statusUpdate.paid
updatedOrder.paymentStatus = (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired'
updatedOrder.status = statusUpdate.paid ? 'paid' : 'pending'
updatedOrder.paidAt = statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined
}
// Update shipping status
if (statusUpdate.shipped !== undefined) {
updatedOrder.shipped = statusUpdate.shipped
if (statusUpdate.shipped && updatedOrder.status === 'paid') {
updatedOrder.status = 'shipped'
}
}
// Apply the update - this should trigger persistence
marketStore.updateOrder(order.id, updatedOrder)
console.log('Order status updated and persisted:', {
orderId: statusUpdate.id,
paid: updatedOrder.paid,
shipped: updatedOrder.shipped,
paymentStatus: updatedOrder.paymentStatus,
status: updatedOrder.status,
paidAt: updatedOrder.paidAt
})
} else {
console.warn('Status update received for unknown order:', statusUpdate.id)
}
}
// Removed publishMerchantCatalog() method
// Publishing is now handled by LNbits API endpoints
}