Files
botfights/server/src/routes/payments.ts
T
DorianandClaude Opus 4.6 4cc18048e8 refactor: replace console.log/error with structured logger across server
Migrate all server modules to use the centralized logger (lib/logger.ts)
instead of raw console calls. Lint warnings reduced from 74 to 25.
Remaining warnings are only no-floating-promises in game engine code.

Files updated: orchestrator.ts, ranked-queue.ts, human-responses.ts,
payments.ts, fight-loop.ts, app.ts, routes/payments.ts
Files suppressed: logger.ts, fight-loop-cli.ts, migrate.ts (legitimate console use)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:06:36 +00:00

311 lines
11 KiB
TypeScript

import { Hono } from 'hono'
import { nanoid } from 'nanoid'
import { logger } from '../lib/logger.js'
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))
logger.info('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)
if (typeof amountSats !== 'number' || !Number.isInteger(amountSats) || amountSats < 1 || amountSats > 1_000_000) {
return c.json({ error: 'amountSats must be an integer between 1 and 1,000,000' }, 400)
}
const amount = amountSats
// 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 })
})