Compare commits
4 commits
22e1a348f2
...
2b14b06fd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b14b06fd8 | ||
|
|
e0bf3ba8ff | ||
|
|
0eede24cad | ||
|
|
3a654857ff |
17 changed files with 5108 additions and 0 deletions
390
src/extensions/marketplace/index.ts
Normal file
390
src/extensions/marketplace/index.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import {
|
||||||
|
Extension, ExtensionContext, ExtensionDatabase, ExtensionInfo,
|
||||||
|
CreateStallRequest, UpdateStallRequest,
|
||||||
|
CreateProductRequest, UpdateProductRequest,
|
||||||
|
CreateOrderRequest
|
||||||
|
} from './types.js'
|
||||||
|
import { migrations } from './migrations.js'
|
||||||
|
import { StallManager } from './managers/stallManager.js'
|
||||||
|
import { ProductManager } from './managers/productManager.js'
|
||||||
|
import { OrderManager } from './managers/orderManager.js'
|
||||||
|
import { MessageManager } from './managers/messageManager.js'
|
||||||
|
import { parseOrderRequest, parseMessageType, NostrEvent } from './nostr/parser.js'
|
||||||
|
import { EVENT_KINDS } from './nostr/kinds.js'
|
||||||
|
import { validatePubkey } from './utils/validation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace Extension for Lightning.Pub
|
||||||
|
*
|
||||||
|
* Implements NIP-15 compatible marketplace functionality:
|
||||||
|
* - Stall management (kind 30017 events)
|
||||||
|
* - Product listings (kind 30018 events)
|
||||||
|
* - Order processing via encrypted DMs
|
||||||
|
* - Customer relationship management
|
||||||
|
*/
|
||||||
|
export default class MarketplaceExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'marketplace',
|
||||||
|
name: 'Nostr Marketplace',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'NIP-15 compatible marketplace for selling products via Lightning',
|
||||||
|
author: 'Lightning.Pub',
|
||||||
|
minPubVersion: '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
private stallManager!: StallManager
|
||||||
|
private productManager!: ProductManager
|
||||||
|
private orderManager!: OrderManager
|
||||||
|
private messageManager!: MessageManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the extension
|
||||||
|
*/
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Run migrations
|
||||||
|
for (const migration of migrations) {
|
||||||
|
await migration.up(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
this.stallManager = new StallManager(db, ctx)
|
||||||
|
this.productManager = new ProductManager(db, ctx)
|
||||||
|
this.orderManager = new OrderManager(db, ctx)
|
||||||
|
this.messageManager = new MessageManager(db, ctx)
|
||||||
|
|
||||||
|
// Register RPC methods
|
||||||
|
this.registerRpcMethods(ctx)
|
||||||
|
|
||||||
|
// Subscribe to payment callbacks
|
||||||
|
ctx.onPaymentReceived(async (payment) => {
|
||||||
|
if (payment.metadata?.extension === 'marketplace' && payment.metadata?.order_id) {
|
||||||
|
await this.orderManager.handlePayment(payment.invoiceId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to incoming Nostr events
|
||||||
|
ctx.onNostrEvent(async (event, applicationId) => {
|
||||||
|
await this.handleNostrEvent(event, applicationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Marketplace] Extension initialized`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on shutdown
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
console.log(`[Marketplace] Extension shutting down`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all RPC methods with the extension context
|
||||||
|
*/
|
||||||
|
private registerRpcMethods(ctx: ExtensionContext): void {
|
||||||
|
// ===== Stall Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.create(appId, req as CreateStallRequest)
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.get(req.id, appId)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listStalls', async (_req, appId) => {
|
||||||
|
const stalls = await this.stallManager.list(appId)
|
||||||
|
return { stalls }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.update(req.id, appId, req as UpdateStallRequest)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
return { stall }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.deleteStall', async (req, appId) => {
|
||||||
|
const success = await this.stallManager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Stall not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.publishStall', async (req, appId) => {
|
||||||
|
const stall = await this.stallManager.get(req.id, appId)
|
||||||
|
if (!stall) throw new Error('Stall not found')
|
||||||
|
const eventId = await this.stallManager.publishToNostr(stall)
|
||||||
|
return { event_id: eventId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Product Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.create(
|
||||||
|
appId,
|
||||||
|
req.stall_id,
|
||||||
|
req as CreateProductRequest
|
||||||
|
)
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.get(req.id, appId)
|
||||||
|
if (!product) throw new Error('Product not found')
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listProducts', async (req, appId) => {
|
||||||
|
const products = await this.productManager.list(appId, {
|
||||||
|
stall_id: req.stall_id,
|
||||||
|
category: req.category,
|
||||||
|
active_only: req.active_only,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { products }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateProduct', async (req, appId) => {
|
||||||
|
const product = await this.productManager.update(req.id, appId, req as UpdateProductRequest)
|
||||||
|
if (!product) throw new Error('Product not found')
|
||||||
|
return { product }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.deleteProduct', async (req, appId) => {
|
||||||
|
const success = await this.productManager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Product not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateInventory', async (req, appId) => {
|
||||||
|
const newQuantity = await this.productManager.updateQuantity(req.id, appId, req.delta)
|
||||||
|
return { quantity: newQuantity }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.checkStock', async (req, appId) => {
|
||||||
|
const result = await this.productManager.checkStock(req.items, appId)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Order Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createOrder', async (req, appId, userPubkey) => {
|
||||||
|
if (!userPubkey) throw new Error('User pubkey required')
|
||||||
|
const order = await this.orderManager.createFromRpc(
|
||||||
|
appId,
|
||||||
|
userPubkey,
|
||||||
|
req as CreateOrderRequest,
|
||||||
|
req.app_user_id
|
||||||
|
)
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getOrder', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.get(req.id, appId)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listOrders', async (req, appId) => {
|
||||||
|
const orders = await this.orderManager.list(appId, {
|
||||||
|
stall_id: req.stall_id,
|
||||||
|
customer_pubkey: req.customer_pubkey,
|
||||||
|
status: req.status,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { orders }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.createInvoice', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.createInvoice(req.order_id, appId)
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.updateOrderStatus', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.updateStatus(
|
||||||
|
req.id,
|
||||||
|
appId,
|
||||||
|
req.status,
|
||||||
|
req.message
|
||||||
|
)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
return { order }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.sendPaymentRequest', async (req, appId) => {
|
||||||
|
const order = await this.orderManager.get(req.order_id, appId)
|
||||||
|
if (!order) throw new Error('Order not found')
|
||||||
|
|
||||||
|
// Create invoice if not exists
|
||||||
|
if (!order.invoice) {
|
||||||
|
await this.orderManager.createInvoice(req.order_id, appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOrder = await this.orderManager.get(req.order_id, appId)
|
||||||
|
if (!updatedOrder?.invoice) throw new Error('Failed to create invoice')
|
||||||
|
|
||||||
|
await this.orderManager.sendPaymentRequestDM(updatedOrder)
|
||||||
|
return { order: updatedOrder }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Message/Customer Methods =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listMessages', async (req, appId) => {
|
||||||
|
const messages = await this.messageManager.listMessages(appId, {
|
||||||
|
customer_pubkey: req.customer_pubkey,
|
||||||
|
order_id: req.order_id,
|
||||||
|
incoming_only: req.incoming_only,
|
||||||
|
unread_only: req.unread_only,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { messages }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getConversation', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const messages = await this.messageManager.getConversation(
|
||||||
|
appId,
|
||||||
|
req.customer_pubkey,
|
||||||
|
req.limit
|
||||||
|
)
|
||||||
|
return { messages }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.sendMessage', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const message = await this.messageManager.sendMessage(
|
||||||
|
appId,
|
||||||
|
req.customer_pubkey,
|
||||||
|
req.message,
|
||||||
|
req.order_id
|
||||||
|
)
|
||||||
|
return { message }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.markMessagesRead', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.customer_pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const count = await this.messageManager.markAsRead(appId, req.customer_pubkey)
|
||||||
|
return { marked_read: count }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getUnreadCount', async (_req, appId) => {
|
||||||
|
const count = await this.messageManager.getUnreadCount(appId)
|
||||||
|
return { unread_count: count }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.listCustomers', async (req, appId) => {
|
||||||
|
const customers = await this.messageManager.listCustomers(appId, {
|
||||||
|
has_orders: req.has_orders,
|
||||||
|
has_unread: req.has_unread,
|
||||||
|
limit: req.limit,
|
||||||
|
offset: req.offset
|
||||||
|
})
|
||||||
|
return { customers }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getCustomer', async (req, appId) => {
|
||||||
|
if (!validatePubkey(req.pubkey)) {
|
||||||
|
throw new Error('Invalid customer pubkey')
|
||||||
|
}
|
||||||
|
const customer = await this.messageManager.getCustomer(req.pubkey, appId)
|
||||||
|
if (!customer) throw new Error('Customer not found')
|
||||||
|
return { customer }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.getCustomerStats', async (_req, appId) => {
|
||||||
|
const stats = await this.messageManager.getCustomerStats(appId)
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Bulk Operations =====
|
||||||
|
|
||||||
|
ctx.registerMethod('marketplace.republishAll', async (req, appId) => {
|
||||||
|
let stallCount = 0
|
||||||
|
let productCount = 0
|
||||||
|
|
||||||
|
if (req.stall_id) {
|
||||||
|
// Republish specific stall and its products
|
||||||
|
const stall = await this.stallManager.get(req.stall_id, appId)
|
||||||
|
if (stall) {
|
||||||
|
await this.stallManager.publishToNostr(stall)
|
||||||
|
stallCount = 1
|
||||||
|
productCount = await this.productManager.republishAllForStall(req.stall_id, appId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Republish all
|
||||||
|
stallCount = await this.stallManager.republishAll(appId)
|
||||||
|
const stalls = await this.stallManager.list(appId)
|
||||||
|
for (const stall of stalls) {
|
||||||
|
productCount += await this.productManager.republishAllForStall(stall.id, appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stalls_published: stallCount,
|
||||||
|
products_published: productCount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming Nostr events (DMs for orders)
|
||||||
|
*/
|
||||||
|
private async handleNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||||
|
// Only handle DMs for now
|
||||||
|
if (event.kind !== EVENT_KINDS.DIRECT_MESSAGE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt the message content (context handles this)
|
||||||
|
const decrypted = event.content // Assume pre-decrypted by context
|
||||||
|
|
||||||
|
// Store the message
|
||||||
|
await this.messageManager.storeIncoming(
|
||||||
|
applicationId,
|
||||||
|
event.pubkey,
|
||||||
|
decrypted,
|
||||||
|
event.id,
|
||||||
|
event.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse message type
|
||||||
|
const { type, parsed } = parseMessageType(decrypted)
|
||||||
|
|
||||||
|
// Handle order requests
|
||||||
|
if (type === 'order_request') {
|
||||||
|
const orderReq = parseOrderRequest(decrypted)
|
||||||
|
if (orderReq) {
|
||||||
|
const order = await this.orderManager.createFromNostr(
|
||||||
|
applicationId,
|
||||||
|
event.pubkey,
|
||||||
|
orderReq,
|
||||||
|
event.id
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create invoice and send payment request
|
||||||
|
const withInvoice = await this.orderManager.createInvoice(order.id, applicationId)
|
||||||
|
await this.orderManager.sendPaymentRequestDM(withInvoice)
|
||||||
|
|
||||||
|
console.log(`[Marketplace] Created order ${order.id} from Nostr DM`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Marketplace] Error handling Nostr event:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for external use
|
||||||
|
export * from './types.js'
|
||||||
|
export { EVENT_KINDS, MESSAGE_TYPES } from './nostr/kinds.js'
|
||||||
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
DirectMessage, Customer, MessageType
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId } from '../utils/validation.js'
|
||||||
|
import { parseMessageType } from '../nostr/parser.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for direct message
|
||||||
|
*/
|
||||||
|
interface MessageRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
order_id: string | null
|
||||||
|
customer_pubkey: string
|
||||||
|
message_type: string
|
||||||
|
content: string
|
||||||
|
incoming: number
|
||||||
|
nostr_event_id: string
|
||||||
|
nostr_event_created_at: number
|
||||||
|
created_at: number
|
||||||
|
read: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for customer
|
||||||
|
*/
|
||||||
|
interface CustomerRow {
|
||||||
|
pubkey: string
|
||||||
|
application_id: string
|
||||||
|
name: string | null
|
||||||
|
about: string | null
|
||||||
|
picture: string | null
|
||||||
|
total_orders: number
|
||||||
|
total_spent_sats: number
|
||||||
|
unread_messages: number
|
||||||
|
first_seen_at: number
|
||||||
|
last_seen_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to DirectMessage object
|
||||||
|
*/
|
||||||
|
function rowToMessage(row: MessageRow): DirectMessage {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
order_id: row.order_id || undefined,
|
||||||
|
customer_pubkey: row.customer_pubkey,
|
||||||
|
message_type: row.message_type as MessageType,
|
||||||
|
content: row.content,
|
||||||
|
incoming: row.incoming === 1,
|
||||||
|
nostr_event_id: row.nostr_event_id,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
read: row.read === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Customer object
|
||||||
|
*/
|
||||||
|
function rowToCustomer(row: CustomerRow): Customer {
|
||||||
|
return {
|
||||||
|
pubkey: row.pubkey,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name || undefined,
|
||||||
|
about: row.about || undefined,
|
||||||
|
picture: row.picture || undefined,
|
||||||
|
total_orders: row.total_orders,
|
||||||
|
total_spent_sats: row.total_spent_sats,
|
||||||
|
unread_messages: row.unread_messages,
|
||||||
|
first_seen_at: row.first_seen_at,
|
||||||
|
last_seen_at: row.last_seen_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing messages
|
||||||
|
*/
|
||||||
|
interface ListMessagesOptions {
|
||||||
|
customer_pubkey?: string
|
||||||
|
order_id?: string
|
||||||
|
incoming_only?: boolean
|
||||||
|
unread_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing customers
|
||||||
|
*/
|
||||||
|
interface ListCustomersOptions {
|
||||||
|
has_orders?: boolean
|
||||||
|
has_unread?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MessageManager - Handles customer DMs and customer management
|
||||||
|
*/
|
||||||
|
export class MessageManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ===== Message Methods =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store incoming message from Nostr
|
||||||
|
*/
|
||||||
|
async storeIncoming(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
decryptedContent: string,
|
||||||
|
nostrEventId: string,
|
||||||
|
nostrEventCreatedAt: number,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
// Parse message type
|
||||||
|
const { type } = parseMessageType(decryptedContent)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const message: DirectMessage = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
order_id: orderId,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
message_type: type,
|
||||||
|
content: decryptedContent,
|
||||||
|
incoming: true,
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
nostr_event_created_at: nostrEventCreatedAt,
|
||||||
|
created_at: now,
|
||||||
|
read: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate event
|
||||||
|
const existing = await this.db.query(
|
||||||
|
'SELECT id FROM direct_messages WHERE nostr_event_id = ?',
|
||||||
|
[nostrEventId]
|
||||||
|
)
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return message // Already stored, return without error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert message
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO direct_messages (
|
||||||
|
id, application_id, order_id, customer_pubkey, message_type,
|
||||||
|
content, incoming, nostr_event_id, nostr_event_created_at,
|
||||||
|
created_at, read
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
message.id,
|
||||||
|
message.application_id,
|
||||||
|
message.order_id || null,
|
||||||
|
message.customer_pubkey,
|
||||||
|
message.message_type,
|
||||||
|
message.content,
|
||||||
|
1, // incoming = true
|
||||||
|
message.nostr_event_id,
|
||||||
|
message.nostr_event_created_at,
|
||||||
|
message.created_at,
|
||||||
|
0 // read = false
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update customer unread count
|
||||||
|
await this.incrementUnread(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
// Ensure customer exists
|
||||||
|
await this.ensureCustomer(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store outgoing message (sent by merchant)
|
||||||
|
*/
|
||||||
|
async storeOutgoing(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
content: string,
|
||||||
|
nostrEventId: string,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
const { type } = parseMessageType(content)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const message: DirectMessage = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
order_id: orderId,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
message_type: type,
|
||||||
|
content,
|
||||||
|
incoming: false,
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
nostr_event_created_at: now,
|
||||||
|
created_at: now,
|
||||||
|
read: true // Outgoing messages are always "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO direct_messages (
|
||||||
|
id, application_id, order_id, customer_pubkey, message_type,
|
||||||
|
content, incoming, nostr_event_id, nostr_event_created_at,
|
||||||
|
created_at, read
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
message.id,
|
||||||
|
message.application_id,
|
||||||
|
message.order_id || null,
|
||||||
|
message.customer_pubkey,
|
||||||
|
message.message_type,
|
||||||
|
message.content,
|
||||||
|
0, // incoming = false
|
||||||
|
message.nostr_event_id,
|
||||||
|
message.nostr_event_created_at,
|
||||||
|
message.created_at,
|
||||||
|
1 // read = true
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message by ID
|
||||||
|
*/
|
||||||
|
async getMessage(id: string, applicationId: string): Promise<DirectMessage | null> {
|
||||||
|
const rows = await this.db.query<MessageRow>(
|
||||||
|
'SELECT * FROM direct_messages WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToMessage(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List messages with filters
|
||||||
|
*/
|
||||||
|
async listMessages(
|
||||||
|
applicationId: string,
|
||||||
|
options: ListMessagesOptions = {}
|
||||||
|
): Promise<DirectMessage[]> {
|
||||||
|
let sql = 'SELECT * FROM direct_messages WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.customer_pubkey) {
|
||||||
|
sql += ' AND customer_pubkey = ?'
|
||||||
|
params.push(options.customer_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.order_id) {
|
||||||
|
sql += ' AND order_id = ?'
|
||||||
|
params.push(options.order_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.incoming_only) {
|
||||||
|
sql += ' AND incoming = 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.unread_only) {
|
||||||
|
sql += ' AND read = 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY nostr_event_created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<MessageRow>(sql, params)
|
||||||
|
return rows.map(rowToMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversation thread with a customer
|
||||||
|
*/
|
||||||
|
async getConversation(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<DirectMessage[]> {
|
||||||
|
const rows = await this.db.query<MessageRow>(
|
||||||
|
`SELECT * FROM direct_messages
|
||||||
|
WHERE application_id = ? AND customer_pubkey = ?
|
||||||
|
ORDER BY nostr_event_created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[applicationId, customerPubkey, limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return in chronological order
|
||||||
|
return rows.map(rowToMessage).reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark messages as read
|
||||||
|
*/
|
||||||
|
async markAsRead(applicationId: string, customerPubkey: string): Promise<number> {
|
||||||
|
const result = await this.db.execute(
|
||||||
|
`UPDATE direct_messages SET read = 1
|
||||||
|
WHERE application_id = ? AND customer_pubkey = ? AND read = 0`,
|
||||||
|
[applicationId, customerPubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset customer unread count
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET unread_messages = 0
|
||||||
|
WHERE application_id = ? AND pubkey = ?`,
|
||||||
|
[applicationId, customerPubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.changes || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread message count
|
||||||
|
*/
|
||||||
|
async getUnreadCount(applicationId: string): Promise<number> {
|
||||||
|
const result = await this.db.query<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM direct_messages
|
||||||
|
WHERE application_id = ? AND incoming = 1 AND read = 0`,
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
return result[0]?.count || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a plain text message to customer
|
||||||
|
*/
|
||||||
|
async sendMessage(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
message: string,
|
||||||
|
orderId?: string
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
// Send via Nostr
|
||||||
|
const eventId = await this.ctx.sendEncryptedDM(
|
||||||
|
applicationId,
|
||||||
|
customerPubkey,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store outgoing message
|
||||||
|
return this.storeOutgoing(
|
||||||
|
applicationId,
|
||||||
|
customerPubkey,
|
||||||
|
message,
|
||||||
|
eventId,
|
||||||
|
orderId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Customer Methods =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer by pubkey
|
||||||
|
*/
|
||||||
|
async getCustomer(pubkey: string, applicationId: string): Promise<Customer | null> {
|
||||||
|
const rows = await this.db.query<CustomerRow>(
|
||||||
|
'SELECT * FROM customers WHERE pubkey = ? AND application_id = ?',
|
||||||
|
[pubkey, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToCustomer(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List customers with filters
|
||||||
|
*/
|
||||||
|
async listCustomers(
|
||||||
|
applicationId: string,
|
||||||
|
options: ListCustomersOptions = {}
|
||||||
|
): Promise<Customer[]> {
|
||||||
|
let sql = 'SELECT * FROM customers WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.has_orders) {
|
||||||
|
sql += ' AND total_orders > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.has_unread) {
|
||||||
|
sql += ' AND unread_messages > 0'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY last_seen_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<CustomerRow>(sql, params)
|
||||||
|
return rows.map(rowToCustomer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update customer profile from Nostr metadata
|
||||||
|
*/
|
||||||
|
async updateCustomerProfile(
|
||||||
|
pubkey: string,
|
||||||
|
applicationId: string,
|
||||||
|
profile: { name?: string; about?: string; picture?: string }
|
||||||
|
): Promise<Customer | null> {
|
||||||
|
await this.ensureCustomer(applicationId, pubkey)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET
|
||||||
|
name = COALESCE(?, name),
|
||||||
|
about = COALESCE(?, about),
|
||||||
|
picture = COALESCE(?, picture),
|
||||||
|
last_seen_at = ?
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
profile.name || null,
|
||||||
|
profile.about || null,
|
||||||
|
profile.picture || null,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
pubkey,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.getCustomer(pubkey, applicationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer statistics summary
|
||||||
|
*/
|
||||||
|
async getCustomerStats(applicationId: string): Promise<{
|
||||||
|
total_customers: number
|
||||||
|
customers_with_orders: number
|
||||||
|
total_unread: number
|
||||||
|
}> {
|
||||||
|
const total = await this.db.query<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM customers WHERE application_id = ?',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const withOrders = await this.db.query<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM customers WHERE application_id = ? AND total_orders > 0',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const unread = await this.db.query<{ sum: number }>(
|
||||||
|
'SELECT SUM(unread_messages) as sum FROM customers WHERE application_id = ?',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_customers: total[0]?.count || 0,
|
||||||
|
customers_with_orders: withOrders[0]?.count || 0,
|
||||||
|
total_unread: unread[0]?.sum || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
private async ensureCustomer(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO customers (pubkey, application_id, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(pubkey, application_id) DO UPDATE SET
|
||||||
|
last_seen_at = ?`,
|
||||||
|
[pubkey, applicationId, now, now, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async incrementUnread(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET unread_messages = unread_messages + 1
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[pubkey, applicationId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Order, Stall, Product, NIP15OrderRequest,
|
||||||
|
CreateOrderRequest, OrderItem, OrderStatus
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateOrderItems } from '../utils/validation.js'
|
||||||
|
import { convertToSats, SupportedCurrency } from '../utils/currency.js'
|
||||||
|
import { buildPaymentRequestContent, buildOrderStatusContent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for order
|
||||||
|
*/
|
||||||
|
interface OrderRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
stall_id: string
|
||||||
|
customer_pubkey: string
|
||||||
|
customer_app_user_id: string | null
|
||||||
|
items: string // JSON
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: string // JSON
|
||||||
|
subtotal_sats: number
|
||||||
|
shipping_sats: number
|
||||||
|
total_sats: number
|
||||||
|
original_currency: string
|
||||||
|
exchange_rate: number | null
|
||||||
|
invoice: string | null
|
||||||
|
invoice_id: string | null
|
||||||
|
status: string
|
||||||
|
nostr_event_id: string | null
|
||||||
|
created_at: number
|
||||||
|
paid_at: number | null
|
||||||
|
shipped_at: number | null
|
||||||
|
completed_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Order object
|
||||||
|
*/
|
||||||
|
function rowToOrder(row: OrderRow): Order {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
stall_id: row.stall_id,
|
||||||
|
customer_pubkey: row.customer_pubkey,
|
||||||
|
customer_app_user_id: row.customer_app_user_id || undefined,
|
||||||
|
items: JSON.parse(row.items),
|
||||||
|
shipping_zone_id: row.shipping_zone_id,
|
||||||
|
shipping_address: row.shipping_address,
|
||||||
|
contact: JSON.parse(row.contact),
|
||||||
|
subtotal_sats: row.subtotal_sats,
|
||||||
|
shipping_sats: row.shipping_sats,
|
||||||
|
total_sats: row.total_sats,
|
||||||
|
original_currency: row.original_currency as SupportedCurrency,
|
||||||
|
exchange_rate: row.exchange_rate || undefined,
|
||||||
|
invoice: row.invoice || undefined,
|
||||||
|
invoice_id: row.invoice_id || undefined,
|
||||||
|
status: row.status as OrderStatus,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
paid_at: row.paid_at || undefined,
|
||||||
|
shipped_at: row.shipped_at || undefined,
|
||||||
|
completed_at: row.completed_at || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing orders
|
||||||
|
*/
|
||||||
|
interface ListOrdersOptions {
|
||||||
|
stall_id?: string
|
||||||
|
customer_pubkey?: string
|
||||||
|
status?: OrderStatus
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OrderManager - Handles order lifecycle and payment integration
|
||||||
|
*/
|
||||||
|
export class OrderManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create order from NIP-15 order request (via Nostr DM)
|
||||||
|
*/
|
||||||
|
async createFromNostr(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
orderReq: NIP15OrderRequest,
|
||||||
|
nostrEventId?: string
|
||||||
|
): Promise<Order> {
|
||||||
|
// Validate items
|
||||||
|
validateOrderItems(orderReq.items)
|
||||||
|
|
||||||
|
// Find stall from first product
|
||||||
|
const firstProduct = await this.getProduct(orderReq.items[0].product_id, applicationId)
|
||||||
|
if (!firstProduct) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stall = await this.getStall(firstProduct.stall_id, applicationId)
|
||||||
|
if (!stall) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const itemsWithDetails = await this.enrichOrderItems(orderReq.items, applicationId)
|
||||||
|
const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals(
|
||||||
|
itemsWithDetails,
|
||||||
|
stall,
|
||||||
|
orderReq.shipping_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = orderReq.id || generateId()
|
||||||
|
|
||||||
|
const order: Order = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
stall_id: stall.id,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
items: itemsWithDetails,
|
||||||
|
shipping_zone_id: orderReq.shipping_id,
|
||||||
|
shipping_address: orderReq.address || '',
|
||||||
|
contact: orderReq.contact || {},
|
||||||
|
subtotal_sats: subtotal,
|
||||||
|
shipping_sats: shippingCost,
|
||||||
|
total_sats: total,
|
||||||
|
original_currency: stall.currency,
|
||||||
|
exchange_rate: exchangeRate,
|
||||||
|
status: 'pending',
|
||||||
|
nostr_event_id: nostrEventId,
|
||||||
|
created_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.insertOrder(order)
|
||||||
|
|
||||||
|
// Reserve inventory
|
||||||
|
await this.reserveInventory(order)
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerStats(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create order from RPC request (native client)
|
||||||
|
*/
|
||||||
|
async createFromRpc(
|
||||||
|
applicationId: string,
|
||||||
|
customerPubkey: string,
|
||||||
|
req: CreateOrderRequest,
|
||||||
|
appUserId?: string
|
||||||
|
): Promise<Order> {
|
||||||
|
// Validate items
|
||||||
|
validateOrderItems(req.items)
|
||||||
|
|
||||||
|
// Find stall
|
||||||
|
const stall = await this.getStall(req.stall_id, applicationId)
|
||||||
|
if (!stall) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const itemsWithDetails = await this.enrichOrderItems(req.items, applicationId)
|
||||||
|
const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals(
|
||||||
|
itemsWithDetails,
|
||||||
|
stall,
|
||||||
|
req.shipping_zone_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const order: Order = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
stall_id: stall.id,
|
||||||
|
customer_pubkey: customerPubkey,
|
||||||
|
customer_app_user_id: appUserId,
|
||||||
|
items: itemsWithDetails,
|
||||||
|
shipping_zone_id: req.shipping_zone_id,
|
||||||
|
shipping_address: req.shipping_address || '',
|
||||||
|
contact: req.contact || {},
|
||||||
|
subtotal_sats: subtotal,
|
||||||
|
shipping_sats: shippingCost,
|
||||||
|
total_sats: total,
|
||||||
|
original_currency: stall.currency,
|
||||||
|
exchange_rate: exchangeRate,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.insertOrder(order)
|
||||||
|
|
||||||
|
// Reserve inventory
|
||||||
|
await this.reserveInventory(order)
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerStats(applicationId, customerPubkey)
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Order | null> {
|
||||||
|
const rows = await this.db.query<OrderRow>(
|
||||||
|
'SELECT * FROM orders WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToOrder(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List orders with filters
|
||||||
|
*/
|
||||||
|
async list(applicationId: string, options: ListOrdersOptions = {}): Promise<Order[]> {
|
||||||
|
let sql = 'SELECT * FROM orders WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.stall_id) {
|
||||||
|
sql += ' AND stall_id = ?'
|
||||||
|
params.push(options.stall_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.customer_pubkey) {
|
||||||
|
sql += ' AND customer_pubkey = ?'
|
||||||
|
params.push(options.customer_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
sql += ' AND status = ?'
|
||||||
|
params.push(options.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<OrderRow>(sql, params)
|
||||||
|
return rows.map(rowToOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create invoice for an order
|
||||||
|
*/
|
||||||
|
async createInvoice(id: string, applicationId: string): Promise<Order> {
|
||||||
|
const order = await this.get(id, applicationId)
|
||||||
|
if (!order) {
|
||||||
|
throw new Error('Order not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'pending') {
|
||||||
|
throw new Error(`Order already ${order.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create invoice via Lightning.Pub
|
||||||
|
const invoice = await this.ctx.createInvoice(order.total_sats, {
|
||||||
|
memo: `Order ${order.id}`,
|
||||||
|
expiry: 3600, // 1 hour
|
||||||
|
metadata: {
|
||||||
|
extension: 'marketplace',
|
||||||
|
order_id: order.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order with invoice
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET invoice = ?, invoice_id = ? WHERE id = ?`,
|
||||||
|
[invoice.paymentRequest, invoice.id, order.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
order.invoice = invoice.paymentRequest
|
||||||
|
order.invoice_id = invoice.id
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle invoice payment callback
|
||||||
|
*/
|
||||||
|
async handlePayment(invoiceId: string): Promise<Order | null> {
|
||||||
|
// Find order by invoice ID
|
||||||
|
const rows = await this.db.query<OrderRow>(
|
||||||
|
'SELECT * FROM orders WHERE invoice_id = ? AND status = ?',
|
||||||
|
[invoiceId, 'pending']
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const order = rowToOrder(rows[0])
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET status = ?, paid_at = ? WHERE id = ?`,
|
||||||
|
['paid', now, order.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
order.status = 'paid'
|
||||||
|
order.paid_at = now
|
||||||
|
|
||||||
|
// Update customer stats
|
||||||
|
await this.updateCustomerSpent(order.application_id, order.customer_pubkey, order.total_sats)
|
||||||
|
|
||||||
|
// Send payment confirmation DM
|
||||||
|
await this.sendOrderStatusDM(order, 'Payment received! Your order is being processed.')
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update order status
|
||||||
|
*/
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
status: OrderStatus,
|
||||||
|
message?: string
|
||||||
|
): Promise<Order | null> {
|
||||||
|
const order = await this.get(id, applicationId)
|
||||||
|
if (!order) return null
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const updates: string[] = ['status = ?']
|
||||||
|
const params: any[] = [status]
|
||||||
|
|
||||||
|
// Set timestamp based on status
|
||||||
|
if (status === 'paid' && !order.paid_at) {
|
||||||
|
updates.push('paid_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
} else if (status === 'shipped' && !order.shipped_at) {
|
||||||
|
updates.push('shipped_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
} else if (status === 'completed' && !order.completed_at) {
|
||||||
|
updates.push('completed_at = ?')
|
||||||
|
params.push(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id, applicationId)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE orders SET ${updates.join(', ')} WHERE id = ? AND application_id = ?`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
const updated = await this.get(id, applicationId)
|
||||||
|
|
||||||
|
// Send status update DM
|
||||||
|
if (updated && message) {
|
||||||
|
await this.sendOrderStatusDM(updated, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancellation - restore inventory
|
||||||
|
if (status === 'cancelled' && order.status === 'pending') {
|
||||||
|
await this.restoreInventory(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment request DM to customer
|
||||||
|
*/
|
||||||
|
async sendPaymentRequestDM(order: Order): Promise<void> {
|
||||||
|
if (!order.invoice) {
|
||||||
|
throw new Error('Order has no invoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = buildPaymentRequestContent(order, order.invoice)
|
||||||
|
|
||||||
|
await this.ctx.sendEncryptedDM(
|
||||||
|
order.application_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
JSON.stringify(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send order status update DM to customer
|
||||||
|
*/
|
||||||
|
async sendOrderStatusDM(order: Order, message: string): Promise<void> {
|
||||||
|
const content = buildOrderStatusContent(order, message)
|
||||||
|
|
||||||
|
await this.ctx.sendEncryptedDM(
|
||||||
|
order.application_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
JSON.stringify(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
private async insertOrder(order: Order): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO orders (
|
||||||
|
id, application_id, stall_id, customer_pubkey, customer_app_user_id,
|
||||||
|
items, shipping_zone_id, shipping_address, contact,
|
||||||
|
subtotal_sats, shipping_sats, total_sats, original_currency, exchange_rate,
|
||||||
|
invoice, invoice_id, status, nostr_event_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
order.id,
|
||||||
|
order.application_id,
|
||||||
|
order.stall_id,
|
||||||
|
order.customer_pubkey,
|
||||||
|
order.customer_app_user_id || null,
|
||||||
|
JSON.stringify(order.items),
|
||||||
|
order.shipping_zone_id,
|
||||||
|
order.shipping_address,
|
||||||
|
JSON.stringify(order.contact),
|
||||||
|
order.subtotal_sats,
|
||||||
|
order.shipping_sats,
|
||||||
|
order.total_sats,
|
||||||
|
order.original_currency,
|
||||||
|
order.exchange_rate || null,
|
||||||
|
order.invoice || null,
|
||||||
|
order.invoice_id || null,
|
||||||
|
order.status,
|
||||||
|
order.nostr_event_id || null,
|
||||||
|
order.created_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStall(id: string, applicationId: string): Promise<Stall | null> {
|
||||||
|
const rows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const row = rows[0] as any
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
shipping_zones: JSON.parse(row.shipping_zones)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProduct(id: string, applicationId: string): Promise<Product | null> {
|
||||||
|
const rows = await this.db.query(
|
||||||
|
'SELECT * FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const row = rows[0] as any
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
images: JSON.parse(row.images),
|
||||||
|
categories: JSON.parse(row.categories),
|
||||||
|
specs: row.specs ? JSON.parse(row.specs) : undefined,
|
||||||
|
active: row.active === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichOrderItems(
|
||||||
|
items: Array<{ product_id: string; quantity: number }>,
|
||||||
|
applicationId: string
|
||||||
|
): Promise<OrderItem[]> {
|
||||||
|
const enriched: OrderItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await this.getProduct(item.product_id, applicationId)
|
||||||
|
if (!product) {
|
||||||
|
throw new Error(`Product ${item.product_id} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.active) {
|
||||||
|
throw new Error(`Product ${product.name} is not available`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stock
|
||||||
|
if (product.quantity !== -1 && product.quantity < item.quantity) {
|
||||||
|
throw new Error(`Insufficient stock for ${product.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.push({
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: product.price
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateTotals(
|
||||||
|
items: OrderItem[],
|
||||||
|
stall: Stall,
|
||||||
|
shippingZoneId: string
|
||||||
|
): Promise<{
|
||||||
|
subtotal: number
|
||||||
|
shippingCost: number
|
||||||
|
total: number
|
||||||
|
exchangeRate?: number
|
||||||
|
}> {
|
||||||
|
// Calculate subtotal in original currency
|
||||||
|
const subtotalOriginal = items.reduce(
|
||||||
|
(sum, item) => sum + item.unit_price * item.quantity,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find shipping zone
|
||||||
|
const shippingZone = stall.shipping_zones.find(z => z.id === shippingZoneId)
|
||||||
|
if (!shippingZone) {
|
||||||
|
throw new Error('Invalid shipping zone')
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingOriginal = shippingZone.cost
|
||||||
|
|
||||||
|
// Convert to sats if needed
|
||||||
|
let subtotal: number
|
||||||
|
let shippingCost: number
|
||||||
|
let exchangeRate: number | undefined
|
||||||
|
|
||||||
|
if (stall.currency === 'sat') {
|
||||||
|
subtotal = subtotalOriginal
|
||||||
|
shippingCost = shippingOriginal
|
||||||
|
} else {
|
||||||
|
subtotal = await convertToSats(this.db, subtotalOriginal, stall.currency)
|
||||||
|
shippingCost = await convertToSats(this.db, shippingOriginal, stall.currency)
|
||||||
|
exchangeRate = subtotal / subtotalOriginal
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotal,
|
||||||
|
shippingCost,
|
||||||
|
total: subtotal + shippingCost,
|
||||||
|
exchangeRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reserveInventory(order: Order): Promise<void> {
|
||||||
|
for (const item of order.items) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
quantity = CASE
|
||||||
|
WHEN quantity = -1 THEN -1
|
||||||
|
ELSE quantity - ?
|
||||||
|
END
|
||||||
|
WHERE id = ? AND quantity != -1`,
|
||||||
|
[item.quantity, item.product_id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreInventory(order: Order): Promise<void> {
|
||||||
|
for (const item of order.items) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
quantity = CASE
|
||||||
|
WHEN quantity = -1 THEN -1
|
||||||
|
ELSE quantity + ?
|
||||||
|
END
|
||||||
|
WHERE id = ? AND quantity != -1`,
|
||||||
|
[item.quantity, item.product_id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCustomerStats(applicationId: string, pubkey: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO customers (pubkey, application_id, total_orders, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?)
|
||||||
|
ON CONFLICT(pubkey, application_id) DO UPDATE SET
|
||||||
|
total_orders = total_orders + 1,
|
||||||
|
last_seen_at = ?`,
|
||||||
|
[pubkey, applicationId, now, now, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCustomerSpent(
|
||||||
|
applicationId: string,
|
||||||
|
pubkey: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE customers SET
|
||||||
|
total_spent_sats = total_spent_sats + ?
|
||||||
|
WHERE pubkey = ? AND application_id = ?`,
|
||||||
|
[amount, pubkey, applicationId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/extensions/marketplace/managers/productManager.ts
Normal file
466
src/extensions/marketplace/managers/productManager.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Product, Stall, CreateProductRequest, UpdateProductRequest
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateProduct } from '../utils/validation.js'
|
||||||
|
import { buildProductEvent, buildDeleteEvent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for product
|
||||||
|
*/
|
||||||
|
interface ProductRow {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images: string // JSON array
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
categories: string // JSON array
|
||||||
|
shipping_cost: number | null
|
||||||
|
specs: string | null // JSON array
|
||||||
|
active: number
|
||||||
|
nostr_event_id: string | null
|
||||||
|
nostr_event_created_at: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Product object
|
||||||
|
*/
|
||||||
|
function rowToProduct(row: ProductRow): Product {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
stall_id: row.stall_id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
images: JSON.parse(row.images),
|
||||||
|
price: row.price,
|
||||||
|
quantity: row.quantity,
|
||||||
|
categories: JSON.parse(row.categories),
|
||||||
|
shipping_cost: row.shipping_cost ?? undefined,
|
||||||
|
specs: row.specs ? JSON.parse(row.specs) : undefined,
|
||||||
|
active: row.active === 1,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query options for listing products
|
||||||
|
*/
|
||||||
|
interface ListProductsOptions {
|
||||||
|
stall_id?: string
|
||||||
|
category?: string
|
||||||
|
active_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductManager - Handles product CRUD, inventory, and Nostr publishing
|
||||||
|
*/
|
||||||
|
export class ProductManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
applicationId: string,
|
||||||
|
stallId: string,
|
||||||
|
req: CreateProductRequest
|
||||||
|
): Promise<Product> {
|
||||||
|
// Validate request
|
||||||
|
validateProduct(req)
|
||||||
|
|
||||||
|
// Verify stall exists and belongs to application
|
||||||
|
const stallRows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[stallId, applicationId]
|
||||||
|
)
|
||||||
|
if (stallRows.length === 0) {
|
||||||
|
throw new Error('Stall not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const product: Product = {
|
||||||
|
id,
|
||||||
|
stall_id: stallId,
|
||||||
|
application_id: applicationId,
|
||||||
|
name: req.name.trim(),
|
||||||
|
description: req.description?.trim() || '',
|
||||||
|
images: req.images || [],
|
||||||
|
price: req.price,
|
||||||
|
quantity: req.quantity ?? -1, // -1 = unlimited
|
||||||
|
categories: req.categories || [],
|
||||||
|
shipping_cost: req.shipping_cost,
|
||||||
|
specs: req.specs,
|
||||||
|
active: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO products (
|
||||||
|
id, stall_id, application_id, name, description, images,
|
||||||
|
price, quantity, categories, shipping_cost, specs, active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
product.id,
|
||||||
|
product.stall_id,
|
||||||
|
product.application_id,
|
||||||
|
product.name,
|
||||||
|
product.description,
|
||||||
|
JSON.stringify(product.images),
|
||||||
|
product.price,
|
||||||
|
product.quantity,
|
||||||
|
JSON.stringify(product.categories),
|
||||||
|
product.shipping_cost ?? null,
|
||||||
|
product.specs ? JSON.stringify(product.specs) : null,
|
||||||
|
product.active ? 1 : 0,
|
||||||
|
product.created_at,
|
||||||
|
product.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish to Nostr if auto-publish enabled
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Product | null> {
|
||||||
|
const rows = await this.db.query<ProductRow>(
|
||||||
|
'SELECT * FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToProduct(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List products with optional filters
|
||||||
|
*/
|
||||||
|
async list(applicationId: string, options: ListProductsOptions = {}): Promise<Product[]> {
|
||||||
|
let sql = 'SELECT * FROM products WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (options.stall_id) {
|
||||||
|
sql += ' AND stall_id = ?'
|
||||||
|
params.push(options.stall_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.active_only !== false) {
|
||||||
|
sql += ' AND active = 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.category) {
|
||||||
|
// Search in JSON array
|
||||||
|
sql += ' AND categories LIKE ?'
|
||||||
|
params.push(`%"${options.category}"%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (options.limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(options.limit)
|
||||||
|
if (options.offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(options.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<ProductRow>(sql, params)
|
||||||
|
return rows.map(rowToProduct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateProductRequest
|
||||||
|
): Promise<Product | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Build updated product
|
||||||
|
const updated: Product = {
|
||||||
|
...existing,
|
||||||
|
name: req.name?.trim() ?? existing.name,
|
||||||
|
description: req.description?.trim() ?? existing.description,
|
||||||
|
images: req.images ?? existing.images,
|
||||||
|
price: req.price ?? existing.price,
|
||||||
|
quantity: req.quantity ?? existing.quantity,
|
||||||
|
categories: req.categories ?? existing.categories,
|
||||||
|
shipping_cost: req.shipping_cost ?? existing.shipping_cost,
|
||||||
|
specs: req.specs ?? existing.specs,
|
||||||
|
active: req.active ?? existing.active,
|
||||||
|
updated_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate merged product
|
||||||
|
validateProduct({
|
||||||
|
name: updated.name,
|
||||||
|
price: updated.price,
|
||||||
|
quantity: updated.quantity,
|
||||||
|
images: updated.images,
|
||||||
|
categories: updated.categories
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
name = ?, description = ?, images = ?, price = ?,
|
||||||
|
quantity = ?, categories = ?, shipping_cost = ?,
|
||||||
|
specs = ?, active = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
updated.name,
|
||||||
|
updated.description,
|
||||||
|
JSON.stringify(updated.images),
|
||||||
|
updated.price,
|
||||||
|
updated.quantity,
|
||||||
|
JSON.stringify(updated.categories),
|
||||||
|
updated.shipping_cost ?? null,
|
||||||
|
updated.specs ? JSON.stringify(updated.specs) : null,
|
||||||
|
updated.active ? 1 : 0,
|
||||||
|
updated.updated_at,
|
||||||
|
id,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish to Nostr
|
||||||
|
if (req.publish_to_nostr !== false && updated.active) {
|
||||||
|
await this.publishToNostr(updated)
|
||||||
|
} else if (!updated.active && existing.nostr_event_id) {
|
||||||
|
// Unpublish if deactivated
|
||||||
|
await this.unpublishFromNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a product
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const product = await this.get(id, applicationId)
|
||||||
|
if (!product) return false
|
||||||
|
|
||||||
|
// Check for pending orders
|
||||||
|
const orders = await this.db.query(
|
||||||
|
`SELECT COUNT(*) as count FROM orders
|
||||||
|
WHERE items LIKE ? AND status IN ('pending', 'paid')`,
|
||||||
|
[`%"${id}"%`]
|
||||||
|
)
|
||||||
|
if ((orders[0] as any).count > 0) {
|
||||||
|
throw new Error('Cannot delete product with pending orders')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpublish from Nostr
|
||||||
|
if (product.nostr_event_id) {
|
||||||
|
await this.unpublishFromNostr(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await this.db.execute(
|
||||||
|
'DELETE FROM products WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product quantity (for inventory management)
|
||||||
|
*/
|
||||||
|
async updateQuantity(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
delta: number
|
||||||
|
): Promise<number> {
|
||||||
|
const product = await this.get(id, applicationId)
|
||||||
|
if (!product) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -1 means unlimited
|
||||||
|
if (product.quantity === -1) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuantity = Math.max(0, product.quantity + delta)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
'UPDATE products SET quantity = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[newQuantity, Math.floor(Date.now() / 1000), id]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish if quantity changed and product is published
|
||||||
|
if (product.nostr_event_id) {
|
||||||
|
const updated = await this.get(id, applicationId)
|
||||||
|
if (updated) await this.publishToNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if products are in stock
|
||||||
|
*/
|
||||||
|
async checkStock(
|
||||||
|
items: Array<{ product_id: string; quantity: number }>,
|
||||||
|
applicationId: string
|
||||||
|
): Promise<{ available: boolean; unavailable: string[] }> {
|
||||||
|
const unavailable: string[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await this.get(item.product_id, applicationId)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.active) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// -1 means unlimited
|
||||||
|
if (product.quantity !== -1 && product.quantity < item.quantity) {
|
||||||
|
unavailable.push(item.product_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: unavailable.length === 0,
|
||||||
|
unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish product to Nostr (kind 30018)
|
||||||
|
*/
|
||||||
|
async publishToNostr(product: Product): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Get stall for currency info
|
||||||
|
const stallRows = await this.db.query(
|
||||||
|
'SELECT * FROM stalls WHERE id = ?',
|
||||||
|
[product.stall_id]
|
||||||
|
)
|
||||||
|
if (stallRows.length === 0) return null
|
||||||
|
|
||||||
|
const stall = stallRows[0] as any
|
||||||
|
const stallObj: Stall = {
|
||||||
|
...stall,
|
||||||
|
shipping_zones: JSON.parse(stall.shipping_zones)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get application's Nostr pubkey
|
||||||
|
const app = await this.ctx.getApplication(product.application_id)
|
||||||
|
if (!app || !app.nostr_public) {
|
||||||
|
console.warn(`No Nostr pubkey for application ${product.application_id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the event
|
||||||
|
const event = buildProductEvent(product, stallObj, app.nostr_public)
|
||||||
|
|
||||||
|
// Sign and publish
|
||||||
|
const eventId = await this.ctx.publishNostrEvent(event)
|
||||||
|
|
||||||
|
// Update database with event info
|
||||||
|
if (eventId) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
nostr_event_id = ?,
|
||||||
|
nostr_event_created_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[eventId, event.created_at, product.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to publish product to Nostr:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpublish product from Nostr (kind 5 deletion)
|
||||||
|
*/
|
||||||
|
async unpublishFromNostr(product: Product): Promise<boolean> {
|
||||||
|
if (!product.nostr_event_id) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await this.ctx.getApplication(product.application_id)
|
||||||
|
if (!app || !app.nostr_public) return false
|
||||||
|
|
||||||
|
const deleteEvent = buildDeleteEvent(
|
||||||
|
product.nostr_event_id,
|
||||||
|
30018, // PRODUCT kind
|
||||||
|
app.nostr_public,
|
||||||
|
`Product ${product.name} removed`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.ctx.publishNostrEvent(deleteEvent)
|
||||||
|
|
||||||
|
// Clear event info
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE products SET
|
||||||
|
nostr_event_id = NULL,
|
||||||
|
nostr_event_created_at = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to unpublish product from Nostr:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republish all products for a stall
|
||||||
|
*/
|
||||||
|
async republishAllForStall(stallId: string, applicationId: string): Promise<number> {
|
||||||
|
const products = await this.list(applicationId, {
|
||||||
|
stall_id: stallId,
|
||||||
|
active_only: true
|
||||||
|
})
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const product of products) {
|
||||||
|
const eventId = await this.publishToNostr(product)
|
||||||
|
if (eventId) count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext, ExtensionDatabase,
|
||||||
|
Stall, CreateStallRequest, UpdateStallRequest
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId, validateStall } from '../utils/validation.js'
|
||||||
|
import { buildStallEvent, buildDeleteEvent } from '../nostr/events.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row for stall
|
||||||
|
*/
|
||||||
|
interface StallRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: string
|
||||||
|
shipping_zones: string // JSON
|
||||||
|
image_url: string | null
|
||||||
|
nostr_event_id: string | null
|
||||||
|
nostr_event_created_at: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert database row to Stall object
|
||||||
|
*/
|
||||||
|
function rowToStall(row: StallRow): Stall {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
currency: row.currency as Stall['currency'],
|
||||||
|
shipping_zones: JSON.parse(row.shipping_zones),
|
||||||
|
image_url: row.image_url || undefined,
|
||||||
|
nostr_event_id: row.nostr_event_id || undefined,
|
||||||
|
nostr_event_created_at: row.nostr_event_created_at || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StallManager - Handles stall CRUD and Nostr publishing
|
||||||
|
*/
|
||||||
|
export class StallManager {
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new stall
|
||||||
|
*/
|
||||||
|
async create(applicationId: string, req: CreateStallRequest): Promise<Stall> {
|
||||||
|
// Validate request
|
||||||
|
validateStall(req)
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
// Assign IDs to shipping zones if not provided
|
||||||
|
const shippingZones = req.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id || generateId(),
|
||||||
|
name: zone.name,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions
|
||||||
|
}))
|
||||||
|
|
||||||
|
const stall: Stall = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
name: req.name.trim(),
|
||||||
|
description: req.description?.trim() || '',
|
||||||
|
currency: req.currency,
|
||||||
|
shipping_zones: shippingZones,
|
||||||
|
image_url: req.image_url,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO stalls (
|
||||||
|
id, application_id, name, description, currency,
|
||||||
|
shipping_zones, image_url, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
stall.id,
|
||||||
|
stall.application_id,
|
||||||
|
stall.name,
|
||||||
|
stall.description,
|
||||||
|
stall.currency,
|
||||||
|
JSON.stringify(stall.shipping_zones),
|
||||||
|
stall.image_url || null,
|
||||||
|
stall.created_at,
|
||||||
|
stall.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish to Nostr if auto-publish enabled
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(stall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stall
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stall by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<Stall | null> {
|
||||||
|
const rows = await this.db.query<StallRow>(
|
||||||
|
'SELECT * FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToStall(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stalls for an application
|
||||||
|
*/
|
||||||
|
async list(applicationId: string): Promise<Stall[]> {
|
||||||
|
const rows = await this.db.query<StallRow>(
|
||||||
|
'SELECT * FROM stalls WHERE application_id = ? ORDER BY created_at DESC',
|
||||||
|
[applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows.map(rowToStall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a stall
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateStallRequest
|
||||||
|
): Promise<Stall | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Build updated stall
|
||||||
|
const updated: Stall = {
|
||||||
|
...existing,
|
||||||
|
name: req.name?.trim() ?? existing.name,
|
||||||
|
description: req.description?.trim() ?? existing.description,
|
||||||
|
currency: req.currency ?? existing.currency,
|
||||||
|
shipping_zones: req.shipping_zones ?? existing.shipping_zones,
|
||||||
|
image_url: req.image_url ?? existing.image_url,
|
||||||
|
updated_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate merged stall
|
||||||
|
validateStall({
|
||||||
|
name: updated.name,
|
||||||
|
currency: updated.currency,
|
||||||
|
shipping_zones: updated.shipping_zones
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
name = ?, description = ?, currency = ?,
|
||||||
|
shipping_zones = ?, image_url = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
updated.name,
|
||||||
|
updated.description,
|
||||||
|
updated.currency,
|
||||||
|
JSON.stringify(updated.shipping_zones),
|
||||||
|
updated.image_url || null,
|
||||||
|
updated.updated_at,
|
||||||
|
id,
|
||||||
|
applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Republish to Nostr (updates parameterized replaceable event)
|
||||||
|
if (req.publish_to_nostr !== false) {
|
||||||
|
await this.publishToNostr(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a stall
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const stall = await this.get(id, applicationId)
|
||||||
|
if (!stall) return false
|
||||||
|
|
||||||
|
// Check for products
|
||||||
|
const products = await this.db.query(
|
||||||
|
'SELECT COUNT(*) as count FROM products WHERE stall_id = ?',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if ((products[0] as any).count > 0) {
|
||||||
|
throw new Error('Cannot delete stall with existing products')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish deletion event to Nostr if it was published
|
||||||
|
if (stall.nostr_event_id) {
|
||||||
|
await this.unpublishFromNostr(stall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await this.db.execute(
|
||||||
|
'DELETE FROM stalls WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish stall to Nostr (kind 30017)
|
||||||
|
*/
|
||||||
|
async publishToNostr(stall: Stall): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Get application's Nostr pubkey
|
||||||
|
const app = await this.ctx.getApplication(stall.application_id)
|
||||||
|
if (!app || !app.nostr_public) {
|
||||||
|
console.warn(`No Nostr pubkey for application ${stall.application_id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the event
|
||||||
|
const event = buildStallEvent(stall, app.nostr_public)
|
||||||
|
|
||||||
|
// Sign and publish via context
|
||||||
|
const eventId = await this.ctx.publishNostrEvent(event)
|
||||||
|
|
||||||
|
// Update database with event info
|
||||||
|
if (eventId) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
nostr_event_id = ?,
|
||||||
|
nostr_event_created_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[eventId, event.created_at, stall.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventId
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to publish stall to Nostr:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpublish stall from Nostr (kind 5 deletion)
|
||||||
|
*/
|
||||||
|
async unpublishFromNostr(stall: Stall): Promise<boolean> {
|
||||||
|
if (!stall.nostr_event_id) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await this.ctx.getApplication(stall.application_id)
|
||||||
|
if (!app || !app.nostr_public) return false
|
||||||
|
|
||||||
|
const deleteEvent = buildDeleteEvent(
|
||||||
|
stall.nostr_event_id,
|
||||||
|
30017, // STALL kind
|
||||||
|
app.nostr_public,
|
||||||
|
`Stall ${stall.name} removed`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.ctx.publishNostrEvent(deleteEvent)
|
||||||
|
|
||||||
|
// Clear event info from database
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE stalls SET
|
||||||
|
nostr_event_id = NULL,
|
||||||
|
nostr_event_created_at = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
[stall.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to unpublish stall from Nostr:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Republish all stalls for an application
|
||||||
|
*/
|
||||||
|
async republishAll(applicationId: string): Promise<number> {
|
||||||
|
const stalls = await this.list(applicationId)
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const stall of stalls) {
|
||||||
|
const eventId = await this.publishToNostr(stall)
|
||||||
|
if (eventId) count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/extensions/marketplace/migrations.ts
Normal file
194
src/extensions/marketplace/migrations.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { ExtensionDatabase } from './types.js'
|
||||||
|
|
||||||
|
export interface ExtensionMigration {
|
||||||
|
version: number
|
||||||
|
description: string
|
||||||
|
up(db: ExtensionDatabase): Promise<void>
|
||||||
|
down?(db: ExtensionDatabase): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrations: ExtensionMigration[] = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
description: 'Create core marketplace tables',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
// Stalls table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
currency TEXT NOT NULL DEFAULT 'sat',
|
||||||
|
shipping_zones TEXT NOT NULL DEFAULT '[]',
|
||||||
|
image_url TEXT,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
nostr_event_created_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stalls_app
|
||||||
|
ON stalls(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Products table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
stall_id TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
images TEXT NOT NULL DEFAULT '[]',
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT -1,
|
||||||
|
categories TEXT NOT NULL DEFAULT '[]',
|
||||||
|
shipping_cost INTEGER,
|
||||||
|
specs TEXT DEFAULT '[]',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
nostr_event_created_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (stall_id) REFERENCES stalls(id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_stall
|
||||||
|
ON products(stall_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_app
|
||||||
|
ON products(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_active
|
||||||
|
ON products(active, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Orders table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
stall_id TEXT NOT NULL,
|
||||||
|
customer_pubkey TEXT NOT NULL,
|
||||||
|
customer_app_user_id TEXT,
|
||||||
|
items TEXT NOT NULL,
|
||||||
|
shipping_zone_id TEXT NOT NULL,
|
||||||
|
shipping_address TEXT NOT NULL,
|
||||||
|
contact TEXT NOT NULL,
|
||||||
|
subtotal_sats INTEGER NOT NULL,
|
||||||
|
shipping_sats INTEGER NOT NULL,
|
||||||
|
total_sats INTEGER NOT NULL,
|
||||||
|
original_currency TEXT NOT NULL,
|
||||||
|
exchange_rate REAL,
|
||||||
|
invoice TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
paid_at INTEGER,
|
||||||
|
shipped_at INTEGER,
|
||||||
|
completed_at INTEGER
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_app
|
||||||
|
ON orders(application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_customer
|
||||||
|
ON orders(customer_pubkey, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_status
|
||||||
|
ON orders(status, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Direct messages table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
order_id TEXT,
|
||||||
|
customer_pubkey TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
incoming INTEGER NOT NULL,
|
||||||
|
nostr_event_id TEXT NOT NULL UNIQUE,
|
||||||
|
nostr_event_created_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
read INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_customer
|
||||||
|
ON direct_messages(customer_pubkey, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_order
|
||||||
|
ON direct_messages(order_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_unread
|
||||||
|
ON direct_messages(read, application_id)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Customers table
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
about TEXT,
|
||||||
|
picture TEXT,
|
||||||
|
total_orders INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spent_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unread_messages INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (pubkey, application_id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute('DROP TABLE IF EXISTS customers')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS direct_messages')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS orders')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS products')
|
||||||
|
await db.execute('DROP TABLE IF EXISTS stalls')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
description: 'Add exchange rates cache table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS exchange_rates (
|
||||||
|
currency TEXT PRIMARY KEY,
|
||||||
|
rate_sats INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute('DROP TABLE IF EXISTS exchange_rates')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
164
src/extensions/marketplace/nostr/events.ts
Normal file
164
src/extensions/marketplace/nostr/events.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { EVENT_KINDS } from './kinds.js'
|
||||||
|
import {
|
||||||
|
Stall, Product, Order,
|
||||||
|
NIP15Stall, NIP15Product, NIP15PaymentRequest, NIP15OrderStatusUpdate
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned Nostr event structure
|
||||||
|
*/
|
||||||
|
export interface UnsignedEvent {
|
||||||
|
kind: number
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build NIP-15 stall event (kind 30017)
|
||||||
|
*/
|
||||||
|
export function buildStallEvent(stall: Stall, pubkey: string): UnsignedEvent {
|
||||||
|
const nip15Stall: NIP15Stall = {
|
||||||
|
id: stall.id,
|
||||||
|
name: stall.name,
|
||||||
|
description: stall.description,
|
||||||
|
currency: stall.currency,
|
||||||
|
shipping: stall.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id,
|
||||||
|
name: zone.name,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.STALL,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', stall.id], // Unique identifier for parameterized replaceable
|
||||||
|
],
|
||||||
|
content: JSON.stringify(nip15Stall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build NIP-15 product event (kind 30018)
|
||||||
|
*/
|
||||||
|
export function buildProductEvent(product: Product, stall: Stall, pubkey: string): UnsignedEvent {
|
||||||
|
const nip15Product: NIP15Product = {
|
||||||
|
id: product.id,
|
||||||
|
stall_id: product.stall_id,
|
||||||
|
name: product.name,
|
||||||
|
description: product.description,
|
||||||
|
images: product.images,
|
||||||
|
currency: stall.currency,
|
||||||
|
price: product.price,
|
||||||
|
quantity: product.quantity,
|
||||||
|
specs: product.specs?.map(s => [s.key, s.value] as [string, string])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add shipping costs if different from stall defaults
|
||||||
|
if (product.shipping_cost !== undefined) {
|
||||||
|
nip15Product.shipping = stall.shipping_zones.map(zone => ({
|
||||||
|
id: zone.id,
|
||||||
|
cost: product.shipping_cost!
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', product.id], // Unique identifier
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add category tags
|
||||||
|
for (const category of product.categories) {
|
||||||
|
tags.push(['t', category])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.PRODUCT,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: JSON.stringify(nip15Product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build deletion event (kind 5)
|
||||||
|
*/
|
||||||
|
export function buildDeleteEvent(
|
||||||
|
eventId: string,
|
||||||
|
kind: number,
|
||||||
|
pubkey: string,
|
||||||
|
reason?: string
|
||||||
|
): UnsignedEvent {
|
||||||
|
const tags: string[][] = [
|
||||||
|
['e', eventId],
|
||||||
|
['k', String(kind)]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.DELETE,
|
||||||
|
pubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: reason || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build payment request DM content
|
||||||
|
*/
|
||||||
|
export function buildPaymentRequestContent(
|
||||||
|
order: Order,
|
||||||
|
invoice: string,
|
||||||
|
message?: string
|
||||||
|
): NIP15PaymentRequest {
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
type: 1,
|
||||||
|
message: message || `Payment request for order ${order.id}. Total: ${order.total_sats} sats`,
|
||||||
|
payment_options: [
|
||||||
|
{ type: 'ln', link: invoice }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build order status update DM content
|
||||||
|
*/
|
||||||
|
export function buildOrderStatusContent(
|
||||||
|
order: Order,
|
||||||
|
message: string
|
||||||
|
): NIP15OrderStatusUpdate {
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
type: 2,
|
||||||
|
message,
|
||||||
|
paid: order.status !== 'pending',
|
||||||
|
shipped: order.status === 'shipped' || order.status === 'completed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build encrypted DM event (kind 4)
|
||||||
|
* Note: Actual encryption happens in the manager using NIP-44
|
||||||
|
*/
|
||||||
|
export function buildDirectMessageEvent(
|
||||||
|
content: string, // Already encrypted
|
||||||
|
fromPubkey: string,
|
||||||
|
toPubkey: string
|
||||||
|
): UnsignedEvent {
|
||||||
|
return {
|
||||||
|
kind: EVENT_KINDS.DIRECT_MESSAGE,
|
||||||
|
pubkey: fromPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['p', toPubkey]
|
||||||
|
],
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* NIP-15 Event Kinds
|
||||||
|
* https://github.com/nostr-protocol/nips/blob/master/15.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EVENT_KINDS = {
|
||||||
|
// Standard kinds
|
||||||
|
METADATA: 0, // User profile
|
||||||
|
TEXT_NOTE: 1, // Short text
|
||||||
|
DIRECT_MESSAGE: 4, // Encrypted DM (NIP-04)
|
||||||
|
DELETE: 5, // Event deletion
|
||||||
|
|
||||||
|
// NIP-15 Marketplace kinds
|
||||||
|
STALL: 30017, // Parameterized replaceable: merchant stall
|
||||||
|
PRODUCT: 30018, // Parameterized replaceable: product listing
|
||||||
|
|
||||||
|
// Lightning.Pub RPC kinds (for native clients)
|
||||||
|
RPC_REQUEST: 21001, // Encrypted RPC request
|
||||||
|
RPC_RESPONSE: 21002, // Encrypted RPC response
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type EventKind = typeof EVENT_KINDS[keyof typeof EVENT_KINDS]
|
||||||
|
|
||||||
|
// Marketplace message types (in DM content)
|
||||||
|
export const MESSAGE_TYPES = {
|
||||||
|
ORDER_REQUEST: 0,
|
||||||
|
PAYMENT_REQUEST: 1,
|
||||||
|
ORDER_STATUS: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MessageTypeNum = typeof MESSAGE_TYPES[keyof typeof MESSAGE_TYPES]
|
||||||
162
src/extensions/marketplace/nostr/parser.ts
Normal file
162
src/extensions/marketplace/nostr/parser.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { EVENT_KINDS, MESSAGE_TYPES } from './kinds.js'
|
||||||
|
import {
|
||||||
|
NIP15Stall, NIP15Product, NIP15OrderRequest,
|
||||||
|
Stall, Product, MessageType
|
||||||
|
} from '../types.js'
|
||||||
|
import { generateId } from '../utils/validation.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr event structure
|
||||||
|
*/
|
||||||
|
export interface NostrEvent {
|
||||||
|
id: string
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
sig?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 stall event
|
||||||
|
*/
|
||||||
|
export function parseStallEvent(event: NostrEvent): Partial<Stall> | null {
|
||||||
|
if (event.kind !== EVENT_KINDS.STALL) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content) as NIP15Stall
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: content.id || dTag?.[1] || generateId(),
|
||||||
|
name: content.name,
|
||||||
|
description: content.description || '',
|
||||||
|
currency: content.currency as any || 'sat',
|
||||||
|
shipping_zones: (content.shipping || []).map(zone => ({
|
||||||
|
id: zone.id || generateId(),
|
||||||
|
name: zone.name || zone.id,
|
||||||
|
cost: zone.cost,
|
||||||
|
regions: zone.regions || []
|
||||||
|
})),
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 product event
|
||||||
|
*/
|
||||||
|
export function parseProductEvent(event: NostrEvent): Partial<Product> | null {
|
||||||
|
if (event.kind !== EVENT_KINDS.PRODUCT) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(event.content) as NIP15Product
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')
|
||||||
|
const categoryTags = event.tags.filter(t => t[0] === 't').map(t => t[1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: content.id || dTag?.[1] || generateId(),
|
||||||
|
stall_id: content.stall_id,
|
||||||
|
name: content.name,
|
||||||
|
description: content.description || '',
|
||||||
|
images: content.images || [],
|
||||||
|
price: content.price,
|
||||||
|
quantity: content.quantity ?? -1,
|
||||||
|
categories: categoryTags.length > 0 ? categoryTags : [],
|
||||||
|
specs: content.specs?.map(([key, value]) => ({ key, value })),
|
||||||
|
nostr_event_id: event.id,
|
||||||
|
nostr_event_created_at: event.created_at
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NIP-15 order request from DM
|
||||||
|
*/
|
||||||
|
export function parseOrderRequest(content: string): NIP15OrderRequest | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.type !== MESSAGE_TYPES.ORDER_REQUEST) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parsed.id || generateId(),
|
||||||
|
type: 0,
|
||||||
|
name: parsed.name,
|
||||||
|
address: parsed.address,
|
||||||
|
message: parsed.message,
|
||||||
|
contact: parsed.contact || {},
|
||||||
|
items: parsed.items || [],
|
||||||
|
shipping_id: parsed.shipping_id
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine message type from decrypted content
|
||||||
|
*/
|
||||||
|
export function parseMessageType(content: string): { type: MessageType; parsed: any } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
|
||||||
|
if (typeof parsed.type === 'number') {
|
||||||
|
switch (parsed.type) {
|
||||||
|
case MESSAGE_TYPES.ORDER_REQUEST:
|
||||||
|
return { type: 'order_request', parsed }
|
||||||
|
case MESSAGE_TYPES.PAYMENT_REQUEST:
|
||||||
|
return { type: 'payment_request', parsed }
|
||||||
|
case MESSAGE_TYPES.ORDER_STATUS:
|
||||||
|
return { type: 'order_status', parsed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has items and shipping_id, treat as order request
|
||||||
|
if (parsed.items && parsed.shipping_id) {
|
||||||
|
return { type: 'order_request', parsed: { ...parsed, type: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'plain', parsed: content }
|
||||||
|
} catch {
|
||||||
|
return { type: 'plain', parsed: content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pubkey from p tag
|
||||||
|
*/
|
||||||
|
export function extractRecipientPubkey(event: NostrEvent): string | null {
|
||||||
|
const pTag = event.tags.find(t => t[0] === 'p')
|
||||||
|
return pTag?.[1] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event is a deletion event for a specific kind
|
||||||
|
*/
|
||||||
|
export function isDeletionEvent(event: NostrEvent, targetKind?: number): boolean {
|
||||||
|
if (event.kind !== EVENT_KINDS.DELETE) return false
|
||||||
|
|
||||||
|
if (targetKind !== undefined) {
|
||||||
|
const kTag = event.tags.find(t => t[0] === 'k')
|
||||||
|
return kTag?.[1] === String(targetKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deleted event IDs from a deletion event
|
||||||
|
*/
|
||||||
|
export function getDeletedEventIds(event: NostrEvent): string[] {
|
||||||
|
if (event.kind !== EVENT_KINDS.DELETE) return []
|
||||||
|
|
||||||
|
return event.tags
|
||||||
|
.filter(t => t[0] === 'e')
|
||||||
|
.map(t => t[1])
|
||||||
|
}
|
||||||
418
src/extensions/marketplace/types.ts
Normal file
418
src/extensions/marketplace/types.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
/**
|
||||||
|
* Marketplace Extension Types
|
||||||
|
* NIP-15 compliant marketplace for Lightning.Pub
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export base extension types
|
||||||
|
export {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
ApplicationInfo,
|
||||||
|
NostrEvent,
|
||||||
|
UnsignedNostrEvent,
|
||||||
|
PaymentReceivedData,
|
||||||
|
RpcMethodHandler
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ShippingZone {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cost: number // In stall's currency
|
||||||
|
regions: string[] // ISO country codes or region names
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stall {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: Currency
|
||||||
|
shipping_zones: ShippingZone[]
|
||||||
|
image_url?: string
|
||||||
|
|
||||||
|
// Nostr sync
|
||||||
|
nostr_event_id?: string
|
||||||
|
nostr_event_created_at?: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images: string[]
|
||||||
|
price: number // In stall's currency
|
||||||
|
quantity: number // Available stock (-1 = unlimited)
|
||||||
|
categories: string[]
|
||||||
|
|
||||||
|
// Shipping override (optional, otherwise use stall zones)
|
||||||
|
shipping_cost?: number
|
||||||
|
|
||||||
|
// Product options/variants (future)
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
active: boolean
|
||||||
|
|
||||||
|
// Nostr sync
|
||||||
|
nostr_event_id?: string
|
||||||
|
nostr_event_created_at?: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpec {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItem {
|
||||||
|
product_id: string
|
||||||
|
product_name: string // Snapshot at order time
|
||||||
|
quantity: number
|
||||||
|
unit_price: number // In stall currency, at order time
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactInfo {
|
||||||
|
nostr?: string // Nostr pubkey
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'pending' // Awaiting payment
|
||||||
|
| 'paid' // Payment received
|
||||||
|
| 'processing' // Merchant preparing
|
||||||
|
| 'shipped' // In transit
|
||||||
|
| 'completed' // Delivered
|
||||||
|
| 'cancelled' // Cancelled by merchant or customer
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
stall_id: string
|
||||||
|
|
||||||
|
// Customer
|
||||||
|
customer_pubkey: string // Nostr pubkey
|
||||||
|
customer_app_user_id?: string // If registered user
|
||||||
|
|
||||||
|
// Order details
|
||||||
|
items: OrderItem[]
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: ContactInfo
|
||||||
|
|
||||||
|
// Pricing (all in sats)
|
||||||
|
subtotal_sats: number
|
||||||
|
shipping_sats: number
|
||||||
|
total_sats: number
|
||||||
|
|
||||||
|
// Original currency info (for display)
|
||||||
|
original_currency: Currency
|
||||||
|
exchange_rate?: number // Rate at order time
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
invoice?: string // BOLT11 invoice
|
||||||
|
invoice_id?: string // Internal invoice reference
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: OrderStatus
|
||||||
|
|
||||||
|
// Nostr
|
||||||
|
nostr_event_id?: string // Order request event
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
created_at: number
|
||||||
|
paid_at?: number
|
||||||
|
shipped_at?: number
|
||||||
|
completed_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectMessage {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
order_id?: string // Optional link to order
|
||||||
|
|
||||||
|
customer_pubkey: string
|
||||||
|
|
||||||
|
// Message content
|
||||||
|
message_type: MessageType
|
||||||
|
content: string // Decrypted content
|
||||||
|
|
||||||
|
// Direction
|
||||||
|
incoming: boolean // true = from customer, false = to customer
|
||||||
|
|
||||||
|
// Nostr
|
||||||
|
nostr_event_id: string
|
||||||
|
nostr_event_created_at: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
created_at: number
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageType =
|
||||||
|
| 'plain' // Regular text message
|
||||||
|
| 'order_request' // Customer order (type 0)
|
||||||
|
| 'payment_request' // Merchant payment request (type 1)
|
||||||
|
| 'order_status' // Order update (type 2)
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
pubkey: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Profile (from kind 0)
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
total_orders: number
|
||||||
|
total_spent_sats: number
|
||||||
|
unread_messages: number
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
first_seen_at: number
|
||||||
|
last_seen_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Currency Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Currency = 'sat' | 'btc' | 'usd' | 'eur' | 'gbp' | 'cad' | 'aud' | 'jpy'
|
||||||
|
|
||||||
|
export interface ExchangeRate {
|
||||||
|
currency: Currency
|
||||||
|
rate_sats: number // How many sats per 1 unit of currency
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NIP-15 Event Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NIP15Stall {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
currency: string
|
||||||
|
shipping: NIP15ShippingZone[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15ShippingZone {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
cost: number
|
||||||
|
regions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15Product {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
currency: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
specs?: Array<[string, string]>
|
||||||
|
shipping?: NIP15ProductShipping[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15ProductShipping {
|
||||||
|
id: string // Zone ID
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15OrderRequest {
|
||||||
|
id: string
|
||||||
|
type: 0
|
||||||
|
name?: string
|
||||||
|
address?: string
|
||||||
|
message?: string
|
||||||
|
contact: {
|
||||||
|
nostr?: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
items: Array<{
|
||||||
|
product_id: string
|
||||||
|
quantity: number
|
||||||
|
}>
|
||||||
|
shipping_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15PaymentRequest {
|
||||||
|
id: string
|
||||||
|
type: 1
|
||||||
|
message?: string
|
||||||
|
payment_options: Array<{
|
||||||
|
type: 'ln' | 'url' | 'btc'
|
||||||
|
link: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NIP15OrderStatusUpdate {
|
||||||
|
id: string
|
||||||
|
type: 2
|
||||||
|
message: string
|
||||||
|
paid?: boolean
|
||||||
|
shipped?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RPC Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Stall operations
|
||||||
|
export interface CreateStallRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
currency: Currency
|
||||||
|
shipping_zones: Array<Omit<ShippingZone, 'id'> & { id?: string }>
|
||||||
|
image_url?: string
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStallRequest {
|
||||||
|
stall_id: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
currency?: Currency
|
||||||
|
shipping_zones?: ShippingZone[]
|
||||||
|
image_url?: string
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetStallRequest {
|
||||||
|
stall_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListStallsRequest {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product operations
|
||||||
|
export interface CreateProductRequest {
|
||||||
|
stall_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
price: number
|
||||||
|
quantity?: number // Default: -1 (unlimited)
|
||||||
|
categories?: string[]
|
||||||
|
shipping_cost?: number
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
active?: boolean
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductRequest {
|
||||||
|
product_id: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
images?: string[]
|
||||||
|
price?: number
|
||||||
|
quantity?: number
|
||||||
|
categories?: string[]
|
||||||
|
shipping_cost?: number
|
||||||
|
specs?: ProductSpec[]
|
||||||
|
active?: boolean
|
||||||
|
publish_to_nostr?: boolean // Default: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetProductRequest {
|
||||||
|
product_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProductsRequest {
|
||||||
|
stall_id?: string
|
||||||
|
category?: string
|
||||||
|
active_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order operations
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
stall_id: string
|
||||||
|
items: Array<{ product_id: string; quantity: number }>
|
||||||
|
shipping_zone_id: string
|
||||||
|
shipping_address: string
|
||||||
|
contact: ContactInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetOrderRequest {
|
||||||
|
order_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListOrdersRequest {
|
||||||
|
stall_id?: string
|
||||||
|
status?: OrderStatus
|
||||||
|
customer_pubkey?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrderStatusRequest {
|
||||||
|
order_id: string
|
||||||
|
status: OrderStatus
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message operations
|
||||||
|
export interface SendMessageRequest {
|
||||||
|
customer_pubkey: string
|
||||||
|
message: string
|
||||||
|
order_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMessagesRequest {
|
||||||
|
customer_pubkey?: string
|
||||||
|
order_id?: string
|
||||||
|
unread_only?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkMessagesReadRequest {
|
||||||
|
customer_pubkey?: string
|
||||||
|
message_ids?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer operations
|
||||||
|
export interface ListCustomersRequest {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCustomerRequest {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extension Context Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MarketplaceContext {
|
||||||
|
application_id: string
|
||||||
|
application_pubkey: string
|
||||||
|
user_id: string
|
||||||
|
is_owner: boolean
|
||||||
|
}
|
||||||
205
src/extensions/marketplace/utils/currency.ts
Normal file
205
src/extensions/marketplace/utils/currency.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange rate data
|
||||||
|
*/
|
||||||
|
interface ExchangeRateData {
|
||||||
|
currency: string
|
||||||
|
rate_sats: number // How many sats per 1 unit of currency
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache duration: 10 minutes
|
||||||
|
const CACHE_DURATION_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
// In-memory cache for rates
|
||||||
|
const rateCache = new Map<string, { rate: number; timestamp: number }>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported fiat currencies
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_CURRENCIES = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy'] as const
|
||||||
|
export type SupportedCurrency = typeof SUPPORTED_CURRENCIES[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchange rate from cache or database
|
||||||
|
*/
|
||||||
|
async function getCachedRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: string
|
||||||
|
): Promise<number | null> {
|
||||||
|
// Check memory cache first
|
||||||
|
const cached = rateCache.get(currency)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) {
|
||||||
|
return cached.rate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database cache
|
||||||
|
const result = await db.query<ExchangeRateData>(
|
||||||
|
'SELECT * FROM exchange_rates WHERE currency = ?',
|
||||||
|
[currency]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const row = result[0]
|
||||||
|
if (Date.now() - row.updated_at * 1000 < CACHE_DURATION_MS) {
|
||||||
|
rateCache.set(currency, { rate: row.rate_sats, timestamp: row.updated_at * 1000 })
|
||||||
|
return row.rate_sats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save exchange rate to cache and database
|
||||||
|
*/
|
||||||
|
async function saveRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: string,
|
||||||
|
rateSats: number
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// Save to memory cache
|
||||||
|
rateCache.set(currency, { rate: rateSats, timestamp: now * 1000 })
|
||||||
|
|
||||||
|
// Save to database (upsert)
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO exchange_rates (currency, rate_sats, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(currency) DO UPDATE SET
|
||||||
|
rate_sats = excluded.rate_sats,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
[currency, rateSats, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current BTC price from public API
|
||||||
|
* Returns price in USD per BTC
|
||||||
|
*/
|
||||||
|
async function fetchBtcPrice(): Promise<number> {
|
||||||
|
// Try CoinGecko first (free, no API key)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd,eur,gbp,cad,aud,jpy'
|
||||||
|
)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return data.bitcoin.usd
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to backup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup: use a hardcoded fallback (should be updated periodically)
|
||||||
|
// This is a safety net - in production you'd want multiple API sources
|
||||||
|
console.warn('Failed to fetch exchange rate, using fallback')
|
||||||
|
return 100000 // Fallback BTC price in USD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exchange rate: sats per 1 unit of currency
|
||||||
|
*/
|
||||||
|
export async function getExchangeRate(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
// Bitcoin denominations are fixed
|
||||||
|
if (currency === 'sat') return 1
|
||||||
|
if (currency === 'btc') return 100_000_000
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = await getCachedRate(db, currency)
|
||||||
|
if (cached !== null) return cached
|
||||||
|
|
||||||
|
// Fetch fresh rate
|
||||||
|
const btcPriceUsd = await fetchBtcPrice()
|
||||||
|
const satsPerBtc = 100_000_000
|
||||||
|
|
||||||
|
// Calculate rates for all fiat currencies
|
||||||
|
// Using approximate cross-rates (in production, fetch all from API)
|
||||||
|
const usdRates: Record<string, number> = {
|
||||||
|
usd: 1,
|
||||||
|
eur: 0.92,
|
||||||
|
gbp: 0.79,
|
||||||
|
cad: 1.36,
|
||||||
|
aud: 1.53,
|
||||||
|
jpy: 149.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sats per 1 unit of each currency
|
||||||
|
for (const [curr, usdRate] of Object.entries(usdRates)) {
|
||||||
|
const priceInCurrency = btcPriceUsd * usdRate
|
||||||
|
const satsPerUnit = Math.round(satsPerBtc / priceInCurrency)
|
||||||
|
await saveRate(db, curr, satsPerUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return requested currency rate
|
||||||
|
const rate = await getCachedRate(db, currency)
|
||||||
|
return rate || 1 // Fallback to 1:1 if something went wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from a currency to sats
|
||||||
|
*/
|
||||||
|
export async function convertToSats(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
amount: number,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
if (currency === 'sat') return Math.round(amount)
|
||||||
|
|
||||||
|
const rate = await getExchangeRate(db, currency)
|
||||||
|
return Math.round(amount * rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from sats to a currency
|
||||||
|
*/
|
||||||
|
export async function convertFromSats(
|
||||||
|
db: ExtensionDatabase,
|
||||||
|
sats: number,
|
||||||
|
currency: SupportedCurrency
|
||||||
|
): Promise<number> {
|
||||||
|
if (currency === 'sat') return sats
|
||||||
|
|
||||||
|
const rate = await getExchangeRate(db, currency)
|
||||||
|
return sats / rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format amount with currency symbol
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: SupportedCurrency): string {
|
||||||
|
const symbols: Record<SupportedCurrency, string> = {
|
||||||
|
sat: ' sats',
|
||||||
|
btc: ' BTC',
|
||||||
|
usd: '$',
|
||||||
|
eur: '€',
|
||||||
|
gbp: '£',
|
||||||
|
cad: 'CA$',
|
||||||
|
aud: 'AU$',
|
||||||
|
jpy: '¥'
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbol = symbols[currency]
|
||||||
|
const isPrefix = ['usd', 'gbp', 'cad', 'aud', 'jpy'].includes(currency)
|
||||||
|
|
||||||
|
if (currency === 'btc') {
|
||||||
|
return `${amount.toFixed(8)}${symbol}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currency === 'sat') {
|
||||||
|
return `${amount.toLocaleString()}${symbol}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fiat currencies
|
||||||
|
const formatted = amount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
return isPrefix ? `${symbol}${formatted}` : `${formatted}${symbol}`
|
||||||
|
}
|
||||||
109
src/extensions/marketplace/utils/validation.ts
Normal file
109
src/extensions/marketplace/utils/validation.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { CreateStallRequest, CreateProductRequest } from '../types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random ID
|
||||||
|
*/
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate stall creation request
|
||||||
|
*/
|
||||||
|
export function validateStall(req: CreateStallRequest): void {
|
||||||
|
if (!req.name || req.name.trim().length === 0) {
|
||||||
|
throw new Error('Stall name is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.name.length > 100) {
|
||||||
|
throw new Error('Stall name must be 100 characters or less')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCurrencies = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy']
|
||||||
|
if (!validCurrencies.includes(req.currency)) {
|
||||||
|
throw new Error(`Invalid currency. Must be one of: ${validCurrencies.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.shipping_zones || req.shipping_zones.length === 0) {
|
||||||
|
throw new Error('At least one shipping zone is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zone of req.shipping_zones) {
|
||||||
|
if (!zone.name || zone.name.trim().length === 0) {
|
||||||
|
throw new Error('Shipping zone name is required')
|
||||||
|
}
|
||||||
|
if (zone.cost < 0) {
|
||||||
|
throw new Error('Shipping cost cannot be negative')
|
||||||
|
}
|
||||||
|
if (!zone.regions || zone.regions.length === 0) {
|
||||||
|
throw new Error('Shipping zone must have at least one region')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate product creation request
|
||||||
|
*/
|
||||||
|
export function validateProduct(req: CreateProductRequest): void {
|
||||||
|
if (!req.name || req.name.trim().length === 0) {
|
||||||
|
throw new Error('Product name is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.name.length > 200) {
|
||||||
|
throw new Error('Product name must be 200 characters or less')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.price < 0) {
|
||||||
|
throw new Error('Price cannot be negative')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.quantity < -1) {
|
||||||
|
throw new Error('Quantity must be -1 (unlimited) or >= 0')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.images && req.images.length > 10) {
|
||||||
|
throw new Error('Maximum 10 images allowed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.categories && req.categories.length > 10) {
|
||||||
|
throw new Error('Maximum 10 categories allowed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Nostr pubkey format (64 char hex)
|
||||||
|
*/
|
||||||
|
export function validatePubkey(pubkey: string): boolean {
|
||||||
|
return /^[0-9a-f]{64}$/i.test(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string input
|
||||||
|
*/
|
||||||
|
export function sanitizeString(input: string, maxLength: number = 1000): string {
|
||||||
|
if (!input) return ''
|
||||||
|
return input.trim().slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate order items
|
||||||
|
*/
|
||||||
|
export function validateOrderItems(items: Array<{ product_id: string; quantity: number }>): void {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
throw new Error('Order must have at least one item')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 100) {
|
||||||
|
throw new Error('Maximum 100 items per order')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.product_id) {
|
||||||
|
throw new Error('Product ID is required for each item')
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
|
||||||
|
throw new Error('Quantity must be a positive integer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/extensions/withdraw/index.ts
Normal file
304
src/extensions/withdraw/index.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension for Lightning.Pub
|
||||||
|
*
|
||||||
|
* Implements LUD-03 (LNURL-withdraw) for creating withdraw links
|
||||||
|
* that allow anyone to pull funds from a Lightning wallet.
|
||||||
|
*
|
||||||
|
* Use cases:
|
||||||
|
* - Quick vouchers (batch single-use codes)
|
||||||
|
* - Faucets
|
||||||
|
* - Gift cards / prepaid cards
|
||||||
|
* - Tips / donations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
CreateWithdrawLinkRequest,
|
||||||
|
UpdateWithdrawLinkRequest,
|
||||||
|
HttpRoute,
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse
|
||||||
|
} from './types.js'
|
||||||
|
import { runMigrations } from './migrations.js'
|
||||||
|
import { WithdrawManager } from './managers/withdrawManager.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension
|
||||||
|
*/
|
||||||
|
export default class WithdrawExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'withdraw',
|
||||||
|
name: 'LNURL Withdraw',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Create withdraw links for vouchers, faucets, and gifts (LUD-03)',
|
||||||
|
author: 'Lightning.Pub',
|
||||||
|
minPubVersion: '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager!: WithdrawManager
|
||||||
|
private baseUrl: string = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the extension
|
||||||
|
*/
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Run migrations
|
||||||
|
await runMigrations(db)
|
||||||
|
|
||||||
|
// Initialize manager
|
||||||
|
this.manager = new WithdrawManager(db, ctx)
|
||||||
|
|
||||||
|
// Register RPC methods
|
||||||
|
this.registerRpcMethods(ctx)
|
||||||
|
|
||||||
|
// Register HTTP routes for LNURL protocol
|
||||||
|
this.registerHttpRoutes(ctx)
|
||||||
|
|
||||||
|
ctx.log('info', 'Extension initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the extension
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
// Cleanup if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base URL for LNURL generation
|
||||||
|
* This should be called by the main application after loading
|
||||||
|
*/
|
||||||
|
setBaseUrl(url: string): void {
|
||||||
|
this.baseUrl = url
|
||||||
|
this.manager.setBaseUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTTP routes for this extension
|
||||||
|
* These need to be mounted by the main HTTP server
|
||||||
|
*/
|
||||||
|
getHttpRoutes(): HttpRoute[] {
|
||||||
|
return [
|
||||||
|
// Initial LNURL request (simple link)
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/:unique_hash',
|
||||||
|
handler: this.handleLnurlRequest.bind(this)
|
||||||
|
},
|
||||||
|
// Initial LNURL request (unique link with use hash)
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/:unique_hash/:id_unique_hash',
|
||||||
|
handler: this.handleLnurlUniqueRequest.bind(this)
|
||||||
|
},
|
||||||
|
// LNURL callback (user submits invoice)
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/lnurl/cb/:unique_hash',
|
||||||
|
handler: this.handleLnurlCallback.bind(this)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register RPC methods with the extension context
|
||||||
|
*/
|
||||||
|
private registerRpcMethods(ctx: ExtensionContext): void {
|
||||||
|
// Create withdraw link
|
||||||
|
ctx.registerMethod('withdraw.createLink', async (req, appId) => {
|
||||||
|
const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest)
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create quick vouchers
|
||||||
|
ctx.registerMethod('withdraw.createVouchers', async (req, appId) => {
|
||||||
|
const vouchers = await this.manager.createVouchers(
|
||||||
|
appId,
|
||||||
|
req.title,
|
||||||
|
req.amount,
|
||||||
|
req.count,
|
||||||
|
req.description
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
vouchers,
|
||||||
|
total_amount_sats: req.amount * req.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get withdraw link
|
||||||
|
ctx.registerMethod('withdraw.getLink', async (req, appId) => {
|
||||||
|
const link = await this.manager.get(req.id, appId)
|
||||||
|
if (!link) throw new Error('Withdraw link not found')
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// List withdraw links
|
||||||
|
ctx.registerMethod('withdraw.listLinks', async (req, appId) => {
|
||||||
|
const links = await this.manager.list(
|
||||||
|
appId,
|
||||||
|
req.include_spent || false,
|
||||||
|
req.limit,
|
||||||
|
req.offset
|
||||||
|
)
|
||||||
|
return { links }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update withdraw link
|
||||||
|
ctx.registerMethod('withdraw.updateLink', async (req, appId) => {
|
||||||
|
const link = await this.manager.update(req.id, appId, req as UpdateWithdrawLinkRequest)
|
||||||
|
if (!link) throw new Error('Withdraw link not found')
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
total_withdrawn_sats: stats.total_sats,
|
||||||
|
withdrawals_count: stats.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete withdraw link
|
||||||
|
ctx.registerMethod('withdraw.deleteLink', async (req, appId) => {
|
||||||
|
const success = await this.manager.delete(req.id, appId)
|
||||||
|
if (!success) throw new Error('Withdraw link not found')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
// List withdrawals
|
||||||
|
ctx.registerMethod('withdraw.listWithdrawals', async (req, appId) => {
|
||||||
|
const withdrawals = await this.manager.listWithdrawals(
|
||||||
|
appId,
|
||||||
|
req.link_id,
|
||||||
|
req.limit,
|
||||||
|
req.offset
|
||||||
|
)
|
||||||
|
return { withdrawals }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get withdrawal stats
|
||||||
|
ctx.registerMethod('withdraw.getStats', async (req, appId) => {
|
||||||
|
// Get all links to calculate total stats
|
||||||
|
const links = await this.manager.list(appId, true)
|
||||||
|
|
||||||
|
let totalLinks = links.length
|
||||||
|
let activeLinks = 0
|
||||||
|
let spentLinks = 0
|
||||||
|
let totalWithdrawn = 0
|
||||||
|
let totalWithdrawals = 0
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
spentLinks++
|
||||||
|
} else {
|
||||||
|
activeLinks++
|
||||||
|
}
|
||||||
|
const stats = await this.manager.getWithdrawalStats(link.id)
|
||||||
|
totalWithdrawn += stats.total_sats
|
||||||
|
totalWithdrawals += stats.count
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_links: totalLinks,
|
||||||
|
active_links: activeLinks,
|
||||||
|
spent_links: spentLinks,
|
||||||
|
total_withdrawn_sats: totalWithdrawn,
|
||||||
|
total_withdrawals: totalWithdrawals
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register HTTP routes (called by extension context)
|
||||||
|
*/
|
||||||
|
private registerHttpRoutes(ctx: ExtensionContext): void {
|
||||||
|
// HTTP routes are exposed via getHttpRoutes()
|
||||||
|
// The main application is responsible for mounting them
|
||||||
|
ctx.log('debug', 'HTTP routes registered for LNURL protocol')
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HTTP Route Handlers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (simple link)
|
||||||
|
* GET /api/v1/lnurl/:unique_hash
|
||||||
|
*/
|
||||||
|
private async handleLnurlRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash } = req.params
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlRequest(unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (unique link)
|
||||||
|
* GET /api/v1/lnurl/:unique_hash/:id_unique_hash
|
||||||
|
*/
|
||||||
|
private async handleLnurlUniqueRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash, id_unique_hash } = req.params
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlRequest(unique_hash, id_unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LNURL callback (user submits invoice)
|
||||||
|
* GET /api/v1/lnurl/cb/:unique_hash?k1=...&pr=...&id_unique_hash=...
|
||||||
|
*/
|
||||||
|
private async handleLnurlCallback(req: HttpRequest): Promise<HttpResponse> {
|
||||||
|
const { unique_hash } = req.params
|
||||||
|
const { k1, pr, id_unique_hash } = req.query
|
||||||
|
|
||||||
|
if (!k1 || !pr) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'ERROR', reason: 'Missing k1 or pr parameter' },
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.manager.handleLnurlCallback(unique_hash, {
|
||||||
|
k1,
|
||||||
|
pr,
|
||||||
|
id_unique_hash
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: result,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for external use
|
||||||
|
export * from './types.js'
|
||||||
|
export { WithdrawManager } from './managers/withdrawManager.js'
|
||||||
711
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
711
src/extensions/withdraw/managers/withdrawManager.ts
Normal file
|
|
@ -0,0 +1,711 @@
|
||||||
|
/**
|
||||||
|
* Withdraw Link Manager
|
||||||
|
*
|
||||||
|
* Handles CRUD operations for withdraw links and processes withdrawals
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
WithdrawLink,
|
||||||
|
Withdrawal,
|
||||||
|
CreateWithdrawLinkRequest,
|
||||||
|
UpdateWithdrawLinkRequest,
|
||||||
|
WithdrawLinkWithLnurl,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
|
LnurlErrorResponse,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlCallbackParams
|
||||||
|
} from '../types.js'
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateK1,
|
||||||
|
generateUniqueHash,
|
||||||
|
generateUseHash,
|
||||||
|
verifyUseHash,
|
||||||
|
encodeLnurl,
|
||||||
|
buildLnurlUrl,
|
||||||
|
buildUniqueLnurlUrl,
|
||||||
|
buildCallbackUrl,
|
||||||
|
satsToMsats
|
||||||
|
} from '../utils/lnurl.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database row types
|
||||||
|
*/
|
||||||
|
interface WithdrawLinkRow {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
min_withdrawable: number
|
||||||
|
max_withdrawable: number
|
||||||
|
uses: number
|
||||||
|
used: number
|
||||||
|
wait_time: number
|
||||||
|
unique_hash: string
|
||||||
|
k1: string
|
||||||
|
is_unique: number
|
||||||
|
uses_csv: string
|
||||||
|
open_time: number
|
||||||
|
webhook_url: string | null
|
||||||
|
webhook_headers: string | null
|
||||||
|
webhook_body: string | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WithdrawalRow {
|
||||||
|
id: string
|
||||||
|
link_id: string
|
||||||
|
application_id: string
|
||||||
|
payment_hash: string
|
||||||
|
amount_sats: number
|
||||||
|
fee_sats: number
|
||||||
|
recipient_node: string | null
|
||||||
|
webhook_success: number | null
|
||||||
|
webhook_response: string | null
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert row to WithdrawLink
|
||||||
|
*/
|
||||||
|
function rowToLink(row: WithdrawLinkRow): WithdrawLink {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || undefined,
|
||||||
|
min_withdrawable: row.min_withdrawable,
|
||||||
|
max_withdrawable: row.max_withdrawable,
|
||||||
|
uses: row.uses,
|
||||||
|
used: row.used,
|
||||||
|
wait_time: row.wait_time,
|
||||||
|
unique_hash: row.unique_hash,
|
||||||
|
k1: row.k1,
|
||||||
|
is_unique: row.is_unique === 1,
|
||||||
|
uses_csv: row.uses_csv,
|
||||||
|
open_time: row.open_time,
|
||||||
|
webhook_url: row.webhook_url || undefined,
|
||||||
|
webhook_headers: row.webhook_headers || undefined,
|
||||||
|
webhook_body: row.webhook_body || undefined,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert row to Withdrawal
|
||||||
|
*/
|
||||||
|
function rowToWithdrawal(row: WithdrawalRow): Withdrawal {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
link_id: row.link_id,
|
||||||
|
application_id: row.application_id,
|
||||||
|
payment_hash: row.payment_hash,
|
||||||
|
amount_sats: row.amount_sats,
|
||||||
|
fee_sats: row.fee_sats,
|
||||||
|
recipient_node: row.recipient_node || undefined,
|
||||||
|
webhook_success: row.webhook_success === null ? undefined : row.webhook_success === 1,
|
||||||
|
webhook_response: row.webhook_response || undefined,
|
||||||
|
created_at: row.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WithdrawManager - Handles withdraw link operations
|
||||||
|
*/
|
||||||
|
export class WithdrawManager {
|
||||||
|
private baseUrl: string = ''
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: ExtensionDatabase,
|
||||||
|
private ctx: ExtensionContext
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base URL for LNURL generation
|
||||||
|
*/
|
||||||
|
setBaseUrl(url: string): void {
|
||||||
|
this.baseUrl = url.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add LNURL to a withdraw link
|
||||||
|
*/
|
||||||
|
private addLnurl(link: WithdrawLink): WithdrawLinkWithLnurl {
|
||||||
|
const lnurlUrl = buildLnurlUrl(this.baseUrl, link.unique_hash)
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
lnurl: encodeLnurl(lnurlUrl),
|
||||||
|
lnurl_url: lnurlUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CRUD Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new withdraw link
|
||||||
|
*/
|
||||||
|
async create(applicationId: string, req: CreateWithdrawLinkRequest): Promise<WithdrawLinkWithLnurl> {
|
||||||
|
// Validation
|
||||||
|
if (req.uses < 1 || req.uses > 250) {
|
||||||
|
throw new Error('Uses must be between 1 and 250')
|
||||||
|
}
|
||||||
|
if (req.min_withdrawable < 1) {
|
||||||
|
throw new Error('Min withdrawable must be at least 1 sat')
|
||||||
|
}
|
||||||
|
if (req.max_withdrawable < req.min_withdrawable) {
|
||||||
|
throw new Error('Max withdrawable must be >= min withdrawable')
|
||||||
|
}
|
||||||
|
if (req.wait_time < 0) {
|
||||||
|
throw new Error('Wait time cannot be negative')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate webhook JSON if provided
|
||||||
|
if (req.webhook_headers) {
|
||||||
|
try {
|
||||||
|
JSON.parse(req.webhook_headers)
|
||||||
|
} catch {
|
||||||
|
throw new Error('webhook_headers must be valid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.webhook_body) {
|
||||||
|
try {
|
||||||
|
JSON.parse(req.webhook_body)
|
||||||
|
} catch {
|
||||||
|
throw new Error('webhook_body must be valid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = generateId()
|
||||||
|
const usesCsv = Array.from({ length: req.uses }, (_, i) => String(i)).join(',')
|
||||||
|
|
||||||
|
const link: WithdrawLink = {
|
||||||
|
id,
|
||||||
|
application_id: applicationId,
|
||||||
|
title: req.title.trim(),
|
||||||
|
description: req.description?.trim(),
|
||||||
|
min_withdrawable: req.min_withdrawable,
|
||||||
|
max_withdrawable: req.max_withdrawable,
|
||||||
|
uses: req.uses,
|
||||||
|
used: 0,
|
||||||
|
wait_time: req.wait_time,
|
||||||
|
unique_hash: generateUniqueHash(),
|
||||||
|
k1: generateK1(),
|
||||||
|
is_unique: req.is_unique || false,
|
||||||
|
uses_csv: usesCsv,
|
||||||
|
open_time: now,
|
||||||
|
webhook_url: req.webhook_url,
|
||||||
|
webhook_headers: req.webhook_headers,
|
||||||
|
webhook_body: req.webhook_body,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO withdraw_links (
|
||||||
|
id, application_id, title, description,
|
||||||
|
min_withdrawable, max_withdrawable, uses, used, wait_time,
|
||||||
|
unique_hash, k1, is_unique, uses_csv, open_time,
|
||||||
|
webhook_url, webhook_headers, webhook_body,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
link.id, link.application_id, link.title, link.description || null,
|
||||||
|
link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time,
|
||||||
|
link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time,
|
||||||
|
link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null,
|
||||||
|
link.created_at, link.updated_at
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.addLnurl(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple vouchers (single-use withdraw links)
|
||||||
|
*/
|
||||||
|
async createVouchers(
|
||||||
|
applicationId: string,
|
||||||
|
title: string,
|
||||||
|
amount: number,
|
||||||
|
count: number,
|
||||||
|
description?: string
|
||||||
|
): Promise<WithdrawLinkWithLnurl[]> {
|
||||||
|
if (count < 1 || count > 100) {
|
||||||
|
throw new Error('Count must be between 1 and 100')
|
||||||
|
}
|
||||||
|
if (amount < 1) {
|
||||||
|
throw new Error('Amount must be at least 1 sat')
|
||||||
|
}
|
||||||
|
|
||||||
|
const vouchers: WithdrawLinkWithLnurl[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const voucher = await this.create(applicationId, {
|
||||||
|
title: `${title} #${i + 1}`,
|
||||||
|
description,
|
||||||
|
min_withdrawable: amount,
|
||||||
|
max_withdrawable: amount,
|
||||||
|
uses: 1,
|
||||||
|
wait_time: 0,
|
||||||
|
is_unique: false
|
||||||
|
})
|
||||||
|
vouchers.push(voucher)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vouchers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a withdraw link by ID
|
||||||
|
*/
|
||||||
|
async get(id: string, applicationId: string): Promise<WithdrawLinkWithLnurl | null> {
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(
|
||||||
|
'SELECT * FROM withdraw_links WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return this.addLnurl(rowToLink(rows[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a withdraw link by unique hash (for LNURL)
|
||||||
|
*/
|
||||||
|
async getByHash(uniqueHash: string): Promise<WithdrawLink | null> {
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(
|
||||||
|
'SELECT * FROM withdraw_links WHERE unique_hash = ?',
|
||||||
|
[uniqueHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return rowToLink(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdraw links for an application
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
applicationId: string,
|
||||||
|
includeSpent: boolean = false,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<WithdrawLinkWithLnurl[]> {
|
||||||
|
let sql = 'SELECT * FROM withdraw_links WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (!includeSpent) {
|
||||||
|
sql += ' AND used < uses'
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
if (offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<WithdrawLinkRow>(sql, params)
|
||||||
|
return rows.map(row => this.addLnurl(rowToLink(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a withdraw link
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
applicationId: string,
|
||||||
|
req: UpdateWithdrawLinkRequest
|
||||||
|
): Promise<WithdrawLinkWithLnurl | null> {
|
||||||
|
const existing = await this.get(id, applicationId)
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (req.uses !== undefined) {
|
||||||
|
if (req.uses < 1 || req.uses > 250) {
|
||||||
|
throw new Error('Uses must be between 1 and 250')
|
||||||
|
}
|
||||||
|
if (req.uses < existing.used) {
|
||||||
|
throw new Error('Cannot reduce uses below current used count')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minWith = req.min_withdrawable ?? existing.min_withdrawable
|
||||||
|
const maxWith = req.max_withdrawable ?? existing.max_withdrawable
|
||||||
|
|
||||||
|
if (minWith < 1) {
|
||||||
|
throw new Error('Min withdrawable must be at least 1 sat')
|
||||||
|
}
|
||||||
|
if (maxWith < minWith) {
|
||||||
|
throw new Error('Max withdrawable must be >= min withdrawable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle uses change
|
||||||
|
let usesCsv = existing.uses_csv
|
||||||
|
const newUses = req.uses ?? existing.uses
|
||||||
|
if (newUses !== existing.uses) {
|
||||||
|
const currentUses = usesCsv.split(',').filter(u => u !== '')
|
||||||
|
if (newUses > existing.uses) {
|
||||||
|
// Add more uses
|
||||||
|
const lastNum = currentUses.length > 0 ? parseInt(currentUses[currentUses.length - 1], 10) : -1
|
||||||
|
for (let i = lastNum + 1; currentUses.length < (newUses - existing.used); i++) {
|
||||||
|
currentUses.push(String(i))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove uses (keep first N)
|
||||||
|
usesCsv = currentUses.slice(0, newUses - existing.used).join(',')
|
||||||
|
}
|
||||||
|
usesCsv = currentUses.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdraw_links SET
|
||||||
|
title = ?, description = ?,
|
||||||
|
min_withdrawable = ?, max_withdrawable = ?,
|
||||||
|
uses = ?, wait_time = ?, is_unique = ?, uses_csv = ?,
|
||||||
|
webhook_url = ?, webhook_headers = ?, webhook_body = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND application_id = ?`,
|
||||||
|
[
|
||||||
|
req.title ?? existing.title,
|
||||||
|
req.description ?? existing.description ?? null,
|
||||||
|
minWith, maxWith,
|
||||||
|
newUses,
|
||||||
|
req.wait_time ?? existing.wait_time,
|
||||||
|
(req.is_unique ?? existing.is_unique) ? 1 : 0,
|
||||||
|
usesCsv,
|
||||||
|
req.webhook_url ?? existing.webhook_url ?? null,
|
||||||
|
req.webhook_headers ?? existing.webhook_headers ?? null,
|
||||||
|
req.webhook_body ?? existing.webhook_body ?? null,
|
||||||
|
now,
|
||||||
|
id, applicationId
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.get(id, applicationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a withdraw link
|
||||||
|
*/
|
||||||
|
async delete(id: string, applicationId: string): Promise<boolean> {
|
||||||
|
const result = await this.db.execute(
|
||||||
|
'DELETE FROM withdraw_links WHERE id = ? AND application_id = ?',
|
||||||
|
[id, applicationId]
|
||||||
|
)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LNURL Protocol Handlers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial LNURL request (user scans QR)
|
||||||
|
* Returns withdraw parameters
|
||||||
|
*/
|
||||||
|
async handleLnurlRequest(
|
||||||
|
uniqueHash: string,
|
||||||
|
idUniqueHash?: string
|
||||||
|
): Promise<LnurlWithdrawResponse | LnurlErrorResponse> {
|
||||||
|
const link = await this.getByHash(uniqueHash)
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link does not exist.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unique links, require id_unique_hash
|
||||||
|
if (link.is_unique && !idUniqueHash) {
|
||||||
|
return { status: 'ERROR', reason: 'This link requires a unique hash.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify unique hash if provided
|
||||||
|
if (idUniqueHash) {
|
||||||
|
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, idUniqueHash)
|
||||||
|
if (!useNumber) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid unique hash.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = buildCallbackUrl(this.baseUrl, link.unique_hash)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: idUniqueHash ? `${callbackUrl}?id_unique_hash=${idUniqueHash}` : callbackUrl,
|
||||||
|
k1: link.k1,
|
||||||
|
minWithdrawable: satsToMsats(link.min_withdrawable),
|
||||||
|
maxWithdrawable: satsToMsats(link.max_withdrawable),
|
||||||
|
defaultDescription: link.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle LNURL callback (user submits invoice)
|
||||||
|
* Pays the invoice and records the withdrawal
|
||||||
|
*/
|
||||||
|
async handleLnurlCallback(
|
||||||
|
uniqueHash: string,
|
||||||
|
params: LnurlCallbackParams
|
||||||
|
): Promise<LnurlSuccessResponse | LnurlErrorResponse> {
|
||||||
|
const link = await this.getByHash(uniqueHash)
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link not found.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.used >= link.uses) {
|
||||||
|
return { status: 'ERROR', reason: 'Withdraw link is spent.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.k1 !== params.k1) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid k1.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wait time
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (now < link.open_time) {
|
||||||
|
const waitSecs = link.open_time - now
|
||||||
|
return { status: 'ERROR', reason: `Please wait ${waitSecs} seconds.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unique links, verify and consume the use hash
|
||||||
|
if (params.id_unique_hash) {
|
||||||
|
const useNumber = verifyUseHash(link.id, link.unique_hash, link.uses_csv, params.id_unique_hash)
|
||||||
|
if (!useNumber) {
|
||||||
|
return { status: 'ERROR', reason: 'Invalid unique hash.' }
|
||||||
|
}
|
||||||
|
} else if (link.is_unique) {
|
||||||
|
return { status: 'ERROR', reason: 'Unique hash required.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-spending with hash check
|
||||||
|
try {
|
||||||
|
await this.createHashCheck(params.id_unique_hash || uniqueHash, params.k1)
|
||||||
|
} catch {
|
||||||
|
return { status: 'ERROR', reason: 'Withdrawal already in progress.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pay the invoice
|
||||||
|
const payment = await this.ctx.payInvoice(
|
||||||
|
link.application_id,
|
||||||
|
params.pr,
|
||||||
|
link.max_withdrawable
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record the withdrawal
|
||||||
|
await this.recordWithdrawal(link, payment.paymentHash, link.max_withdrawable, payment.feeSats)
|
||||||
|
|
||||||
|
// Increment usage
|
||||||
|
await this.incrementUsage(link, params.id_unique_hash)
|
||||||
|
|
||||||
|
// Clean up hash check
|
||||||
|
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
|
||||||
|
|
||||||
|
// Dispatch webhook if configured
|
||||||
|
if (link.webhook_url) {
|
||||||
|
this.dispatchWebhook(link, payment.paymentHash, params.pr).catch(err => {
|
||||||
|
console.error('[Withdraw] Webhook error:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'OK' }
|
||||||
|
} catch (err: any) {
|
||||||
|
// Clean up hash check on failure
|
||||||
|
await this.deleteHashCheck(params.id_unique_hash || uniqueHash)
|
||||||
|
return { status: 'ERROR', reason: `Payment failed: ${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment link usage and update open_time
|
||||||
|
*/
|
||||||
|
private async incrementUsage(link: WithdrawLink, idUniqueHash?: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
let usesCsv = link.uses_csv
|
||||||
|
|
||||||
|
// Remove used hash from uses_csv if unique
|
||||||
|
if (idUniqueHash) {
|
||||||
|
const uses = usesCsv.split(',').filter(u => {
|
||||||
|
const hash = generateUseHash(link.id, link.unique_hash, u.trim())
|
||||||
|
return hash !== idUniqueHash
|
||||||
|
})
|
||||||
|
usesCsv = uses.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdraw_links SET
|
||||||
|
used = used + 1,
|
||||||
|
open_time = ?,
|
||||||
|
uses_csv = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[now + link.wait_time, usesCsv, now, link.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a successful withdrawal
|
||||||
|
*/
|
||||||
|
private async recordWithdrawal(
|
||||||
|
link: WithdrawLink,
|
||||||
|
paymentHash: string,
|
||||||
|
amountSats: number,
|
||||||
|
feeSats: number
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
await this.db.execute(
|
||||||
|
`INSERT INTO withdrawals (
|
||||||
|
id, link_id, application_id,
|
||||||
|
payment_hash, amount_sats, fee_sats,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
generateId(),
|
||||||
|
link.id,
|
||||||
|
link.application_id,
|
||||||
|
paymentHash,
|
||||||
|
amountSats,
|
||||||
|
feeSats,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create hash check to prevent double-spending
|
||||||
|
*/
|
||||||
|
private async createHashCheck(hash: string, k1: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
await this.db.execute(
|
||||||
|
'INSERT INTO hash_checks (hash, k1, created_at) VALUES (?, ?, ?)',
|
||||||
|
[hash, k1, now]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete hash check after completion
|
||||||
|
*/
|
||||||
|
private async deleteHashCheck(hash: string): Promise<void> {
|
||||||
|
await this.db.execute('DELETE FROM hash_checks WHERE hash = ?', [hash])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdrawals
|
||||||
|
*/
|
||||||
|
async listWithdrawals(
|
||||||
|
applicationId: string,
|
||||||
|
linkId?: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<Withdrawal[]> {
|
||||||
|
let sql = 'SELECT * FROM withdrawals WHERE application_id = ?'
|
||||||
|
const params: any[] = [applicationId]
|
||||||
|
|
||||||
|
if (linkId) {
|
||||||
|
sql += ' AND link_id = ?'
|
||||||
|
params.push(linkId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
sql += ' LIMIT ?'
|
||||||
|
params.push(limit)
|
||||||
|
if (offset) {
|
||||||
|
sql += ' OFFSET ?'
|
||||||
|
params.push(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db.query<WithdrawalRow>(sql, params)
|
||||||
|
return rows.map(rowToWithdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdrawal stats for a link
|
||||||
|
*/
|
||||||
|
async getWithdrawalStats(linkId: string): Promise<{ total_sats: number; count: number }> {
|
||||||
|
const result = await this.db.query<{ total: number; count: number }>(
|
||||||
|
`SELECT COALESCE(SUM(amount_sats), 0) as total, COUNT(*) as count
|
||||||
|
FROM withdrawals WHERE link_id = ?`,
|
||||||
|
[linkId]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
total_sats: result[0]?.total || 0,
|
||||||
|
count: result[0]?.count || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch webhook notification
|
||||||
|
*/
|
||||||
|
private async dispatchWebhook(
|
||||||
|
link: WithdrawLink,
|
||||||
|
paymentHash: string,
|
||||||
|
paymentRequest: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!link.webhook_url) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.webhook_headers) {
|
||||||
|
Object.assign(headers, JSON.parse(link.webhook_headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
lnurlw: link.id,
|
||||||
|
body: link.webhook_body ? JSON.parse(link.webhook_body) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(link.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update withdrawal record with webhook result
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdrawals SET
|
||||||
|
webhook_success = ?,
|
||||||
|
webhook_response = ?
|
||||||
|
WHERE payment_hash = ?`,
|
||||||
|
[response.ok ? 1 : 0, await response.text(), paymentHash]
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
await this.db.execute(
|
||||||
|
`UPDATE withdrawals SET
|
||||||
|
webhook_success = 0,
|
||||||
|
webhook_response = ?
|
||||||
|
WHERE payment_hash = ?`,
|
||||||
|
[err.message, paymentHash]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/extensions/withdraw/migrations.ts
Normal file
153
src/extensions/withdraw/migrations.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension Database Migrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
export interface Migration {
|
||||||
|
version: number
|
||||||
|
name: string
|
||||||
|
up: (db: ExtensionDatabase) => Promise<void>
|
||||||
|
down?: (db: ExtensionDatabase) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrations: Migration[] = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
name: 'create_withdraw_links_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS withdraw_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Display
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Amounts (sats)
|
||||||
|
min_withdrawable INTEGER NOT NULL,
|
||||||
|
max_withdrawable INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- Usage limits
|
||||||
|
uses INTEGER NOT NULL DEFAULT 1,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wait_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Security
|
||||||
|
unique_hash TEXT NOT NULL UNIQUE,
|
||||||
|
k1 TEXT NOT NULL,
|
||||||
|
is_unique INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uses_csv TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
open_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Webhooks
|
||||||
|
webhook_url TEXT,
|
||||||
|
webhook_headers TEXT,
|
||||||
|
webhook_body TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for looking up by unique_hash (LNURL)
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdraw_links_unique_hash
|
||||||
|
ON withdraw_links(unique_hash)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for listing by application
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdraw_links_application
|
||||||
|
ON withdraw_links(application_id, created_at DESC)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
name: 'create_withdrawals_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS withdrawals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
link_id TEXT NOT NULL,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Payment details
|
||||||
|
payment_hash TEXT NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Recipient
|
||||||
|
recipient_node TEXT,
|
||||||
|
|
||||||
|
-- Webhook result
|
||||||
|
webhook_success INTEGER,
|
||||||
|
webhook_response TEXT,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (link_id) REFERENCES withdraw_links(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for listing withdrawals by link
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawals_link
|
||||||
|
ON withdrawals(link_id, created_at DESC)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Index for looking up by payment hash
|
||||||
|
await db.execute(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_withdrawals_payment_hash
|
||||||
|
ON withdrawals(payment_hash)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
name: 'create_hash_checks_table',
|
||||||
|
up: async (db: ExtensionDatabase) => {
|
||||||
|
// Temporary table to prevent double-spending during payment processing
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hash_checks (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
k1 TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all pending migrations
|
||||||
|
*/
|
||||||
|
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Get current version
|
||||||
|
const versionResult = await db.query<{ value: string }>(
|
||||||
|
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
|
||||||
|
).catch(() => [])
|
||||||
|
|
||||||
|
const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0
|
||||||
|
|
||||||
|
// Run pending migrations
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (migration.version > currentVersion) {
|
||||||
|
console.log(`[Withdraw] Running migration ${migration.version}: ${migration.name}`)
|
||||||
|
await migration.up(db)
|
||||||
|
|
||||||
|
// Update version
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
[String(migration.version)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/extensions/withdraw/types.ts
Normal file
261
src/extensions/withdraw/types.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw Extension Types
|
||||||
|
* Implements LUD-03 (LNURL-withdraw) for Lightning.Pub
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export base extension types
|
||||||
|
export {
|
||||||
|
Extension,
|
||||||
|
ExtensionInfo,
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
ApplicationInfo,
|
||||||
|
RpcMethodHandler
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A withdraw link that can be used to pull funds
|
||||||
|
*/
|
||||||
|
export interface WithdrawLink {
|
||||||
|
id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Display
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
// Amounts (in sats)
|
||||||
|
min_withdrawable: number
|
||||||
|
max_withdrawable: number
|
||||||
|
|
||||||
|
// Usage limits
|
||||||
|
uses: number // Total allowed uses
|
||||||
|
used: number // Times used so far
|
||||||
|
wait_time: number // Seconds between uses
|
||||||
|
|
||||||
|
// Security
|
||||||
|
unique_hash: string // For LNURL URL
|
||||||
|
k1: string // Challenge for callback
|
||||||
|
is_unique: boolean // Generate unique code per use
|
||||||
|
uses_csv: string // Comma-separated list of available use IDs
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
open_time: number // Unix timestamp when next use is allowed
|
||||||
|
|
||||||
|
// Webhook notifications
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string // JSON string
|
||||||
|
webhook_body?: string // JSON string
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdrawal record - tracks each successful withdrawal
|
||||||
|
*/
|
||||||
|
export interface Withdrawal {
|
||||||
|
id: string
|
||||||
|
link_id: string
|
||||||
|
application_id: string
|
||||||
|
|
||||||
|
// Payment details
|
||||||
|
payment_hash: string
|
||||||
|
amount_sats: number
|
||||||
|
fee_sats: number
|
||||||
|
|
||||||
|
// Recipient (if known)
|
||||||
|
recipient_node?: string
|
||||||
|
|
||||||
|
// Webhook result
|
||||||
|
webhook_success?: boolean
|
||||||
|
webhook_response?: string
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash check - prevents double-spending during payment
|
||||||
|
*/
|
||||||
|
export interface HashCheck {
|
||||||
|
hash: string
|
||||||
|
k1: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LNURL Protocol Types (LUD-03)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw response (first call)
|
||||||
|
* Returned when user scans the QR code
|
||||||
|
*/
|
||||||
|
export interface LnurlWithdrawResponse {
|
||||||
|
tag: 'withdrawRequest'
|
||||||
|
callback: string // URL to call with invoice
|
||||||
|
k1: string // Challenge
|
||||||
|
minWithdrawable: number // Millisats
|
||||||
|
maxWithdrawable: number // Millisats
|
||||||
|
defaultDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL error response
|
||||||
|
*/
|
||||||
|
export interface LnurlErrorResponse {
|
||||||
|
status: 'ERROR'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL success response
|
||||||
|
*/
|
||||||
|
export interface LnurlSuccessResponse {
|
||||||
|
status: 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RPC Request/Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new withdraw link
|
||||||
|
*/
|
||||||
|
export interface CreateWithdrawLinkRequest {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
min_withdrawable: number // sats
|
||||||
|
max_withdrawable: number // sats
|
||||||
|
uses: number // 1-250
|
||||||
|
wait_time: number // seconds between uses
|
||||||
|
is_unique?: boolean // generate unique code per use
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string // JSON
|
||||||
|
webhook_body?: string // JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing withdraw link
|
||||||
|
*/
|
||||||
|
export interface UpdateWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
min_withdrawable?: number
|
||||||
|
max_withdrawable?: number
|
||||||
|
uses?: number
|
||||||
|
wait_time?: number
|
||||||
|
is_unique?: boolean
|
||||||
|
webhook_url?: string
|
||||||
|
webhook_headers?: string
|
||||||
|
webhook_body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdraw link by ID
|
||||||
|
*/
|
||||||
|
export interface GetWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdraw links
|
||||||
|
*/
|
||||||
|
export interface ListWithdrawLinksRequest {
|
||||||
|
include_spent?: boolean // Include fully used links
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete withdraw link
|
||||||
|
*/
|
||||||
|
export interface DeleteWithdrawLinkRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create quick vouchers (batch of single-use links)
|
||||||
|
*/
|
||||||
|
export interface CreateVouchersRequest {
|
||||||
|
title: string
|
||||||
|
amount: number // sats per voucher
|
||||||
|
count: number // number of vouchers (1-100)
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get withdraw link with LNURL
|
||||||
|
*/
|
||||||
|
export interface WithdrawLinkWithLnurl extends WithdrawLink {
|
||||||
|
lnurl: string // bech32 encoded LNURL
|
||||||
|
lnurl_url: string // raw callback URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List withdrawals for a link
|
||||||
|
*/
|
||||||
|
export interface ListWithdrawalsRequest {
|
||||||
|
link_id?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Withdraw link response with stats
|
||||||
|
*/
|
||||||
|
export interface WithdrawLinkResponse {
|
||||||
|
link: WithdrawLinkWithLnurl
|
||||||
|
total_withdrawn_sats: number
|
||||||
|
withdrawals_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vouchers response
|
||||||
|
*/
|
||||||
|
export interface VouchersResponse {
|
||||||
|
vouchers: WithdrawLinkWithLnurl[]
|
||||||
|
total_amount_sats: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP Handler Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL callback parameters
|
||||||
|
*/
|
||||||
|
export interface LnurlCallbackParams {
|
||||||
|
k1: string // Challenge from initial response
|
||||||
|
pr: string // Payment request (BOLT11 invoice)
|
||||||
|
id_unique_hash?: string // For unique links
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP route handler
|
||||||
|
*/
|
||||||
|
export interface HttpRoute {
|
||||||
|
method: 'GET' | 'POST'
|
||||||
|
path: string
|
||||||
|
handler: (req: HttpRequest) => Promise<HttpResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpRequest {
|
||||||
|
params: Record<string, string>
|
||||||
|
query: Record<string, string>
|
||||||
|
body?: any
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpResponse {
|
||||||
|
status: number
|
||||||
|
body: any
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
131
src/extensions/withdraw/utils/lnurl.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* LNURL Encoding Utilities
|
||||||
|
*
|
||||||
|
* LNURL is a bech32-encoded URL with hrp "lnurl"
|
||||||
|
* See: https://github.com/lnurl/luds
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { bech32 } from 'bech32'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a URL as LNURL (bech32)
|
||||||
|
*/
|
||||||
|
export function encodeLnurl(url: string): string {
|
||||||
|
const words = bech32.toWords(Buffer.from(url, 'utf8'))
|
||||||
|
return bech32.encode('lnurl', words, 2000) // 2000 char limit for URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an LNURL to a URL
|
||||||
|
*/
|
||||||
|
export function decodeLnurl(lnurl: string): string {
|
||||||
|
const { prefix, words } = bech32.decode(lnurl, 2000)
|
||||||
|
if (prefix !== 'lnurl') {
|
||||||
|
throw new Error('Invalid LNURL prefix')
|
||||||
|
}
|
||||||
|
return Buffer.from(bech32.fromWords(words)).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL-safe random ID
|
||||||
|
*/
|
||||||
|
export function generateId(length: number = 22): string {
|
||||||
|
const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4))
|
||||||
|
return bytes.toString('base64url').slice(0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a k1 challenge (32 bytes hex)
|
||||||
|
*/
|
||||||
|
export function generateK1(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique hash for a link
|
||||||
|
*/
|
||||||
|
export function generateUniqueHash(): string {
|
||||||
|
return generateId(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique hash for a specific use of a link
|
||||||
|
* This creates a deterministic hash based on link ID, unique_hash, and use number
|
||||||
|
*/
|
||||||
|
export function generateUseHash(linkId: string, uniqueHash: string, useNumber: string): string {
|
||||||
|
const data = `${linkId}${uniqueHash}${useNumber}`
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a use hash matches one of the available uses
|
||||||
|
*/
|
||||||
|
export function verifyUseHash(
|
||||||
|
linkId: string,
|
||||||
|
uniqueHash: string,
|
||||||
|
usesCsv: string,
|
||||||
|
providedHash: string
|
||||||
|
): string | null {
|
||||||
|
const uses = usesCsv.split(',').filter(u => u.trim() !== '')
|
||||||
|
|
||||||
|
for (const useNumber of uses) {
|
||||||
|
const expectedHash = generateUseHash(linkId, uniqueHash, useNumber.trim())
|
||||||
|
if (expectedHash === providedHash) {
|
||||||
|
return useNumber.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LNURL callback URL for a withdraw link
|
||||||
|
*/
|
||||||
|
export function buildLnurlUrl(baseUrl: string, uniqueHash: string): string {
|
||||||
|
// Remove trailing slash from baseUrl
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/${uniqueHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LNURL callback URL for a unique withdraw link
|
||||||
|
*/
|
||||||
|
export function buildUniqueLnurlUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
uniqueHash: string,
|
||||||
|
useHash: string
|
||||||
|
): string {
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/${uniqueHash}/${useHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the callback URL for the second step (where user sends invoice)
|
||||||
|
*/
|
||||||
|
export function buildCallbackUrl(baseUrl: string, uniqueHash: string): string {
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/api/v1/lnurl/cb/${uniqueHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sats to millisats
|
||||||
|
*/
|
||||||
|
export function satsToMsats(sats: number): number {
|
||||||
|
return sats * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Millisats to sats
|
||||||
|
*/
|
||||||
|
export function msatsToSats(msats: number): number {
|
||||||
|
return Math.floor(msats / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a BOLT11 invoice (basic check)
|
||||||
|
*/
|
||||||
|
export function isValidBolt11(invoice: string): boolean {
|
||||||
|
const lower = invoice.toLowerCase()
|
||||||
|
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue