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
+19 -1
View File
@@ -38,6 +38,15 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
user's own address. Adding that to cached_balance_sats gives the balance the
user will end up with once everything currently in flight confirms.
The change output's own confirmation is credited by two independent, unordered
paths: the Electrum listener (event-driven, near-instant — app/deposits/service.py
turns it into a UtxoEvent and folds it into cached_balance_sats via
recompute_balance) and this module's PendingTransaction.status flip
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
so for the gap until the poller catches up the row is still "pending" here while
the same sats are already inside cached_balance_sats — double-counting the
change unless excluded below.
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
@@ -54,10 +63,19 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
)
).all()
already_credited = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
).all()
}
pending_change_sats = 0
for row in pending:
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
for out in tx.vout:
for vout, out in enumerate(tx.vout):
if (row.current_txid, vout) in already_credited:
continue
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value