`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>
285 lines
13 KiB
Python
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
|