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:
co-authored by
Claude Opus 4.6
parent
6ede5da675
commit
a254a77151
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user