diff --git a/server/src/engine/payment-edge.test.ts b/server/src/engine/payment-edge.test.ts new file mode 100644 index 0000000..a0101a2 --- /dev/null +++ b/server/src/engine/payment-edge.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + placeBet, + settleBets, + getFightBets, + clearEscrow, + getPoolInfo, +} from './betting.js' +import { validateBet, calculateOdds, calculatePayout } from './odds.js' + +beforeEach(() => { + clearEscrow() +}) + +describe('7.3 — simultaneous bets on same fight', () => { + it('two concurrent bets on same fight: both accepted into escrow', async () => { + const fightId = 'fight-sim-1' + const [bet1, bet2] = await Promise.all([ + placeBet(fightId, 'pub1', 'botA', 500, 'cashuA_test_500', 1200, 1200, 'botA'), + placeBet(fightId, 'pub2', 'botB', 300, 'cashuA_test_300', 1200, 1200, 'botA'), + ]) + + expect(bet1.fightId).toBe(fightId) + expect(bet2.fightId).toBe(fightId) + + const bets = getFightBets(fightId) + expect(bets).toHaveLength(2) + + const pool = getPoolInfo(fightId)! + expect(pool.totalPool).toBe(800) // 500 + 300 + expect(pool.betCount).toBe(2) + }) + + it('settlement distributes correctly with multiple bets', async () => { + const fightId = 'fight-sim-2' + // Bet 1: pub1 bets on botA (winner) + await placeBet(fightId, 'pub1', 'botA', 1000, 'cashuA_test_1000', 1200, 1200, 'botA') + // Bet 2: pub2 bets on botB (loser) + await placeBet(fightId, 'pub2', 'botB', 500, 'cashuA_test_500', 1200, 1200, 'botA') + + const settlements = await settleBets(fightId, 'botA') + expect(settlements).toHaveLength(2) + + const winner = settlements.find(s => s.won)! + const loser = settlements.find(s => !s.won)! + + expect(winner.payoutSats).toBeGreaterThan(0) + expect(winner.payoutToken).toBeTruthy() + expect(loser.payoutSats).toBe(0) + expect(loser.payoutToken).toBeNull() + }) +}) + +describe('7.3 — fight cancelled/draw: bet refund', () => { + it('draw settlement refunds all bets', async () => { + const fightId = 'fight-draw-1' + await placeBet(fightId, 'pub1', 'botA', 1000, 'cashuA_test_1000', 1200, 1200, 'botA') + await placeBet(fightId, 'pub2', 'botB', 500, 'cashuA_test_500', 1200, 1200, 'botA') + + // Draw: winnerId = null → all bets refunded + const settlements = await settleBets(fightId, null) + expect(settlements).toHaveLength(2) + + for (const s of settlements) { + expect(s.won).toBe(false) + // Refund amount equals original bet amount + expect(s.payoutSats).toBeGreaterThan(0) // refund, not zero + expect(s.payoutToken).toBeTruthy() // minted refund token + } + + // Verify bet 1 refunded correct amount + const bet1Settlement = settlements.find(s => s.payoutSats === 1000) + expect(bet1Settlement).toBeDefined() + + // Escrow cleared after settlement + expect(getFightBets(fightId)).toHaveLength(0) + }) + + it('clearEscrow returns count of orphaned fight pools', async () => { + await placeBet('fight-a', 'pub1', 'botA', 500, 'cashuA_test', 1200, 1200, 'botA') + await placeBet('fight-b', 'pub2', 'botB', 300, 'cashuA_test', 1200, 1200, 'botA') + + const cleared = clearEscrow() + expect(cleared).toBe(2) + + // All pools gone + expect(getFightBets('fight-a')).toHaveLength(0) + expect(getFightBets('fight-b')).toHaveLength(0) + }) + + it('settlement on empty fight returns empty array', async () => { + const settlements = await settleBets('nonexistent-fight', 'botA') + expect(settlements).toEqual([]) + }) +}) + +describe('7.3 — bet validation edge cases', () => { + it('rejects zero-sat bet', () => { + const result = validateBet(0) + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + + it('rejects negative bet', () => { + const result = validateBet(-100) + expect(result.valid).toBe(false) + }) + + it('rejects bet above maximum (100k sats)', () => { + const result = validateBet(100_001) + expect(result.valid).toBe(false) + }) + + it('accepts minimum valid bet (100 sats)', () => { + const result = validateBet(100) + expect(result.valid).toBe(true) + }) + + it('accepts maximum valid bet (100k sats)', () => { + const result = validateBet(100_000) + expect(result.valid).toBe(true) + }) +}) + +describe('7.3 — odds at extreme ELO gaps', () => { + it('odds remain finite and positive at huge ELO gap', () => { + const odds = calculateOdds(3000, 100) + expect(odds.botAPayoutMultiplier).toBeGreaterThan(0) + expect(odds.botBPayoutMultiplier).toBeGreaterThan(0) + expect(isFinite(odds.botAPayoutMultiplier)).toBe(true) + expect(isFinite(odds.botBPayoutMultiplier)).toBe(true) + // Favorite should have lower payout multiplier + expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botBPayoutMultiplier) + }) + + it('equal ELO produces near-even odds', () => { + const odds = calculateOdds(1200, 1200) + // Should be ~2.0x for both (minus house edge) + expect(odds.botAPayoutMultiplier).toBeCloseTo(odds.botBPayoutMultiplier, 1) + }) + + it('payout calculation is correct', () => { + const payout = calculatePayout(1000, 2.5) + expect(payout).toBe(2500) + }) +}) + +describe('7.3 — Cashu token validation', () => { + it('placeBet rejects empty token', async () => { + await expect( + placeBet('fight-1', 'pub1', 'botA', 500, '', 1200, 1200, 'botA') + ).rejects.toThrow(/Invalid.*Cashu/i) + }) + + it('placeBet rejects too-short token', async () => { + await expect( + placeBet('fight-1', 'pub1', 'botA', 500, 'short', 1200, 1200, 'botA') + ).rejects.toThrow(/Invalid.*Cashu/i) + }) + + it('placeBet accepts valid cashuA-prefixed token', async () => { + const bet = await placeBet('fight-1', 'pub1', 'botA', 500, 'cashuA_valid_token_test', 1200, 1200, 'botA') + expect(bet.id).toBeTruthy() + expect(bet.amountSats).toBe(500) + }) +}) + +describe('7.3 — wallet persistence (no server interaction)', () => { + it('escrow survives multiple bet/settle cycles without leaks', async () => { + // Cycle 1 + await placeBet('f1', 'pub1', 'botA', 500, 'cashuA_test', 1200, 1200, 'botA') + await settleBets('f1', 'botA') + expect(getPoolInfo('f1')).toBeNull() + + // Cycle 2 + await placeBet('f2', 'pub1', 'botA', 300, 'cashuA_test', 1200, 1200, 'botA') + await settleBets('f2', null) // draw refund + expect(getPoolInfo('f2')).toBeNull() + + // Cycle 3 + await placeBet('f3', 'pub1', 'botA', 200, 'cashuA_test', 1200, 1200, 'botA') + await settleBets('f3', 'botB') // loss + expect(getPoolInfo('f3')).toBeNull() + + // No orphaned escrow + expect(clearEscrow()).toBe(0) + }) +})