feat(market): migrate order DMs to NIP-17 (NIP-44 + NIP-59) #39
9 changed files with 101 additions and 107 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ function activitiesHtmlPlugin(): Plugin {
|
||||||
name: 'activities-html-rewrite',
|
name: 'activities-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
// Rewrite all non-asset requests to activities.html
|
// Rewrite all non-asset requests to activities.html.
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.') // skip files with extensions
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/activities.html'
|
req.url = '/activities.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ function castleHtmlPlugin(): Plugin {
|
||||||
name: 'castle-html-rewrite',
|
name: 'castle-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
// Rewrite all non-asset requests to castle.html
|
// Rewrite all non-asset requests to castle.html.
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.') // skip files with extensions
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/castle.html'
|
req.url = '/castle.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ function chatHtmlPlugin(): Plugin {
|
||||||
name: 'chat-html-rewrite',
|
name: 'chat-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/chat.html'
|
req.url = '/chat.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ function forumHtmlPlugin(): Plugin {
|
||||||
name: 'forum-html-rewrite',
|
name: 'forum-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/forum.html'
|
req.url = '/forum.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ function marketHtmlPlugin(): Plugin {
|
||||||
name: 'market-html-rewrite',
|
name: 'market-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/market.html'
|
req.url = '/market.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ function tasksHtmlPlugin(): Plugin {
|
||||||
name: 'tasks-html-rewrite',
|
name: 'tasks-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/tasks.html'
|
req.url = '/tasks.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,15 @@ function walletHtmlPlugin(): Plugin {
|
||||||
name: 'wallet-html-rewrite',
|
name: 'wallet-html-rewrite',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||||
|
// contain dots and would otherwise get mistaken for an asset request.
|
||||||
|
const path = req.url ? req.url.split('?')[0] : ''
|
||||||
if (
|
if (
|
||||||
req.url &&
|
req.url &&
|
||||||
!req.url.startsWith('/@') &&
|
!req.url.startsWith('/@') &&
|
||||||
!req.url.startsWith('/src/') &&
|
!req.url.startsWith('/src/') &&
|
||||||
!req.url.startsWith('/node_modules/') &&
|
!req.url.startsWith('/node_modules/') &&
|
||||||
!req.url.includes('.')
|
!path.includes('.')
|
||||||
) {
|
) {
|
||||||
req.url = '/wallet.html'
|
req.url = '/wallet.html'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue