/** * Betting engine tests — escrow lifecycle, bet placement, settlement, and edge cases. */ import { describe, it, expect, beforeEach } from 'vitest' import { placeBet, lockBets, settleBets, clearEscrow, getFightBets, getPoolInfo, generateBetProof, type BetPlacement, type BetSettlement, } from './betting.js' import { calculateOdds } from './odds.js' // Wipe escrow between every test to prevent leakage beforeEach(() => { clearEscrow() }) // -- helpers ---------------------------------------------------------------- const VALID_TOKEN = 'cashuA_valid_token_abc123' const SHORT_TOKEN = 'short' // < 10 chars, fails verifyCashuToken async function placeDefaultBet(overrides: { fightId?: string pubkey?: string botId?: string amount?: number token?: string eloA?: number eloB?: number botAId?: string } = {}): Promise { return placeBet( overrides.fightId ?? 'fight-1', overrides.pubkey ?? 'pubkey-bettor-1', overrides.botId ?? 'botA', overrides.amount ?? 1000, overrides.token ?? VALID_TOKEN, overrides.eloA ?? 1200, overrides.eloB ?? 1200, overrides.botAId ?? 'botA', ) } // -- placeBet --------------------------------------------------------------- describe('placeBet', () => { it('valid bet creates placement with correct odds', async () => { const bet = await placeDefaultBet() expect(bet.id).toBeTruthy() expect(bet.id.length).toBe(12) expect(bet.fightId).toBe('fight-1') expect(bet.bettorPubkey).toBe('pubkey-bettor-1') expect(bet.botId).toBe('botA') expect(bet.amountSats).toBe(1000) expect(bet.cashuToken).toBe(VALID_TOKEN) // With equal ELOs, odds should be close to even (~1.94 with 3% edge) const odds = calculateOdds(1200, 1200) expect(bet.oddsAtPlacement).toBe(odds.botAPayoutMultiplier) expect(bet.potentialPayout).toBe(Math.floor(1000 * odds.botAPayoutMultiplier)) }) it('betting on bot B uses bot B payout multiplier', async () => { const bet = await placeDefaultBet({ botId: 'botB', eloA: 1500, eloB: 1200, botAId: 'botA', }) const odds = calculateOdds(1500, 1200) expect(bet.oddsAtPlacement).toBe(odds.botBPayoutMultiplier) expect(bet.potentialPayout).toBe(Math.floor(1000 * odds.botBPayoutMultiplier)) }) it('rejects bet below minimum (100 sats)', async () => { await expect(placeDefaultBet({ amount: 50 })) .rejects.toThrow('Minimum bet is 100 sats') }) it('rejects bet above maximum (100000 sats)', async () => { await expect(placeDefaultBet({ amount: 200_000 })) .rejects.toThrow('Maximum bet is 100000 sats') }) it('rejects zero amount', async () => { await expect(placeDefaultBet({ amount: 0 })) .rejects.toThrow('positive integer') }) it('rejects negative amount', async () => { await expect(placeDefaultBet({ amount: -100 })) .rejects.toThrow('positive integer') }) it('rejects non-integer amount', async () => { await expect(placeDefaultBet({ amount: 100.5 })) .rejects.toThrow('positive integer') }) it('rejects invalid Cashu token (too short)', async () => { await expect(placeDefaultBet({ token: SHORT_TOKEN })) .rejects.toThrow('Invalid or insufficient Cashu token') }) it('rejects empty Cashu token', async () => { await expect(placeDefaultBet({ token: '' })) .rejects.toThrow('Invalid or insufficient Cashu token') }) it('multiple bets accumulate in same escrow pool', async () => { await placeDefaultBet({ pubkey: 'user1', amount: 500 }) await placeDefaultBet({ pubkey: 'user2', amount: 300 }) await placeDefaultBet({ pubkey: 'user3', amount: 700 }) const bets = getFightBets('fight-1') expect(bets).toHaveLength(3) const pool = getPoolInfo('fight-1')! expect(pool.totalPool).toBe(1500) expect(pool.betCount).toBe(3) }) it('separate fights have isolated escrow pools', async () => { await placeDefaultBet({ fightId: 'fight-A', amount: 500 }) await placeDefaultBet({ fightId: 'fight-B', amount: 300 }) expect(getFightBets('fight-A')).toHaveLength(1) expect(getFightBets('fight-B')).toHaveLength(1) expect(getPoolInfo('fight-A')!.totalPool).toBe(500) expect(getPoolInfo('fight-B')!.totalPool).toBe(300) }) }) // -- lockBets --------------------------------------------------------------- describe('lockBets', () => { it('updates locked timestamp on existing pool', async () => { await placeDefaultBet() const beforeLock = new Date().toISOString() lockBets('fight-1') // Pool still exists and bets still accessible const bets = getFightBets('fight-1') expect(bets).toHaveLength(1) }) it('no-op on nonexistent fight', () => { // Should not throw expect(() => lockBets('nonexistent-fight')).not.toThrow() }) }) // -- settleBets ------------------------------------------------------------- describe('settleBets', () => { it('winners get payout tokens, losers get nothing', async () => { await placeDefaultBet({ pubkey: 'winner-pub', botId: 'botA', amount: 1000 }) await placeDefaultBet({ pubkey: 'loser-pub', botId: 'botB', amount: 500 }) const settlements = await settleBets('fight-1', 'botA') expect(settlements).toHaveLength(2) const winnerSettlement = settlements.find(s => s.won)! expect(winnerSettlement).toBeDefined() expect(winnerSettlement.payoutSats).toBeGreaterThan(0) expect(winnerSettlement.payoutToken).toBeTruthy() expect(winnerSettlement.payoutToken).toContain('cashuA_payout_') const loserSettlement = settlements.find(s => !s.won)! expect(loserSettlement).toBeDefined() expect(loserSettlement.payoutSats).toBe(0) expect(loserSettlement.payoutToken).toBeNull() }) it('winner payout matches potential payout from placement', async () => { const bet = await placeDefaultBet({ botId: 'botA', amount: 1000 }) const settlements = await settleBets('fight-1', 'botA') expect(settlements).toHaveLength(1) expect(settlements[0].won).toBe(true) expect(settlements[0].payoutSats).toBe(bet.potentialPayout) }) it('draw refunds all bets at original amount', async () => { await placeDefaultBet({ pubkey: 'pub1', botId: 'botA', amount: 1000 }) await placeDefaultBet({ pubkey: 'pub2', botId: 'botB', amount: 500 }) const settlements = await settleBets('fight-1', null) expect(settlements).toHaveLength(2) // All should be refunded (won=false but payoutSats = original amount) for (const s of settlements) { expect(s.won).toBe(false) expect(s.payoutSats).toBeGreaterThan(0) expect(s.payoutToken).toBeTruthy() } const refund1 = settlements.find(s => s.payoutSats === 1000)! const refund2 = settlements.find(s => s.payoutSats === 500)! expect(refund1).toBeDefined() expect(refund2).toBeDefined() }) it('empty pool returns empty array', async () => { const settlements = await settleBets('nonexistent-fight', 'botA') expect(settlements).toEqual([]) }) it('pool with zero bets returns empty array', async () => { // No bets placed on this fight const settlements = await settleBets('empty-fight', 'botA') expect(settlements).toEqual([]) }) it('clears escrow after successful settlement', async () => { await placeDefaultBet() await settleBets('fight-1', 'botA') expect(getFightBets('fight-1')).toEqual([]) expect(getPoolInfo('fight-1')).toBeNull() }) it('double settlement returns empty (escrow already cleared)', async () => { await placeDefaultBet() const first = await settleBets('fight-1', 'botA') const second = await settleBets('fight-1', 'botA') expect(first).toHaveLength(1) expect(second).toEqual([]) }) }) // -- clearEscrow ------------------------------------------------------------ describe('clearEscrow', () => { it('clears all pools and returns count', async () => { await placeDefaultBet({ fightId: 'fight-A' }) await placeDefaultBet({ fightId: 'fight-B' }) await placeDefaultBet({ fightId: 'fight-C' }) const count = clearEscrow() expect(count).toBe(3) expect(getPoolInfo('fight-A')).toBeNull() expect(getPoolInfo('fight-B')).toBeNull() expect(getPoolInfo('fight-C')).toBeNull() }) it('returns 0 when no escrow pools exist', () => { const count = clearEscrow() expect(count).toBe(0) }) }) // -- getFightBets ----------------------------------------------------------- describe('getFightBets', () => { it('returns correct bets for a specific fight', async () => { await placeDefaultBet({ fightId: 'fight-1', pubkey: 'pub1' }) await placeDefaultBet({ fightId: 'fight-1', pubkey: 'pub2' }) await placeDefaultBet({ fightId: 'fight-2', pubkey: 'pub3' }) const fight1Bets = getFightBets('fight-1') expect(fight1Bets).toHaveLength(2) expect(fight1Bets.every(b => b.fightId === 'fight-1')).toBe(true) const fight2Bets = getFightBets('fight-2') expect(fight2Bets).toHaveLength(1) expect(fight2Bets[0].bettorPubkey).toBe('pub3') }) it('returns empty array for unknown fight', () => { const bets = getFightBets('nonexistent') expect(bets).toEqual([]) }) }) // -- getPoolInfo ------------------------------------------------------------ describe('getPoolInfo', () => { it('returns pool aggregation for active fight', async () => { await placeDefaultBet({ pubkey: 'p1', amount: 1000 }) await placeDefaultBet({ pubkey: 'p2', amount: 500 }) const info = getPoolInfo('fight-1') expect(info).not.toBeNull() expect(info!.totalPool).toBe(1500) expect(info!.betCount).toBe(2) }) it('returns null for unknown fight', () => { expect(getPoolInfo('unknown')).toBeNull() }) }) // -- generateBetProof ------------------------------------------------------- describe('generateBetProof', () => { it('generates correct proof for a winning bet', async () => { const bet = await placeDefaultBet({ botId: 'botA', amount: 1000 }) const settlement: BetSettlement = { betId: bet.id, won: true, payoutSats: bet.potentialPayout, payoutToken: 'cashuA_payout_test', } const proof = generateBetProof(bet, settlement, 'botA') expect(proof.betId).toBe(bet.id) expect(proof.fightId).toBe('fight-1') expect(proof.winnerId).toBe('botA') expect(proof.betOnBotId).toBe('botA') expect(proof.amountSats).toBe(1000) expect(proof.oddsAtPlacement).toBe(bet.oddsAtPlacement) expect(proof.won).toBe(true) expect(proof.payoutSats).toBe(bet.potentialPayout) expect(proof.timestamp).toBeTruthy() }) it('generates correct proof for a losing bet', async () => { const bet = await placeDefaultBet({ botId: 'botB', amount: 500 }) const settlement: BetSettlement = { betId: bet.id, won: false, payoutSats: 0, payoutToken: null, } const proof = generateBetProof(bet, settlement, 'botA') expect(proof.won).toBe(false) expect(proof.payoutSats).toBe(0) expect(proof.winnerId).toBe('botA') expect(proof.betOnBotId).toBe('botB') }) it('generates correct proof for a draw', async () => { const bet = await placeDefaultBet({ amount: 1000 }) const settlement: BetSettlement = { betId: bet.id, won: false, payoutSats: 1000, payoutToken: 'cashuA_refund_test', } const proof = generateBetProof(bet, settlement, null) expect(proof.winnerId).toBeNull() expect(proof.won).toBe(false) expect(proof.payoutSats).toBe(1000) }) })