import { Hono } from 'hono' import { nanoid } from 'nanoid' import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js' import { encrypt, decrypt } from '../engine/crypto.js' import { rateLimit } from '../middleware/rate-limit.js' export const paymentsRouter = new Hono() // POST /connect-wallet paymentsRouter.post('/connect-wallet', async (c) => { const { pubkey, method, connectionData } = await c.req.json<{ pubkey: string method: 'nwc' | 'lnaddress' | 'cashu_mint' connectionData: string }>() if (!pubkey || !method || !connectionData) { return c.json({ error: 'Missing pubkey, method, or connectionData' }, 400) } // Look up bot by publicKey const botRows = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found for this public key' }, 404) } const botId = botRows[0].id const encryptedData = encrypt(connectionData) const now = new Date().toISOString() // Upsert wallet connection const existing = await db.select({ id: schema.walletConnections.id }) .from(schema.walletConnections) .where(eq(schema.walletConnections.botId, botId)) .limit(1) if (existing.length > 0) { await db.update(schema.walletConnections).set({ method, connectionData: encryptedData, lastUsedAt: now, }).where(eq(schema.walletConnections.botId, botId)) } else { await db.insert(schema.walletConnections).values({ id: nanoid(12), botId, method, connectionData: encryptedData, createdAt: now, }) } await db.update(schema.bots).set({ hasWallet: true }).where(eq(schema.bots.id, botId)) return c.json({ success: true }) }) // GET /wallet-status paymentsRouter.get('/wallet-status', async (c) => { const pubkey = c.req.query('pubkey') if (!pubkey) return c.json({ connected: false, method: null }) const botRows = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (botRows.length === 0) return c.json({ connected: false, method: null }) const walletRows = await db.select({ method: schema.walletConnections.method }) .from(schema.walletConnections) .where(eq(schema.walletConnections.botId, botRows[0].id)) .limit(1) if (walletRows.length === 0) return c.json({ connected: false, method: null }) return c.json({ connected: true, method: walletRows[0].method }) }) // POST /create-invoice — rate limited: 10 per minute per IP paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => { const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>() if (!botId) return c.json({ error: 'Missing botId' }, 400) // In production, verify bot ownership if (process.env.NODE_ENV === 'production') { if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { return c.json({ error: 'Missing pubkey' }, 400) } const botRows = await db.select({ publicKey: schema.bots.publicKey }) .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { return c.json({ error: 'Unauthorized' }, 403) } } try { const result = await createEntryInvoice(botId) return c.json(result) } catch (err) { const message = err instanceof Error ? err.message : 'Invoice creation failed' return c.json({ error: message }, 500) } }) // GET /check/:paymentId — rate limited: 30 per minute per IP paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => { const paymentId = c.req.param('paymentId') if (!paymentId || paymentId.length > 24) { return c.json({ error: 'Invalid paymentId' }, 400) } try { const status = await checkPaymentStatus(paymentId) return c.json({ status }) } catch (err) { const message = err instanceof Error ? err.message : 'Status check failed' return c.json({ error: message }, 500) } }) // POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => { const paymentId = c.req.param('paymentId') if (!paymentId || paymentId.length > 24) { return c.json({ error: 'Invalid paymentId' }, 400) } const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined })) const rows = await db.select().from(schema.payments) .where(eq(schema.payments.id, paymentId)).limit(1) if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404) const payment = rows[0] if (payment.status === 'confirmed') return c.json({ status: 'confirmed' }) if (payment.status !== 'pending') return c.json({ error: 'Payment is not pending' }, 400) // Must be an inbound entry payment if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400) // Verify caller owns this payment's bot if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) { const botRows = await db.select({ publicKey: schema.bots.publicKey }) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { return c.json({ error: 'Unauthorized' }, 403) } } else if (process.env.NODE_ENV === 'production') { return c.json({ error: 'Missing or invalid pubkey' }, 400) } // In production, also verify payment via NWC lookup (belt and suspenders) if (process.env.NODE_ENV === 'production' && payment.invoice && payment.invoice !== 'dev_auto_confirmed') { try { const serverStatus = await checkPaymentStatus(paymentId) if (serverStatus !== 'confirmed') { return c.json({ error: 'Server could not verify payment. Try again.' }, 402) } // checkPaymentStatus already updated the DB return c.json({ status: 'confirmed' }) } catch { // NWC check failed — fall through to client-confirmed path with preimage if (!preimage) { return c.json({ error: 'Payment verification failed and no preimage provided.' }, 402) } } } await db.update(schema.payments).set({ status: 'confirmed', preimage: preimage || null, confirmedAt: new Date().toISOString(), }).where(eq(schema.payments.id, paymentId)) console.log(`[payments] payment ${paymentId} confirmed via client (preimage: ${preimage ? 'yes' : 'no'})`) return c.json({ status: 'confirmed' }) }) // POST /submit-cashu paymentsRouter.post('/submit-cashu', async (c) => { const { botId, token } = await c.req.json<{ botId: string; token: string }>() if (!botId || !token) return c.json({ error: 'Missing botId or token' }, 400) try { const result = await redeemCashuToken(token, botId) return c.json({ paymentId: result.paymentId, status: result.valid ? 'confirmed' : 'failed', }) } catch (err) { const message = err instanceof Error ? err.message : 'Cashu redemption failed' return c.json({ error: message }, 500) } }) // GET /winnings/:botId paymentsRouter.get('/winnings/:botId', async (c) => { const botId = c.req.param('botId') const unclaimed = await db.select({ paymentId: schema.payments.id, cashuToken: schema.payments.cashuToken, amountSats: schema.payments.amountSats, }).from(schema.payments) .where(eq(schema.payments.botId, botId)) // Filter in JS since drizzle doesn't easily combine multiple conditions const filtered = unclaimed.filter(p => p.cashuToken) return c.json({ unclaimed: filtered }) }) // POST /claim/:paymentId paymentsRouter.post('/claim/:paymentId', async (c) => { const paymentId = c.req.param('paymentId') const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined })) const rows = await db.select().from(schema.payments) .where(eq(schema.payments.id, paymentId)) .limit(1) if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404) const payment = rows[0] // Verify caller owns this payment's bot if (pubkey) { const botRows = await db.select({ publicKey: schema.bots.publicKey }) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { return c.json({ error: 'Unauthorized' }, 403) } } else if (process.env.NODE_ENV === 'production') { return c.json({ error: 'Missing pubkey' }, 400) } if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400) // Clear the token from DB after claiming await db.update(schema.payments).set({ cashuToken: null }) .where(eq(schema.payments.id, paymentId)) return c.json({ cashuToken: payment.cashuToken }) }) // DELETE /disconnect-wallet paymentsRouter.delete('/disconnect-wallet', async (c) => { const { pubkey } = await c.req.json<{ pubkey: string }>() if (!pubkey) return c.json({ error: 'Missing pubkey' }, 400) const botRows = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (botRows.length === 0) return c.json({ error: 'Bot not found' }, 404) const botId = botRows[0].id await db.delete(schema.walletConnections) .where(eq(schema.walletConnections.botId, botId)) await db.update(schema.bots).set({ hasWallet: false }) .where(eq(schema.bots.id, botId)) return c.json({ success: true }) }) // POST /zap — zap sats to a fight winner paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => { const { winnerId, fightId, amountSats } = await c.req.json<{ winnerId: string fightId: string amountSats: number }>() if (!winnerId || !fightId) return c.json({ error: 'Missing winnerId or fightId' }, 400) const amount = amountSats || 21 // Verify the fight exists and this bot actually won const fightRows = await db.select({ winnerId: schema.fights.winnerId, status: schema.fights.status, }).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1) if (fightRows.length === 0) return c.json({ error: 'Fight not found' }, 404) if (fightRows[0].status !== 'finished') return c.json({ error: 'Fight not finished' }, 400) if (fightRows[0].winnerId !== winnerId) return c.json({ error: 'Bot did not win this fight' }, 400) // Increment zaps on the winner const botRows = await db.select({ zapsReceived: schema.bots.zapsReceived }) .from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1) if (botRows.length === 0) return c.json({ error: 'Bot not found' }, 404) await db.update(schema.bots).set({ zapsReceived: (botRows[0].zapsReceived || 0) + 1, }).where(eq(schema.bots.id, winnerId)) return c.json({ ok: true, zapsReceived: (botRows[0].zapsReceived || 0) + 1 }) })