2026-03-08 00:33:08 +00:00
|
|
|
import { Hono } from 'hono'
|
2026-03-08 21:44:17 +00:00
|
|
|
import { toError } from '../lib/utils.js'
|
2026-03-12 22:46:24 +00:00
|
|
|
import { getDecodedToken } from '@cashu/cashu-ts'
|
2026-03-08 00:33:08 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:34:51 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 22:46:24 +00:00
|
|
|
// Validate Cashu token format before any DB lookups
|
|
|
|
|
try {
|
|
|
|
|
getDecodedToken(cashuToken)
|
|
|
|
|
} catch {
|
|
|
|
|
return c.json({ error: 'Invalid Cashu token format.' }, 400)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:33:08 +00:00
|
|
|
// 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,
|
|
|
|
|
})
|
2026-03-08 20:34:11 +00:00
|
|
|
} catch (err: unknown) {
|
2026-03-08 21:44:17 +00:00
|
|
|
return c.json({ error: toError(err).message }, 400)
|
2026-03-08 00:33:08 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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 })
|
|
|
|
|
})
|