The code treated a broadcast as final: money moved on-chain and the DB was updated on the assumption it would either confirm or be fee-bumped until it did. Neither is guaranteed, and every way that assumption broke was permanent (BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21). Persist before broadcasting. place_bet and request_withdrawal now write their rows in a "building" state and commit, then broadcast, then promote to broadcast/pending in a second commit. Before, a failure or crash between the broadcast and the commit left the coins irreversibly spent with no trace: no participant (so no entry in the draw), no pending row (so no RBF and no confirmation tracking), and the UTXOs not even marked spent, so the next bet would try to double-spend them. A refused broadcast now releases the reserved UTXOs, restores the balance, removes the participant (or marks the withdrawal failed), audit-logs it, and answers a translatable broadcast_failed — as 502, since the network refused it, not the caller, where it used to be an opaque 500. Reconcile what's in flight against the chain. New PendingTransactionReconciler (app/tx/reconcile.py, every 120s and once at startup) asks whether each non-terminal tx exists: present -> promote, gone -> mark failed with a reason, release the inputs, roll the domain row back, audit-log it. Grace periods differ by state (120s for "building", 6h for "pending", so the RBF bumper gets its attempts first). It is deliberately biased to inaction: only a server that positively doesn't know the tx counts as absent, and a transport failure never abandons anything, because releasing a UTXO whose tx is actually alive would invite a double-spend. Verified against the live server, which answers "No such mempool or blockchain transaction" for an unknown txid. Stop keying on a value that changes. An RBF bump changes the txid, and _on_bet_confirmed looked the participant up by bet_txid — so a bumped bet confirmed under a txid no participant carried, the row stayed "broadcast" forever, and the scheduler waited on it forever: the round could never close and the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id, withdrawal_id), and bump_fee retargets every stored txid — bet_txid, Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the previous one in replaced_by_txid, which was never written at all. One bad row no longer blocks the rest. The confirmation poller's per-tx lookup is guarded: a txid the server can't resolve used to abort the whole pass, so nothing confirmed again until an operator intervened. It also selects plain columns instead of hydrating entities that outlive their session. Tests: 6 reconciler cases including "a broken connection must not release coins"; the bet-ordering test probes committed state from an independent session during the broadcast, and caught a real mistake in the first draft of this change (the _pending_transaction helper still hardcoded status="pending", so rows were born already-broadcast and would have got the 6-hour grace instead of 120s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
9.2 KiB
Python
251 lines
9.2 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),
|
|
)
|
|
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),
|
|
)
|
|
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),
|
|
)
|
|
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_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
|