diff --git a/app/wallet/balance.py b/app/wallet/balance.py index d911549..6981eba 100644 --- a/app/wallet/balance.py +++ b/app/wallet/balance.py @@ -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 diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index fff442c..f86a2d9 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -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)