Files
botfights/server/src/engine/betting.test.ts
T
2026-04-11 19:46:37 +01:00

363 lines
11 KiB
TypeScript

/**
* 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<BetPlacement> {
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)
})
})