Fix money-safety bugs in economy (heist, blackjack, bail)

Multi-agent audit of the money-moving economy modules surfaced three
confirmed correctness bugs. All three are fixed here with regression tests.

heist (core/economy/heist.py):
- `house = await get_user(house.HOUSE_ID)` shadowed the imported `house`
  module for the whole function, so `house.HOUSE_ID` raised UnboundLocalError
  on every successful heist (win payout was entirely dead) and on the
  fail-path compensation branch. Rename the local to `house_rec`.
- Un-shadowing exposed a latent mint: the pot was floored at 300 but the
  house was debited only min(total, balance), so a poor house paid out more
  than it lost. Cap the pot at the balance and debit exactly what is paid
  (house debit == sum of payouts). No mint, no leak.
- Add the missing `_is_jailed` import (do_heist_check referenced it unimported).

blackjack (commands/economy_games_commands.py):
- Button callbacks had no reentrancy guard; discord.py dispatches each click
  as its own task, so double-clicking Stand within the dealer-reveal window
  paid out twice (mint), and double-clicking Double/Split deducted the extra
  bet twice. Add a synchronous `_busy` guard (matching the existing RpsGame
  idiom) on all four callbacks plus a `_resolved` idempotency flag on
  settlement, so a game can only pay out once.

bail (core/economy/jail.py, commands/economy_extra_commands.py):
- do_bail only checked balance, never jail state; a double-click or a stale
  BailView from a re-run /jailbreak charged bail twice, destroying coins (bail
  is a pure sink). Make do_bail a no-op when the user is not jailed, and add a
  UI reentrancy guard + "already free" message.

