Don't double-count a bet/withdrawal's own change in pending balance (B-51)

A change output's confirmation is credited by two independent, unordered
paths: the Electrum listener (event-driven, near-instant — credits it as
a UtxoEvent and folds it into cached_balance_sats via recompute_balance)
and this module's PendingTransaction.status flip (tx/confirmation.py,
polled every 10s). The listener normally wins that race, so for the gap
until the poller catches up, compute_pending_balance kept adding the same
change on top of a cached_balance_sats that already included it —
observed live as a user's displayed balance briefly jumping by exactly
the change amount before self-correcting a few seconds later.

Fix: skip any change output whose (txid, vout) already has a UtxoEvent
for this user before summing pending_change_sats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:03:53 +02:00
co-authored by Claude Sonnet 5
parent 9207bbcb8f
commit fe909bedcf
2 changed files with 67 additions and 1 deletions
+48
View File
@@ -91,6 +91,54 @@ async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_
assert pending_balance == 2_000_000_000
async def test_pending_balance_does_not_double_count_change_already_credited(session_factory):
"""The Electrum listener (event-driven) and the confirmation poller (10s
cadence) independently react to the same change output confirming. When the
listener wins that race — the common case — the change is already a
UtxoEvent inside cached_balance_sats while the PendingTransaction row is
still "pending". compute_pending_balance must not add the change a second
time in that window."""
user_id = await _make_funded_user(session_factory, 4, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
change_vout, change_out = next(
(i, out) for i, out in enumerate(tx.vout) if out.script_pubkey.address(network=PLM_MAINNET) == user.address
)
user = await session.get(User, user_id)
# Simulate the listener having already credited the change output as
# confirmed, before the poller has flipped `pending.status`.
session.add(
UtxoEvent(
user_id=user_id,
txid=pending.current_txid,
vout=change_vout,
amount_sats=change_out.value,
confirmed_height=101,
)
)
await recompute_balance(session, user_id)
await session.commit()
async with session_factory() as session:
user = await session.get(User, user_id)
pending_balance, has_pending = await compute_pending_balance(session, user)
assert has_pending is True # the PendingTransaction row is still "pending"
assert pending_balance == user.cached_balance_sats # already-credited change isn't added again
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)