336 lines
8.5 KiB
TypeScript
336 lines
8.5 KiB
TypeScript
// 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<string, {
|
|
fightId: string
|
|
bets: BetPlacement[]
|
|
totalPool: number
|
|
lockedAt: string
|
|
}>()
|
|
|
|
/**
|
|
* 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<BetPlacement> {
|
|
// 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.
|
|
* Uses a try/finally pattern to ensure escrow is only cleared on success.
|
|
*/
|
|
export async function settleBets(
|
|
fightId: string,
|
|
winnerId: string | null,
|
|
): Promise<BetSettlement[]> {
|
|
const pool = escrow.get(fightId)
|
|
if (!pool || pool.bets.length === 0) return []
|
|
|
|
const settlements: BetSettlement[] = []
|
|
|
|
// Process all bets before clearing escrow — if minting fails, escrow stays intact
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Only clear escrow after all settlements succeeded
|
|
// If any mintCashuToken call threw, we never reach here and escrow remains intact
|
|
escrow.delete(fightId)
|
|
|
|
return settlements
|
|
}
|
|
|
|
/**
|
|
* Clear all escrow state (used during graceful shutdown).
|
|
* Returns the number of fights with open bets that were cleared.
|
|
*/
|
|
export function clearEscrow(): number {
|
|
const count = escrow.size
|
|
escrow.clear()
|
|
return count
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<string> {
|
|
// 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<LightningInvoice> {
|
|
// 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(),
|
|
}
|
|
}
|