Decouple the RBF abandon clock from the bump clock (B-27)
bump_fee (tx/broadcast.py) used to overwrite PendingTransaction. broadcast_at on every fee bump, but reconcile.py's abandon-after-N- hours grace period is measured from that same column. A transaction successfully bumped every rbf_timeout_seconds (900s by default) but never mined reset that clock before it could ever reach the 6-hour abandon window, so it was never abandoned: its UTXOs never returned to the user, and if it was a bet the round stayed in "closing" indefinitely. PendingTransaction gains a last_broadcast_at column (migration 861e76aaf34c, backfilled from broadcast_at for existing rows before the NOT NULL constraint is applied). broadcast_at is now never rewritten after creation, so reconcile.py's _is_due keeps measuring from the first broadcast unchanged. bump_fee updates last_broadcast_at instead, and should_bump now reads last_broadcast_at rather than broadcast_at — correct, since whether another bump is due should reset after every bump, unlike the reconciler's abandon check, which must not. BUGS.md moves B-27 to "Previously fixed" with the fix description; the suite grows from 148 to 151 tests, including a direct proof that a tx bumped a minute ago but first broadcast 7 hours ago still gets abandoned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ def _key(seed_byte: int) -> HDKey:
|
||||
def test_should_bump_false_before_timeout():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc),
|
||||
broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
@@ -31,6 +31,7 @@ def test_should_bump_true_after_timeout():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
|
||||
|
||||
@@ -39,6 +40,19 @@ def test_should_bump_false_when_not_pending():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
|
||||
def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch):
|
||||
"""B-27 regression: a tx first broadcast long ago, but bumped recently, must not
|
||||
be due for another bump yet — should_bump has to look at last_broadcast_at, not
|
||||
the original broadcast_at, or every tick would try to re-bump it."""
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000),
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
@@ -136,6 +150,63 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
||||
assert row.attempt_count == 2
|
||||
|
||||
|
||||
async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
|
||||
"""B-27 regression: bump_fee must only ever update last_broadcast_at. Before
|
||||
this, it overwrote broadcast_at on every bump — the same field
|
||||
tx/reconcile.py's abandon-after-N-hours grace period measures from — so a
|
||||
repeatedly-bumped-but-never-mined tx reset that clock forever and was never
|
||||
abandoned."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "33" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
async with session_factory() as session:
|
||||
user = User(username="carol", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=original_broadcast_at,
|
||||
last_broadcast_at=original_broadcast_at,
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
client = FakeClient({utxo_txid: utxo_amount})
|
||||
before_bump = datetime.now(timezone.utc)
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
await bump_fee(session, client, row)
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at
|
||||
assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump
|
||||
|
||||
|
||||
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
|
||||
@@ -59,7 +59,14 @@ _RAW_TX = (
|
||||
)
|
||||
|
||||
|
||||
async def _seed_bet(session_factory, *, pending_status: str, participant_status: str, age_seconds: int):
|
||||
async def _seed_bet(
|
||||
session_factory,
|
||||
*,
|
||||
pending_status: str,
|
||||
participant_status: str,
|
||||
age_seconds: int,
|
||||
last_broadcast_age_seconds: int | None = None,
|
||||
):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -85,6 +92,10 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status:
|
||||
status=participant_status,
|
||||
)
|
||||
)
|
||||
# last_broadcast_age_seconds defaults to age_seconds (never bumped): the two
|
||||
# timestamps only diverge in the B-27 regression test below, which simulates
|
||||
# a tx that's been bumped recently but first appeared long ago.
|
||||
last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet",
|
||||
@@ -95,6 +106,7 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status:
|
||||
raw_tx_hex=_RAW_TX,
|
||||
status=pending_status,
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -167,6 +179,27 @@ async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory):
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 0
|
||||
|
||||
|
||||
async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory):
|
||||
"""B-27 regression: before last_broadcast_at existed, bump_fee overwrote
|
||||
broadcast_at on every bump, which is the same field the abandon grace period is
|
||||
measured from — so a tx first seen long ago but bumped minutes ago (exactly what
|
||||
a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was
|
||||
never abandoned. The reconciler must still abandon it based on when it *first*
|
||||
appeared, ignoring how recently it was last bumped."""
|
||||
await _seed_bet(
|
||||
session_factory,
|
||||
pending_status="pending",
|
||||
participant_status="broadcast",
|
||||
age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window
|
||||
last_broadcast_age_seconds=60, # bumped a minute ago
|
||||
)
|
||||
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).one().status == "failed"
|
||||
|
||||
|
||||
async def test_transport_failure_never_abandons_anything(session_factory):
|
||||
"""A dead connection says nothing about the transaction. Treating it as "gone"
|
||||
would release coins for transactions that are perfectly alive."""
|
||||
|
||||
Reference in New Issue
Block a user