Files
botfights/server/src/routes/bets.ts
T

190 lines
6.1 KiB
TypeScript
Raw Normal View History

import { Hono } from 'hono'
import { toError } from '../lib/utils.js'
import { placeBetSchema, depositSchema, withdrawSchema, formatZodError } from '../lib/validators.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 parsed = placeBetSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
amountSats: 'amountSats must be an integer between 1 and 1,000,000',
}, 'Missing required fields.') }, 400)
}
const { fightId, pubkey, botId, amountSats, cashuToken } = parsed.data
// 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 parsed = depositSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
amountSats: 'Amount must be 100-1,000,000 sats.',
pubkey: 'Missing amountSats or pubkey.',
}, 'Missing amountSats or pubkey.') }, 400)
}
const { amountSats, pubkey } = parsed.data
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 parsed = withdrawSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message || 'Missing cashuToken or bolt11 invoice.' }, 400)
}
const { cashuToken, bolt11 } = parsed.data
const result = await withdrawToLightning(cashuToken, bolt11)
if (!result.success) {
return c.json({ error: result.error }, 400)
}
return c.json({ success: true, preimage: result.preimage })
})