Tests: 47 passed (4 new regression tests covering heist coin-conservation and
bail idempotency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene Arumetsa
2026-08-10 18:31:46 +03:00
parent 8d16da268d
commit 968356f925
6 changed files with 220 additions and 63 deletions

View File

@@ -396,12 +396,17 @@ def register_economy_extra_commands(
def __init__(self, user_id: int): def __init__(self, user_id: int):
super().__init__(timeout=60) super().__init__(timeout=60)
self.user_id = user_id self.user_id = user_id
self._paying = False
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return return
if self._paying or self.is_finished():
await interaction.response.defer()
return
self._paying = True
res = await economy.do_bail(self.user_id) res = await economy.do_bail(self.user_id)
self.clear_items() self.clear_items()
self.stop() self.stop()
@@ -414,6 +419,15 @@ def register_economy_extra_commands(
), ),
color=0xED4245, color=0xED4245,
) )
elif not res["ok"]:
# Already released (stale view or double-click) - no charge applied.
embed = discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=S.JAILBREAK_UI["bail_already_free"].format(
balance=coin(res["balance"]),
),
color=0x57F287,
)
else: else:
embed = discord.Embed( embed = discord.Embed(
title=S.TITLE["jailbreak_bail"], title=S.TITLE["jailbreak_bail"],

View File

@@ -792,6 +792,11 @@ def register_economy_games_commands(
self._doubled_hands: set[int] = set() self._doubled_hands: set[int] = set()
self._split_aces: bool = False self._split_aces: bool = False
self.message: discord.Message | None = None self.message: discord.Message | None = None
# _busy: a callback is mid-flight (prevents overlapping button tasks,
# since discord.py dispatches each click as its own task).
# _resolved: the game has paid out (makes settlement idempotent).
self._busy = False
self._resolved = False
self._refresh_buttons() self._refresh_buttons()
@property @property
@@ -864,6 +869,9 @@ def register_economy_games_commands(
return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2)
async def _resolve_all(self, interaction: discord.Interaction) -> None: async def _resolve_all(self, interaction: discord.Interaction) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id) active_games.discard(self.user_id)
self.clear_items() self.clear_items()
self.stop() self.stop()
@@ -949,6 +957,11 @@ def register_economy_games_commands(
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return return
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer() await interaction.response.defer()
self._cur_hand.append(self.deck.pop()) self._cur_hand.append(self.deck.pop())
val = _bj_value(self._cur_hand) val = _bj_value(self._cur_hand)
@@ -969,21 +982,35 @@ def register_economy_games_commands(
else: else:
self._refresh_buttons() self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self) await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def _stand(self, interaction: discord.Interaction) -> None: async def _stand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return return
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer() await interaction.response.defer()
if len(self.hands) > 1: if len(self.hands) > 1:
await self._advance_or_finish(interaction) await self._advance_or_finish(interaction)
else: else:
await self._do_dealer_reveal(interaction) await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _double(self, interaction: discord.Interaction) -> None: async def _double(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return return
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet) res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]: if not res["ok"]:
await interaction.response.send_message( await interaction.response.send_message(
@@ -997,11 +1024,18 @@ def register_economy_games_commands(
await self.message.edit(embed=self._cur_embed(), view=None) await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY) await asyncio.sleep(_BJ_DEAL_DELAY)
await self._do_dealer_reveal(interaction) await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _split_hand(self, interaction: discord.Interaction) -> None: async def _split_hand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return return
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet) res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]: if not res["ok"]:
await interaction.response.send_message( await interaction.response.send_message(
@@ -1021,8 +1055,13 @@ def register_economy_games_commands(
else: else:
self._refresh_buttons() self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self) await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id) active_games.discard(self.user_id)
try: try:
await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets))

View File

@@ -7,7 +7,7 @@ import random
from .. import pb_client from .. import pb_client
from ..pb_client import DatabaseError from ..pb_client import DatabaseError
from . import house from . import house
from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
from .house import _credit_house, _refund_house_safe, _refund_user_safe from .house import _credit_house, _refund_house_safe, _refund_user_safe
@@ -39,19 +39,25 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
failed_users: list[int] = [] failed_users: list[int] = []
if success and house.HOUSE_ID is not None: if success and house.HOUSE_ID is not None:
# NB: use a distinct local name - assigning to `house` here would shadow
# the imported module for the whole function and break `house.HOUSE_ID`.
try: try:
house = await get_user(house.HOUSE_ID) house_rec = await get_user(house.HOUSE_ID)
pct = random.uniform(0.20, 0.55) pct = random.uniform(0.20, 0.55)
total = max(300, int(house["balance"] * pct)) # Never promise more than the house actually holds: the desired pot
payout_each = total // len(user_ids) # is capped at the current balance, and each share is floored, so the
# Atomic decrement (capped at the balance we read) instead of a full # amount debited equals the amount paid out (no minting, no leak).
# record commit, so concurrent _credit_house increments aren't lost. pot = min(max(300, int(house_rec["balance"] * pct)), house_rec["balance"])
debit = min(total, house["balance"]) pot = max(0, pot)
payout_each = pot // len(user_ids)
debit = payout_each * len(user_ids)
# Atomic decrement instead of a full record commit, so concurrent
# _credit_house increments aren't lost.
if debit > 0: if debit > 0:
await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] await pb_client.update_record(house_rec["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
except DatabaseError: except DatabaseError:
return {"ok": False, "reason": "db_error"} return {"ok": False, "reason": "db_error"}
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit)
for uid in user_ids: for uid in user_ids:
async with _user_lock(uid): async with _user_lock(uid):

View File

@@ -50,6 +50,12 @@ async def do_bail(user_id: int) -> dict:
"""Charge bail fine after exhausting jailbreak rolls and free the user. """Charge bail fine after exhausting jailbreak rolls and free the user.
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
user = await get_user(user_id) user = await get_user(user_id)
# Idempotency guard: only an actively-jailed user can be charged bail. The
# first successful call clears jailed_until, so a rapid second click or a
# stale BailView from a re-run /jailbreak becomes a no-op instead of a
# second fine (bail is a pure sink - a double charge destroys coins).
if not _is_jailed(user):
return {"ok": False, "reason": "not_jailed", "balance": user["balance"]}
if user["balance"] < MIN_BAIL: if user["balance"] < MIN_BAIL:
return {"ok": False, "reason": "broke", "balance": user["balance"]} return {"ok": False, "reason": "broke", "balance": user["balance"]}
pct = random.uniform(0.20, 0.30) pct = random.uniform(0.20, 0.30)

View File

@@ -1167,6 +1167,7 @@ JAILBREAK_UI: dict[str, str] = {
"bail_btn": "💸 Maksa kautsjon", "bail_btn": "💸 Maksa kautsjon",
"bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}", "bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
"bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}", "bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}",
"bail_already_free": "✅ Oled juba vaba - kautsjonit ei võetud. Saldo: {balance}",
"fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⬡).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}", "fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⬡).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
"fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}", "fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}",
"fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni või jääda vanglasse kuni aja lõpuni.", "fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni või jääda vanglasse kuni aja lõpuni.",

View File

@@ -0,0 +1,91 @@
"""Regression tests for the money-safety audit fixes.
Covers the two pure-logic fixes:
- heist payout conserves coins (no minting when the house is poor) and the win
path no longer crashes on the shadowed `house` local.
- do_bail is idempotent: a jailed user can only be charged once per sentence, so
a double-click or a stale BailView cannot destroy coins with a second fine.
"""
from datetime import datetime, timedelta, timezone
from core import economy
from conftest import run
UID = 111
OTHER = 222
HOUSE = 999
def _fixed_now(monkeypatch, dt: datetime):
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
class TestHeistConservation:
def _setup_house(self, fake_pb, monkeypatch, balance: int):
monkeypatch.setattr(economy.house, "HOUSE_ID", HOUSE)
monkeypatch.setattr(economy.house, "_house_pb_id", None)
run(economy.get_user(HOUSE))
fake_pb.record_for(HOUSE)["balance"] = balance
def test_poor_house_win_does_not_mint(self, fake_pb, monkeypatch):
# Poor house is the case the pre-fix code minted from: the desired pot
# (floored at 300) exceeded the balance, so payouts outran the debit.
self._setup_house(fake_pb, monkeypatch, balance=100)
run(economy.get_user(UID))
run(economy.get_user(OTHER))
house_before = fake_pb.record_for(HOUSE)["balance"]
res = run(economy.do_heist_resolve([UID, OTHER], True)) # must not raise
assert res["ok"] and res["success"]
house_after = fake_pb.record_for(HOUSE)["balance"]
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
# Coin conservation: the house loses exactly what the players gain.
assert house_before - house_after == gains
assert house_after >= 0
assert res["payout_each"] * 2 == gains
def test_rich_house_win_pays_out_and_conserves(self, fake_pb, monkeypatch):
self._setup_house(fake_pb, monkeypatch, balance=100_000)
run(economy.get_user(UID))
run(economy.get_user(OTHER))
house_before = fake_pb.record_for(HOUSE)["balance"]
res = run(economy.do_heist_resolve([UID, OTHER], True))
assert res["ok"] and res["payout_each"] > 0
house_after = fake_pb.record_for(HOUSE)["balance"]
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
assert house_before - house_after == gains
class TestBailIdempotency:
def _jail(self, fake_pb, now, balance: int):
run(economy.get_user(UID))
rec = fake_pb.record_for(UID)
rec["balance"] = balance
rec["jailed_until"] = (now + timedelta(minutes=30)).isoformat()
def test_second_bail_when_free_is_noop(self, fake_pb, monkeypatch):
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
self._jail(fake_pb, now, balance=1000)
first = run(economy.do_bail(UID))
assert first["ok"]
bal_after_first = fake_pb.record_for(UID)["balance"]
assert bal_after_first < 1000 # a fine was charged
assert fake_pb.record_for(UID)["jailed_until"] is None # freed
# A double-click / stale view fires do_bail again while already free.
second = run(economy.do_bail(UID))
assert not second["ok"] and second["reason"] == "not_jailed"
assert fake_pb.record_for(UID)["balance"] == bal_after_first # no second charge
def test_bail_charges_once_for_a_real_sentence(self, fake_pb, monkeypatch):
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
self._jail(fake_pb, now, balance=1000)
res = run(economy.do_bail(UID))
assert res["ok"] and res["fine"] >= economy.MIN_BAIL