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>
322 lines
12 KiB
Python
322 lines
12 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from embit import script
|
|
from embit.bip32 import HDKey
|
|
from embit.transaction import Transaction
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.config import settings
|
|
from app.db.base import Base
|
|
from app.db.models import PendingTransaction, User
|
|
from app.tx.broadcast import RbfError, bump_fee, should_bump
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
from app.wallet.psbt_builder import Utxo, build_signed_transaction
|
|
|
|
|
|
def _key(seed_byte: int) -> HDKey:
|
|
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
|
|
return root.derive("m/84h/746h/0h/0/0")
|
|
|
|
|
|
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), last_broadcast_at=datetime.now(timezone.utc),
|
|
)
|
|
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, prevout_values: dict[str, int]):
|
|
self._prevout_values = prevout_values
|
|
self.broadcasted: list[str] = []
|
|
|
|
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
|
|
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}}
|
|
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
|
self.broadcasted.append(raw_tx_hex)
|
|
return "network-txid"
|
|
|
|
|
|
@pytest.fixture
|
|
async def session_factory(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
|
monkeypatch.setattr(
|
|
settings,
|
|
"xprv_encryption_key",
|
|
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
|
)
|
|
from app.wallet import hd
|
|
|
|
hd._account_key = None
|
|
hd.generate_master_key()
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield async_sessionmaker(engine, expire_on_commit=False)
|
|
await engine.dispose()
|
|
hd._account_key = None
|
|
|
|
|
|
async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
|
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(99).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "11" * 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,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="alice", 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=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
new_txid = await bump_fee(session, client, row)
|
|
|
|
assert client.broadcasted
|
|
assert new_txid != built.txid
|
|
|
|
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
|
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
|
assert new_change < old_change # fee bump came out of the change output
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
assert row.current_txid == new_txid
|
|
assert row.fee_rate_sat_vb == 2
|
|
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
|
|
|
|
signer = derive_user_key(0)
|
|
my_address = derive_user_address(0)
|
|
from_script = script.p2wpkh(signer.to_public())
|
|
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 10_000_000 # exact amount, no change output
|
|
utxo_txid = "22" * 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,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="bob", 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=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
client = FakeClient({utxo_txid: utxo_amount})
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
with pytest.raises(RbfError):
|
|
await bump_fee(session, client, row)
|
|
|
|
|
|
async def test_bump_fee_retargets_every_stored_txid(session_factory):
|
|
"""B-02/B-20: a bump changes the txid, and everything that recorded the old one
|
|
has to follow — the participant's bet_txid (whose staleness used to wedge the
|
|
round forever), the UTXO's spent_txid (which the reconciler matches on), and
|
|
replaced_by_txid, which was never written at all."""
|
|
from app.db.models import Round, RoundParticipant, UtxoEvent
|
|
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(98).to_public()).address(network=PLM_MAINNET)
|
|
|
|
utxo_amount = 150_000_000
|
|
utxo_txid = "22" * 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,
|
|
)
|
|
|
|
async with session_factory() as session:
|
|
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
|
session.add(user)
|
|
session.add(Round(id=1, status="open"))
|
|
await session.flush()
|
|
session.add(
|
|
UtxoEvent(
|
|
user_id=user.id, txid=utxo_txid, vout=0, amount_sats=utxo_amount,
|
|
confirmed_height=5, spent_txid=built.txid,
|
|
)
|
|
)
|
|
session.add(
|
|
RoundParticipant(
|
|
round_id=1, user_id=user.id, bet_amount_sats=built.recipient_sats,
|
|
bet_txid=built.txid, status="broadcast",
|
|
)
|
|
)
|
|
pending = PendingTransaction(
|
|
kind="bet", round_id=1, user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=1,
|
|
raw_tx_hex=built.raw_hex, status="pending",
|
|
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
|
)
|
|
session.add(pending)
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
async with session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
new_txid = await bump_fee(session, FakeClient({utxo_txid: utxo_amount}), row)
|
|
|
|
async with session_factory() as session:
|
|
from sqlalchemy import select
|
|
|
|
row = await session.get(PendingTransaction, pending_id)
|
|
assert row.current_txid == new_txid
|
|
assert row.replaced_by_txid == built.txid # points backwards at what it replaced
|
|
|
|
participant = (await session.scalars(select(RoundParticipant))).one()
|
|
assert participant.bet_txid == new_txid
|
|
|
|
utxo = (await session.scalars(select(UtxoEvent))).one()
|
|
assert utxo.spent_txid == new_txid
|