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
+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)