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>
207 lines
9.0 KiB
Python
207 lines
9.0 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from embit import script
|
|
from embit.psbt import PSBT
|
|
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
|
from embit.finalizer import finalize_psbt
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
|
|
from app.electrum.client import ElectrumClient
|
|
from app.rounds.config import get_round_config
|
|
from app.wallet.hd import derive_pool_key, derive_user_key
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_POLL_INTERVAL_SECONDS = 30
|
|
_FEE_RATE_INCREMENT = 1 # minimum relay-policy-friendly bump per BIP125
|
|
|
|
|
|
class RbfError(Exception):
|
|
pass
|
|
|
|
|
|
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
|
"""Pure decision: has this pending tx been unconfirmed for longer than the
|
|
configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the
|
|
I/O-heavy bump_fee() so it's trivially unit-testable."""
|
|
if pending.status != "pending":
|
|
return False
|
|
return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
|
|
|
|
|
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
|
|
"""Returns (signing_key, own_script, own_address) for the single sender that
|
|
controls every input of this tx — a user for bet/withdrawal, the pool for
|
|
payout. All our builders only ever spend one address's UTXOs per tx."""
|
|
if pending.kind == "payout":
|
|
key = derive_pool_key()
|
|
else:
|
|
user = await session.get(User, pending.user_id)
|
|
key = derive_user_key(user.derivation_index)
|
|
own_script = script.p2wpkh(key.to_public())
|
|
own_address = own_script.address(network=PLM_MAINNET)
|
|
return key, own_script, own_address
|
|
|
|
|
|
async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int:
|
|
txid_hex = vin.txid.hex()
|
|
tx = await client.get_transaction(txid_hex, verbose=True)
|
|
value_coins = tx["vout"][vin.vout]["value"]
|
|
return round(value_coins * 100_000_000)
|
|
|
|
|
|
def _find_change_output(tx: Transaction, change_address: str) -> int | None:
|
|
for i, out in enumerate(tx.vout):
|
|
if out.script_pubkey.address(network=PLM_MAINNET) == change_address:
|
|
return i
|
|
return None
|
|
|
|
|
|
async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str:
|
|
"""Rebuild `pending`'s transaction with a higher fee (same inputs, same
|
|
recipient outputs, the extra fee taken from the change output) and
|
|
rebroadcast. Returns the new txid.
|
|
|
|
Only handles the common case: exactly one change output paying back to the
|
|
tx's own sender address, large enough to absorb the increase. If there's no
|
|
such output (e.g. an exact-amount bet with no change), this raises RbfError —
|
|
bumping such a tx would require selecting additional inputs, which isn't
|
|
implemented for the MVP; it needs manual operator intervention.
|
|
"""
|
|
old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
|
signing_key, own_script, own_address = await _signing_context(session, pending)
|
|
|
|
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
|
|
total_in = sum(input_amounts)
|
|
old_fee = total_in - sum(o.value for o in old_tx.vout)
|
|
|
|
new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT
|
|
new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate
|
|
fee_delta = new_fee - old_fee
|
|
if fee_delta <= 0:
|
|
fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount
|
|
|
|
change_index = _find_change_output(old_tx, own_address)
|
|
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
|
|
raise RbfError(f"pending_transaction {pending.id}: no change output large enough to absorb a fee bump")
|
|
|
|
new_vout = list(old_tx.vout)
|
|
bumped_change = new_vout[change_index].value - fee_delta
|
|
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
|
|
|
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
|
|
new_tx = Transaction(vin=new_vin, vout=new_vout)
|
|
psbt = PSBT(new_tx)
|
|
for i, amount in enumerate(input_amounts):
|
|
psbt.inputs[i].witness_utxo = TransactionOutput(amount, own_script)
|
|
|
|
signed = psbt.sign_with(signing_key)
|
|
if signed != len(new_vin):
|
|
raise RuntimeError(f"expected {len(new_vin)} signatures, got {signed}")
|
|
|
|
final_tx = finalize_psbt(psbt)
|
|
if final_tx is None:
|
|
raise RuntimeError("failed to finalize bumped PSBT")
|
|
|
|
raw_hex = final_tx.serialize().hex()
|
|
new_txid = final_tx.txid().hex()
|
|
await client.broadcast(raw_hex)
|
|
|
|
old_txid = pending.current_txid
|
|
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
|
|
pending.current_txid = new_txid
|
|
pending.raw_tx_hex = raw_hex
|
|
pending.fee_rate_sat_vb = new_fee_rate
|
|
pending.attempt_count += 1
|
|
pending.broadcast_at = datetime.now(timezone.utc)
|
|
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
|
await session.commit()
|
|
|
|
logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, old_txid, new_txid)
|
|
return new_txid
|
|
|
|
|
|
async def _retarget_txid_references(
|
|
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
|
|
) -> None:
|
|
"""A bump changes the txid, and everything that recorded the old one has to
|
|
follow — otherwise the bumped tx confirms and nothing recognizes it (B-02).
|
|
|
|
The worst case was the bet path: _on_bet_confirmed used to look the participant
|
|
up by bet_txid, so after a bump it found nothing, the participant stayed
|
|
"broadcast" forever, and the scheduler waited on it forever — the round could
|
|
never close and the lottery stopped. The handlers now key off immutable ids
|
|
(round_id/user_id, withdrawal_id) as well, so this update is about keeping the
|
|
stored txids *true* — for the admin UI, for the audit trail, and for
|
|
reconcile.py, which matches UtxoEvent.spent_txid against current_txid.
|
|
"""
|
|
if pending.kind == "bet":
|
|
participant = await session.scalar(
|
|
select(RoundParticipant).where(
|
|
RoundParticipant.round_id == pending.round_id,
|
|
RoundParticipant.user_id == pending.user_id,
|
|
)
|
|
)
|
|
if participant is not None:
|
|
participant.bet_txid = new_txid
|
|
elif pending.kind == "withdrawal" and pending.withdrawal_id is not None:
|
|
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
|
|
if withdrawal is not None:
|
|
withdrawal.txid = new_txid
|
|
elif pending.kind == "payout" and pending.round_id is not None:
|
|
round_ = await session.get(Round, pending.round_id)
|
|
if round_ is not None and round_.payout_txid == old_txid:
|
|
round_.payout_txid = new_txid
|
|
|
|
# The UTXOs this tx spends are still the same UTXOs — only the id of the tx
|
|
# spending them changed. Keeping this in step is what lets reconcile.py tell
|
|
# "reserved by this pending tx" from "spent by something else".
|
|
spent = (await session.scalars(select(UtxoEvent).where(UtxoEvent.spent_txid == old_txid))).all()
|
|
for utxo in spent:
|
|
utxo.spent_txid = new_txid
|
|
|
|
|
|
class RbfBumper:
|
|
def __init__(self, session_factory: async_sessionmaker, get_client):
|
|
self._session_factory = session_factory
|
|
self._get_client = get_client
|
|
|
|
async def run(self) -> None:
|
|
while True:
|
|
client = self._get_client()
|
|
if client is not None:
|
|
try:
|
|
await self._tick(client)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("RBF bump tick failed")
|
|
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
|
|
|
async def _tick(self, client: ElectrumClient) -> None:
|
|
now = datetime.now(timezone.utc)
|
|
async with self._session_factory() as session:
|
|
timeout_seconds = (await get_round_config(session)).rbf_timeout_seconds
|
|
candidates = (
|
|
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
|
).all()
|
|
due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
|
|
|
|
for pending in due:
|
|
async with self._session_factory() as session:
|
|
row = await session.get(PendingTransaction, pending.id)
|
|
if row is None or row.status != "pending":
|
|
continue
|
|
try:
|
|
await bump_fee(session, client, row)
|
|
except RbfError:
|
|
logger.exception("could not bump pending_transaction %s", row.id)
|
|
except Exception:
|
|
logger.exception("unexpected error bumping pending_transaction %s", row.id)
|