import { Hono } from 'hono' import { toError } from '../lib/utils.js' import { getDecodedToken } from '@cashu/cashu-ts' import { db, schema } from '../db/index.js' import { eq, desc } from 'drizzle-orm' import { calculateOdds } from '../engine/odds.js' import { placeBet, getFightBets, createDepositInvoice, withdrawToLightning, } from '../engine/betting.js' import { rateLimit } from '../middleware/rate-limit.js' export const betsRouter = new Hono() // Get odds for a fight betsRouter.get('/odds/:fightId', async (c) => { const fightId = c.req.param('fightId') const fight = await db.select().from(schema.fights) .where(eq(schema.fights.id, fightId)).limit(1) if (!fight[0]) return c.json({ error: 'Fight not found.' }, 404) if (fight[0].status !== 'scheduled') { return c.json({ error: 'Betting closed — fight already started.' }, 400) } const [botA, botB] = await Promise.all([ db.select({ eloRating: schema.bots.eloRating, winStreak: schema.bots.winStreak }) .from(schema.bots).where(eq(schema.bots.id, fight[0].botAId)).limit(1), db.select({ eloRating: schema.bots.eloRating, winStreak: schema.bots.winStreak }) .from(schema.bots).where(eq(schema.bots.id, fight[0].botBId)).limit(1), ]) if (!botA[0] || !botB[0]) return c.json({ error: 'Bot not found.' }, 404) const odds = calculateOdds(botA[0].eloRating, botB[0].eloRating, { streakA: botA[0].winStreak, streakB: botB[0].winStreak, }) return c.json({ fightId, botAId: fight[0].botAId, botBId: fight[0].botBId, ...odds, }) }) // Place a bet betsRouter.post('/place', rateLimit(60_000, 10), async (c) => { const body = await c.req.json() const { fightId, pubkey, botId, amountSats, cashuToken } = body if (!fightId || !pubkey || !botId || !amountSats || !cashuToken) { return c.json({ error: 'Missing required fields.' }, 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) } // Validate Cashu token format before any DB lookups try { getDecodedToken(cashuToken) } catch { return c.json({ error: 'Invalid Cashu token format.' }, 400) } // Verify fight is still open const fight = await db.select().from(schema.fights) .where(eq(schema.fights.id, fightId)).limit(1) if (!fight[0]) return c.json({ error: 'Fight not found.' }, 404) if (fight[0].status !== 'scheduled') { return c.json({ error: 'Betting closed.' }, 400) } // Verify botId is in the fight if (botId !== fight[0].botAId && botId !== fight[0].botBId) { return c.json({ error: 'Bot is not in this fight.' }, 400) } // Get ELO ratings const [botA, botB] = await Promise.all([ db.select({ eloRating: schema.bots.eloRating }) .from(schema.bots).where(eq(schema.bots.id, fight[0].botAId)).limit(1), db.select({ eloRating: schema.bots.eloRating }) .from(schema.bots).where(eq(schema.bots.id, fight[0].botBId)).limit(1), ]) if (!botA[0] || !botB[0]) return c.json({ error: 'Bot not found.' }, 404) try { const bet = await placeBet( fightId, pubkey, botId, amountSats, cashuToken, botA[0].eloRating, botB[0].eloRating, fight[0].botAId, ) // Persist to DB await db.insert(schema.bets).values({ id: bet.id, fightId: bet.fightId, bettorPubkey: bet.bettorPubkey, botId: bet.botId, amountSats: bet.amountSats, oddsAtPlacement: bet.oddsAtPlacement, cashuToken: bet.cashuToken, status: 'locked', createdAt: new Date().toISOString(), }) return c.json({ betId: bet.id, amountSats: bet.amountSats, odds: bet.oddsAtPlacement, potentialPayout: bet.potentialPayout, }) } catch (err: unknown) { return c.json({ error: toError(err).message }, 400) } }) // Get bets for a fight betsRouter.get('/fight/:fightId', async (c) => { const fightId = c.req.param('fightId') const bets = await db.select({ id: schema.bets.id, botId: schema.bets.botId, amountSats: schema.bets.amountSats, oddsAtPlacement: schema.bets.oddsAtPlacement, status: schema.bets.status, payoutSats: schema.bets.payoutSats, createdAt: schema.bets.createdAt, }).from(schema.bets) .where(eq(schema.bets.fightId, fightId)) .orderBy(desc(schema.bets.createdAt)) return c.json({ bets }) }) // Get bet history for a user (by pubkey) betsRouter.get('/history/:pubkey', async (c) => { const pubkey = c.req.param('pubkey') const bets = await db.select({ id: schema.bets.id, fightId: schema.bets.fightId, botId: schema.bets.botId, amountSats: schema.bets.amountSats, oddsAtPlacement: schema.bets.oddsAtPlacement, status: schema.bets.status, payoutSats: schema.bets.payoutSats, createdAt: schema.bets.createdAt, settledAt: schema.bets.settledAt, }).from(schema.bets) .where(eq(schema.bets.bettorPubkey, pubkey)) .orderBy(desc(schema.bets.createdAt)) return c.json({ bets }) }) // Lightning deposit — get invoice betsRouter.post('/deposit', rateLimit(60_000, 5), async (c) => { const { amountSats, pubkey } = await c.req.json() if (!amountSats || !pubkey) { return c.json({ error: 'Missing amountSats or pubkey.' }, 400) } if (amountSats < 100 || amountSats > 1_000_000) { return c.json({ error: 'Amount must be 100-1,000,000 sats.' }, 400) } const invoice = await createDepositInvoice(amountSats, pubkey) return c.json(invoice) }) // Lightning withdraw — burn Cashu tokens, pay LN invoice betsRouter.post('/withdraw', rateLimit(60_000, 3), async (c) => { const { cashuToken, bolt11 } = await c.req.json() if (!cashuToken || !bolt11) { return c.json({ error: 'Missing cashuToken or bolt11 invoice.' }, 400) } const result = await withdrawToLightning(cashuToken, bolt11) if (!result.success) { return c.json({ error: result.error }, 400) } return c.json({ success: true, preimage: result.preimage }) })