feat(extensions): add NIP-15 marketplace extension #5

Open
padreug wants to merge 1 commit from feature/marketplace into dev
12 changed files with 3548 additions and 0 deletions
Showing only changes of commit 772a2131e6 - Show all commits

View 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'

View 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]
)
}
}

View 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]
)
}
}

View 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
}
}

View 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
}
}

View 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')
}
}
]

View 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
}
}

View 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]

View 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])
}

View 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
}

View 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}`
}

View 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')
}
}
}