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:
+19
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user