// BOTFIGHTS Betting Engine // Cashu ecash escrow with Lightning deposit/withdraw // All amounts in satoshis import { nanoid } from 'nanoid' import { calculateOdds, calculatePayout, validateBet } from './odds.js' // --- Cashu Token Interface --- // Cashu tokens are base64-encoded ecash tokens from a Cashu mint // See: https://github.com/cashubtc/nuts export interface CashuToken { token: string // base64-encoded Cashu token amount: number // total sats in token mint: string // mint URL } export interface BetPlacement { id: string fightId: string bettorPubkey: string botId: string amountSats: number oddsAtPlacement: number potentialPayout: number cashuToken: string } export interface BetSettlement { betId: string won: boolean payoutSats: number payoutToken: string | null } // --- Escrow State --- // In-memory escrow for active fights. Persisted to DB on settlement. const escrow = new Map() /** * Place a bet on a fight outcome. * Validates the bet, verifies the Cashu token, and locks it in escrow. */ export async function placeBet( fightId: string, bettorPubkey: string, botId: string, amountSats: number, cashuToken: string, eloA: number, eloB: number, botAId: string, ): Promise { // Validate bet amount const validation = validateBet(amountSats) if (!validation.valid) { throw new Error(validation.error) } // Verify Cashu token (in production, this would call the mint's /verify endpoint) const tokenValid = await verifyCashuToken(cashuToken, amountSats) if (!tokenValid) { throw new Error('Invalid or insufficient Cashu token.') } // Calculate odds at time of placement const odds = calculateOdds(eloA, eloB) const isOnBotA = botId === botAId const payoutMultiplier = isOnBotA ? odds.botAPayoutMultiplier : odds.botBPayoutMultiplier const potentialPayout = calculatePayout(amountSats, payoutMultiplier) const bet: BetPlacement = { id: nanoid(12), fightId, bettorPubkey, botId, amountSats, oddsAtPlacement: payoutMultiplier, potentialPayout, cashuToken, } // Add to escrow if (!escrow.has(fightId)) { escrow.set(fightId, { fightId, bets: [], totalPool: 0, lockedAt: new Date().toISOString(), }) } const pool = escrow.get(fightId)! pool.bets.push(bet) pool.totalPool += amountSats return bet } /** * Lock all bets for a fight (no more bets accepted after fight starts). */ export function lockBets(fightId: string): void { const pool = escrow.get(fightId) if (pool) { pool.lockedAt = new Date().toISOString() } } /** * Settle all bets for a completed fight. * Winners get their payout as new Cashu tokens. * Losers forfeit their tokens. */ export async function settleBets( fightId: string, winnerId: string | null, ): Promise { const pool = escrow.get(fightId) if (!pool || pool.bets.length === 0) return [] const settlements: BetSettlement[] = [] for (const bet of pool.bets) { // Draw: refund all bets if (!winnerId) { const refundToken = await mintCashuToken(bet.amountSats) settlements.push({ betId: bet.id, won: false, payoutSats: bet.amountSats, payoutToken: refundToken, }) continue } const won = bet.botId === winnerId if (won) { // Winner: mint payout token const payoutToken = await mintCashuToken(bet.potentialPayout) settlements.push({ betId: bet.id, won: true, payoutSats: bet.potentialPayout, payoutToken, }) } else { // Loser: no payout settlements.push({ betId: bet.id, won: false, payoutSats: 0, payoutToken: null, }) } } // Clean up escrow escrow.delete(fightId) return settlements } /** * Get all active bets for a fight. */ export function getFightBets(fightId: string): BetPlacement[] { return escrow.get(fightId)?.bets ?? [] } /** * Get escrow pool info for a fight. */ export function getPoolInfo(fightId: string): { totalPool: number betCount: number botAPool: number botBPool: number } | null { const pool = escrow.get(fightId) if (!pool) return null // We don't track which is A/B here, caller provides that context return { totalPool: pool.totalPool, betCount: pool.bets.length, botAPool: 0, // Caller should aggregate from bets botBPool: 0, } } // --- Cashu Integration Stubs --- // These would connect to a real Cashu mint in production. // For now they validate format and simulate minting. const MINT_URL = process.env.CASHU_MINT_URL || 'https://mint.botfights.example.com' /** * Verify a Cashu token is valid and has sufficient amount. * In production: POST to mint's /verify endpoint. */ async function verifyCashuToken(token: string, expectedAmount: number): Promise { if (!token || token.length < 10) return false // Production: decode token, check mint URL, verify with mint // For now, accept any non-empty token with valid base64-ish format try { // Cashu tokens start with 'cashuA' (v1) or 'cashuB' (v2) if (token.startsWith('cashuA') || token.startsWith('cashuB')) { return true } // Also accept for development/testing if (process.env.NODE_ENV !== 'production') { return true } return false } catch { return false } } /** * Mint new Cashu tokens for a payout amount. * In production: POST to mint's /mint endpoint. */ async function mintCashuToken(amountSats: number): Promise { // Production: request tokens from mint // Returns a base64-encoded Cashu token return `cashuA_payout_${amountSats}_${nanoid(8)}` } // --- Lightning Integration --- // For depositing sats to get Cashu tokens, and withdrawing Cashu to Lightning export interface LightningInvoice { bolt11: string amountSats: number hash: string expiresAt: string } /** * Create a Lightning invoice for depositing sats. * User pays this invoice, receives Cashu tokens in return. * In production: calls Lightning node (LND/CLN/LDK) to create invoice. */ export async function createDepositInvoice( amountSats: number, pubkey: string, ): Promise { // Production: call LN node to create invoice // On payment confirmation, mint Cashu tokens and credit to user return { bolt11: `lnbc${amountSats}n1_deposit_${nanoid(8)}`, amountSats, hash: nanoid(32), expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(), } } /** * Withdraw Cashu tokens to a Lightning address. * Burns the Cashu tokens, pays the Lightning invoice. * In production: verify token, burn with mint, pay invoice via LN node. */ export async function withdrawToLightning( cashuToken: string, bolt11Invoice: string, ): Promise<{ success: boolean; preimage?: string; error?: string }> { // Production: verify token amount >= invoice amount, burn token, pay invoice if (!cashuToken || !bolt11Invoice) { return { success: false, error: 'Missing token or invoice.' } } // Simulate successful payment return { success: true, preimage: nanoid(32), } } // --- Bet Verification --- // Cryptographic proof that bets were settled fairly export interface BetProof { betId: string fightId: string winnerId: string | null betOnBotId: string amountSats: number oddsAtPlacement: number won: boolean payoutSats: number timestamp: string } /** * Generate a verifiable proof of bet settlement. * In production, this could be signed with a server key or * published to a Nostr relay for public verification. */ export function generateBetProof( bet: BetPlacement, settlement: BetSettlement, winnerId: string | null, ): BetProof { return { betId: bet.id, fightId: bet.fightId, winnerId, betOnBotId: bet.botId, amountSats: bet.amountSats, oddsAtPlacement: bet.oddsAtPlacement, won: settlement.won, payoutSats: settlement.payoutSats, timestamp: new Date().toISOString(), } }