"""B-87 (TX-11, TX-24, AUD-40, AUD-09): a bump never leaves a dust-sized output, and every bump that cannot be made is visible from /admin. `bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance withdrawal) shrank to ~153 sat after the very first minimum bump, a replacement with a sub-dust output is refused by relay policy, and the bumper retried it every 30 seconds for ever with nothing but a log line to show for it. """ import json from datetime import datetime, timedelta, timezone import pytest from embit import script from embit.finalizer import finalize_psbt from embit.psbt import PSBT from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy import select from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User from app.tx.broadcast import RbfBumper, RbfError, bump_fee from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import ( DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, Utxo, build_signed_transaction, estimate_vsize, ) from tests.unit.fake_network import FakeNetwork, rpc_error from tests.unit.test_broadcast import FakeClient, _key, _seed_payout, session_factory # noqa: F401 (fixture) _AMOUNT = 10_000_000 _DELTA = estimate_vsize(1, 2) # the first minimum bump of a one-input, two-output tx at 1 sat/vB: 141 sat def _stale() -> datetime: return datetime.now(timezone.utc) - timedelta(seconds=5000) async def _pending_bet(session_factory, *, change: int, fee_rate: int = 1, username: str = "alice"): """A real bet whose change output is exactly `change` sats, in `pending`, due for a bump.""" from app.wallet.hd import derive_user_address, derive_user_key signer = derive_user_key(0) address = derive_user_address(0) utxo_txid = "11" * 32 built = build_signed_transaction( signing_key=signer, from_script=script.p2wpkh(signer.to_public()), utxos=[Utxo(utxo_txid, 0, _AMOUNT + change)], to_address=script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET), amount_sats=_AMOUNT, change_address=address, fee_rate_sat_vb=fee_rate, ) assert built.change_sats == change async with session_factory() as session: user = User(username=username, password_hash="x", derivation_index=0, address=address) session.add(user) await session.commit() row = PendingTransaction( kind="bet", user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=fee_rate, raw_tx_hex=built.raw_hex, status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(), ) session.add(row) await session.commit() return row.id, built, FakeClient({utxo_txid: _AMOUNT + change}) def _outputs(raw_hex: str) -> list[int]: return [o.value for o in Transaction.parse(bytes.fromhex(raw_hex)).vout] # --- the change that would fall under dust is dropped into the fee (TX-11) ------------------------------ async def test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump(session_factory): """The case of the finding: a 294 sat change. The replacement carries one output (the recipient's, untouched), pays the whole change as fee, and is the last one possible.""" pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS) new_txid = await bump_fee(session_factory, client, pending_id) assert new_txid is not None and new_txid != built.txid old, new = _outputs(built.raw_hex), _outputs(client.broadcasted[0]) assert len(old) == 2 and len(new) == 1 assert new[0] == old[0] # the recipient is never touched assert sum(old) - sum(new) == DUST_LIMIT_SATS # the whole change went to the fee async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) assert row.current_txid == new_txid and row.status == "pending" assert row.fee_rate_sat_vb == (built.fee_sats + DUST_LIMIT_SATS) // estimate_vsize(1, 1) # nothing is left to reduce: any further bump is impossible, with the reason to show for it async with session_factory() as session: (await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale() await session.commit() with pytest.raises(RbfError) as refused: await bump_fee(session_factory, client, pending_id) assert refused.value.reason == "change_too_small" and len(client.broadcasted) == 1 @pytest.mark.parametrize( "change, kept", [ pytest.param(DUST_LIMIT_SATS + _DELTA, True, id="leaves-exactly-dust-limit-and-is-kept"), pytest.param(DUST_LIMIT_SATS + _DELTA - 1, False, id="one-sat-under-and-is-dropped"), pytest.param(DUST_LIMIT_SATS + _DELTA + 1_000, True, id="plenty-and-is-kept"), ], ) async def test_the_dust_boundary_of_what_a_bump_leaves(session_factory, change, kept): pending_id, built, client = await _pending_bet(session_factory, change=change) await bump_fee(session_factory, client, pending_id) new = _outputs(client.broadcasted[0]) if kept: assert len(new) == 2 and new[1] == change - _DELTA >= DUST_LIMIT_SATS else: assert len(new) == 1 assert sum(_outputs(built.raw_hex)) - sum(new) == change # all of it, not just the delta async def test_a_change_that_cannot_even_pay_the_minimum_increase_cannot_be_bumped(session_factory): """Giving the whole output to the fee must still reach BIP125's minimum increase.""" from app.wallet.hd import derive_user_address, derive_user_key signer = derive_user_key(0) address = derive_user_address(0) legacy = Transaction( vin=[TransactionInput(bytes.fromhex("22" * 32), 0, sequence=RBF_SEQUENCE)], vout=[ TransactionOutput(9_999_000, script.Script.from_address(script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET))), TransactionOutput(_DELTA - 1, script.Script.from_address(address)), ], ) async with session_factory() as session: user = User(username="bob", password_hash="x", derivation_index=0, address=address) session.add(user) await session.commit() row = PendingTransaction( kind="bet", user_id=user.id, current_txid=legacy.txid().hex(), fee_rate_sat_vb=1, raw_tx_hex=legacy.serialize().hex(), status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(), ) session.add(row) await session.commit() pending_id = row.id client = FakeClient({"22" * 32: 10_000_000}) with pytest.raises(RbfError): await bump_fee(session_factory, client, pending_id) assert client.broadcasted == [] async def test_a_payouts_bump_reserve_is_dropped_the_same_way(session_factory): """TX-11 names the reserve too: when a bump would shrink it under dust it is dropped, and the winner's and the fee address' outputs are still byte-for-byte what they were.""" built, pending_id, prevouts = await _seed_payout(session_factory, n=1, fee_rate=1) async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) row.fee_rate_sat_vb = 9 # the next target (10 sat/vB) leaves 172 sat of a 1 720 sat reserve await session.commit() client = FakeClient(prevouts) await bump_fee(session_factory, client, pending_id) old_tx = Transaction.parse(bytes.fromhex(built.raw_hex)) new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) assert len(old_tx.vout) == 3 and len(new_tx.vout) == 2 assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize() assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize() assert sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout) == old_tx.vout[2].value # --- every refused bump is visible (TX-24, AUD-40, AUD-09) ------------------------------------------------------ def _bumper(session_factory, client, network=None): return RbfBumper(session_factory, lambda: client, network=network or FakeNetwork()) async def _failures(session_factory) -> list[dict]: async with session_factory() as session: rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "rbf_bump_failed"))).all() return [dict(json.loads(r.payload_json), user_id=r.user_id, round_id=r.round_id) for r in rows] async def test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason(session_factory): pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS) bumper = _bumper(session_factory, client) await bumper._tick(client) # succeeds: the change is dropped assert await _failures(session_factory) == [] async with session_factory() as session: (await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale() await session.commit() for _ in range(3): # three 30 second rounds with the same impossible bump await bumper._tick(client) async with session_factory() as session: (await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale() await session.commit() failures = await _failures(session_factory) assert len(failures) == 1 # once, not once per tick row = failures[0] assert row["reason"] == "change_too_small" and row["kind"] == "bet" assert row["pending_transaction_id"] == pending_id and row["user_id"] is not None and row["round_id"] is None async with session_factory() as session: pending = await session.get(PendingTransaction, pending_id) assert row["txid"] == pending.current_txid != built.txid # the current txid, i.e. the one that cannot be bumped assert pending.status == "pending" # the row itself is untouched (TX-24) assert len(row["message"]) <= 200 async def test_the_maximum_fee_rate_is_reported(session_factory): pending_id, built, client = await _pending_bet(session_factory, change=1_000_000, fee_rate=MAX_FEE_RATE_SAT_VB) await _bumper(session_factory, client)._tick(client) failures = await _failures(session_factory) assert [f["reason"] for f in failures] == ["max_fee_rate"] and failures[0]["txid"] == built.txid assert client.broadcasted == [] async def test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone(session_factory): pending_id, built, client = await _pending_bet(session_factory, change=1_000_000) async def refuse(raw): raise rpc_error("min relay fee not met") client.broadcast = refuse bumper = _bumper(session_factory, client, FakeNetwork(known=False)) await bumper._tick(client) failures = await _failures(session_factory) assert [f["reason"] for f in failures] == ["replacement_rejected"] assert "min relay fee not met" in failures[0]["message"] async with session_factory() as session: assert (await session.get(PendingTransaction, pending_id)).current_txid == built.txid async def test_a_new_txid_or_a_new_reason_writes_a_new_row(session_factory): """AUD-09: the pair (current txid, reason) is what makes two failures the same one.""" pending_id, built, client = await _pending_bet(session_factory, change=1_000_000) bumper = _bumper(session_factory, client, FakeNetwork(known=False)) async def refuse(raw): raise rpc_error("min relay fee not met") real_broadcast = client.broadcast client.broadcast = refuse for _ in range(2): await bumper._tick(client) async with session_factory() as session: (await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale() await session.commit() assert [f["reason"] for f in await _failures(session_factory)] == ["replacement_rejected"] # same pair: once client.broadcast = real_broadcast # the bump now succeeds ... await bumper._tick(client) async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) assert row.current_txid != built.txid row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB # ... and the next one hits the ceiling: new txid, new reason row.last_broadcast_at = _stale() await session.commit() await bumper._tick(client) failures = await _failures(session_factory) assert [f["reason"] for f in failures] == ["replacement_rejected", "max_fee_rate"] assert failures[0]["txid"] == built.txid and failures[1]["txid"] != built.txid async def test_a_payout_failure_carries_its_round(session_factory): built, pending_id, prevouts = await _seed_payout(session_factory, n=1) async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB row.last_broadcast_at = _stale() await session.commit() client = FakeClient(prevouts) await _bumper(session_factory, client)._tick(client) failures = await _failures(session_factory) assert len(failures) == 1 and failures[0]["kind"] == "payout" and failures[0]["round_id"] == 1 assert failures[0]["user_id"] is None