Files
plm-lottery/tests/unit/test_rbf_dust.py
T
davideandClaude Sonnet 5.5 ccd15807ed Drop a dust-sized change into the fee on an RBF bump and audit every refused bump (B-87, TX-11, TX-24, AUD-40, AUD-09, ADR-19)
`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, relay policy refuses a
replacement with a sub-dust output, and the bumper tried again every 30 seconds for ever, with
nothing but a log line to show for it.

- When what would be left of the reducible output (the user's change, a payout's bump
  reserve) is under DUST_LIMIT_SATS the output is dropped and its whole value goes to the fee:
  that replacement is valid and the last one possible. If the output does not exist, or even
  its whole value is below BIP125's minimum increase (vsize x 1 sat/vB), RbfError. The
  recorded fee rate uses the replacement's own, smaller, size.
- RbfError now carries a `reason` (`change_too_small`, `max_fee_rate`, `replacement_rejected`);
  RbfBumper._record_failure writes one `rbf_bump_failed` row {pending_transaction_id, kind,
  txid, reason, message[:200]} with the row's user_id/round_id, in a transaction of its own,
  once per (current txid, reason) - the audit log is the dedupe clock (AUD-09) - and the pending
  row is untouched. A new txid or a new reason writes a new row.
- bump_fee's docstring no longer cites the "bet without change" case that B-62 removed.

No existing test changed (all bumps they make leave a change well above dust). New
tests/unit/test_rbf_dust.py: the 294 sat case end to end (recipient untouched, whole change to
the fee, last bump impossible), the exact boundary (leaves 294 kept / leaves 293 dropped), a
change under the minimum increase, a payout's reserve dropped with the winner and fee outputs
byte-identical, and the audit rows for each reason, once per pair, new row on a new txid or
reason, round/user attribution. Mutation-checked: the dust test of the output and the
once-per-pair check each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:53:10 +02:00

285 lines
13 KiB
Python

"""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