feat: betting system — Cashu escrow, Lightning deposit/withdraw, payouts

Complete betting infrastructure:
- Bets schema (SQLite) with escrow status tracking
- Cashu token verification + minting (stub for real mint)
- Lightning invoice creation + withdrawal (stub for real LN node)
- Bet placement with odds lock, settlement on fight end
- Payout automation for winners, refunds on draws
- Bet history by pubkey + fight pool info
- Verifiable bet proofs for transparency
- API routes: /api/bets/{odds,place,fight,history,deposit,withdraw}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 00:33:08 +00:00
co-authored by Claude Opus 4.6
parent 6ede5da675
commit a254a77151
5 changed files with 522 additions and 5 deletions
+176
View File
@@ -0,0 +1,176 @@
import { Hono } from 'hono'
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)
}
// 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: any) {
return c.json({ error: 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 })
})