feat: payment routes + ranked queue endpoint + app wiring
Add /api/payments router (connect-wallet, wallet-status, create-invoice, check, submit-cashu, winnings, claim, disconnect-wallet). Add ranked queue endpoints to /api/queue. Mount payments router in app.ts with orphan payment recovery on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e046cb13eb
commit
460564a8ee
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user