363 lines
11 KiB
TypeScript
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)
|
||
|
|
})
|
||
|
|
})
|