diff --git a/server/src/app.ts b/server/src/app.ts index f4a31fd..4066612 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -7,12 +7,14 @@ import { queueRouter } from './routes/queue.js' import { authRouter } from './routes/auth.js' import { docsRouter } from './routes/docs.js' import { betsRouter } from './routes/bets.js' +import { paymentsRouter } from './routes/payments.js' import { rateLimit } from './middleware/rate-limit.js' import { existsSync, readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { cleanupOrphanedFights } from './engine/orchestrator.js' +import { recoverOrphanedPayments } from './engine/payments.js' export const app = new Hono() @@ -37,6 +39,7 @@ app.route('/api/fights', fightsRouter) app.route('/api/queue', queueRouter) app.route('/api/docs', docsRouter) app.route('/api/bets', betsRouter) +app.route('/api/payments', paymentsRouter) // In production, serve the frontend SPA const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -107,3 +110,8 @@ cleanupOrphanedFights().then(() => { }).catch(err => { console.error('[botfights] cleanup error:', err) }) + +// Recover orphaned payments on startup +recoverOrphanedPayments().catch(err => { + console.error('[botfights] payment recovery error:', err) +}) diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts new file mode 100644 index 0000000..f655483 --- /dev/null +++ b/server/src/routes/payments.ts @@ -0,0 +1,215 @@ +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 { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto' + +export const paymentsRouter = new Hono() + +// Encryption for wallet connection data +const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY +let encryptionKey: Buffer + +if (ENCRYPTION_KEY_HEX) { + encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex') +} else { + encryptionKey = randomBytes(32) + console.warn('[payments] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.') +} + +function encrypt(plaintext: string): string { + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex') +} + +function decrypt(ciphertext: string): string { + const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':') + const iv = Buffer.from(ivHex, 'hex') + const authTag = Buffer.from(authTagHex, 'hex') + const encrypted = Buffer.from(encryptedHex, 'hex') + const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv) + decipher.setAuthTag(authTag) + return decipher.update(encrypted) + decipher.final('utf8') +} + +// 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 +paymentsRouter.post('/create-invoice', async (c) => { + const { botId } = await c.req.json<{ botId: string }>() + if (!botId) return c.json({ error: 'Missing botId' }, 400) + + 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 +paymentsRouter.get('/check/:paymentId', async (c) => { + const paymentId = c.req.param('paymentId') + 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 /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 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.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 }) +}) diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index 9e1e6d3..d11bd1f 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js' +import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' export const queueRouter = new Hono() @@ -41,3 +42,26 @@ queueRouter.post('/leave/:botId', (c) => { const left = leaveQueue(botId) return c.json({ left }) }) + +// Get ranked queue status +queueRouter.get('/ranked-status', (c) => { + return c.json(getRankedQueueStatus()) +}) + +// Join ranked queue — requires confirmed payment +queueRouter.post('/join-ranked/:botId', async (c) => { + const botId = c.req.param('botId') + const { paymentId } = await c.req.json<{ paymentId: string }>() + + if (!paymentId) { + return c.json({ error: 'Missing paymentId' }, 400) + } + + try { + const fightId = await joinRankedQueue(botId, paymentId) + return c.json({ fightId, message: 'Ranked match found! Fight starting.' }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Ranked queue error' + return c.json({ error: message }, 500) + } +})