Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)

The poller and the reconciler matched current_txid only. If an earlier version of
a bumped transaction confirmed it was never seen, and after 6h the row was abandoned:
a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout
rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally,
so a crash or a `database is locked` in between left a transaction on the network that
the DB did not know, and an abandon or a confirmation in the meantime was overwritten.

- New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on
  the owning row; existing rows backfilled with current_txid + raw and the txid their
  last bump replaced, raw_hex empty). Written with the row by place_bet,
  request_withdrawal and _trigger_payout; removed with a bet row that a verified
  refusal deletes (DAT-38).
- bump_fee in four phases: read, build and sign, commit the replacement as a candidate
  version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal
  raises RbfError and leaves the row), then switch the row over with
  `UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is
  a logged conflict: nothing is overwritten and the candidate stays recorded. An
  uncertain broadcast adopts the replacement. A retry rebuilds the same candidate
  without tripping the unique txid.
- poll_once and reconcile_once look for every txid of a row. When the version found is
  not the current one, broadcast.adopt_version makes it current (raw tx included) and
  retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does,
  before the confirmation handler runs. _release_inputs frees inputs claimed by any
  version of the row.
- RbfBumper takes the listener as `network`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:46:43 +02:00
1 parent 2b6ddb019b
commit f936d6660c
23 files changed
+836 -130

No files matched your search

+3
View File
@@ -14,6 +14,7 @@ from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.tx.versions import delete_versions, record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
@@ -148,6 +149,7 @@ async def place_bet(
session.add(participant)
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
# B-53: the deadline check at the top of this function happened before the UTXO
# scan and the signing above, so re-check it here against the clock as it is now —
@@ -258,6 +260,7 @@ async def _release_failed_bet(
if row is not None:
row.spent_txid = None
await session.delete(participant)
await delete_versions(session, pending.id) # B-77 (DAT-38): the versions go with the row
await session.delete(pending)
await recompute_balance(session, user_id)
await write_audit_log(
+21
View File
@@ -200,6 +200,27 @@ class PendingTransaction(Base):
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class PendingTransactionTxid(Base):
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
one, each RBF replacement and each candidate written before a bump is broadcast.
The network may confirm *any* of them — the original while a bump was in flight, a
replacement the process crashed before recording — so the poller and the reconciler
look for every txid of a row, not only `current_txid`. Rows are never modified or
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
over the whole table, so a txid belongs to exactly one platform transaction.
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
whose raw transaction is no longer available."""
__tablename__ = "pending_transaction_txids"
id: Mapped[int] = mapped_column(primary_key=True)
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
txid: Mapped[str] = mapped_column(String(64), unique=True)
raw_hex: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class Withdrawal(Base):
__tablename__ = "withdrawals"
+1 -1
View File
@@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
scheduler = RoundScheduler(AsyncSessionLocal, listener)
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
+2
View File
@@ -18,6 +18,7 @@ from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -493,6 +494,7 @@ class RoundScheduler:
status="building",
)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version
await session.commit()
pending_id = pending.id
+110 -19
View File
@@ -6,13 +6,23 @@ 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 import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.db.models import (
PendingTransaction,
PendingTransactionTxid,
Round,
RoundParticipant,
User,
UtxoEvent,
Withdrawal,
)
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
@@ -93,28 +103,42 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
async def bump_fee(
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
session_factory: async_sessionmaker,
client: ElectrumClient,
pending_id: int,
network: TransactionNetwork | None = None,
) -> str | None:
"""Rebuild pending_transaction `pending_id`'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, or None if there was nothing
to do (the row is gone or already left "pending" — a normal race with
confirmation, not an error).
confirmation, not an error, or a conflict recorded below).
Three phases, so no DB session is held across the network calls this needs
Four phases, so no DB session is held across the network calls this needs
(one get_transaction per input, then a broadcast) — the same shape used
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
needed and close the session, do the chain work, then reopen to persist.
B-77 (TX-12, TX-13, DAT-34): the replacement is **written before it is broadcast**.
It used to be sent in the chain phase and recorded afterwards, unconditionally, so
a crash or a `database is locked` in between left a transaction on the network
that the database did not know, and an abandon or a confirmation that happened in
the meantime was silently overwritten. Now the signed replacement is committed as
a *candidate* version (pending_transaction_txids) first; a candidate that is never
broadcast is harmless, and one that is — and then confirms — is recognised like any
other version. After the broadcast the row is switched over with a conditional
UPDATE (status still pending, current_txid still the one replaced); zero rows
matched is a conflict: the row is left alone, the candidate stays recorded, and
the reconciler resolves it.
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. Also raises
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
(B-32) — the reconciler abandons it if it never confirms (B-27), instead of
this retrying an ever-higher fee forever.
(B-32), and when the network explicitly refuses the replacement (TX-24).
"""
# --- Phase 1: read what's needed, close the session before any network call ---
async with session_factory() as session:
@@ -131,9 +155,10 @@ async def bump_fee(
kind = pending.kind
current_fee_rate = pending.fee_rate_sat_vb
raw_tx_hex = pending.raw_tx_hex
old_txid = pending.current_txid
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id)
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
# --- Phase 2: chain reads and signing — no DB session open, nothing sent yet ---
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
total_in = sum(input_amounts)
@@ -178,27 +203,69 @@ async def bump_fee(
raw_hex = final_tx.serialize().hex()
new_txid = final_tx.txid().hex()
await client.broadcast(raw_hex)
# --- Phase 3: persist the outcome ----------------------------------------------
# --- Phase 3: record the replacement as a candidate, *then* broadcast ----------
async with session_factory() as session:
await begin_immediate(session) # B-88
pending = await session.get(PendingTransaction, pending_id)
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
if pending is None or pending.status != "pending" or pending.current_txid != old_txid:
logger.info("pending_transaction %s changed while its bump was being built; skipping", pending_id)
return None
await record_version(session, pending, new_txid, raw_hex) # B-77 (TX-12, DAT-71)
await session.commit()
verdict = await broadcast_with_verdict(client, network, raw_hex, new_txid) # B-76: also fans out
if verdict.outcome is BroadcastOutcome.REFUSED:
# The network explicitly refused the replacement and no server knows it: the row
# is unchanged and keeps the version that is on the network (TX-24). The
# candidate stays recorded — it was never relayed, so nobody will confirm it.
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
# follows it (TX-12); the old version stays recorded and is still recognised should
# it be the one that confirms.
# --- Phase 4: switch the row over, conditionally --------------------------------
async with session_factory() as session:
await begin_immediate(session) # B-88
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
# floor above raised fee_delta past the naive target, the tx now pays more
# than target_fee_rate implied. Recording the true rate keeps the next bump's
# arithmetic honest instead of drifting from what's really being paid.
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
pending.attempt_count += 1
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
# from it — overwriting it here used to reset that clock on every bump, so a
# repeatedly-bumped-but-never-mined tx was never abandoned.
pending.last_broadcast_at = datetime.now(timezone.utc)
result = await session.execute(
update(PendingTransaction)
.where(
PendingTransaction.id == pending_id,
PendingTransaction.status == "pending",
PendingTransaction.current_txid == old_txid,
)
.values(
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
current_txid=new_txid,
raw_tx_hex=raw_hex,
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
attempt_count=PendingTransaction.attempt_count + 1,
last_broadcast_at=datetime.now(timezone.utc),
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
# Confirmed, abandoned or already re-bumped while we were broadcasting. The
# row is not overwritten; the candidate is still recorded, and confirmation
# and reconciliation look for it like any other version (TX-13).
await session.rollback()
logger.warning(
"bump of pending_transaction %s: the row changed during the broadcast (replaced %s by %s); "
"left to the reconciler",
pending_id,
old_txid,
new_txid,
)
return None
pending = await session.get(PendingTransaction, pending_id)
await _retarget_txid_references(session, pending, old_txid, new_txid)
await session.commit()
@@ -206,6 +273,29 @@ async def bump_fee(
return new_txid
async def adopt_version(session: AsyncSession, pending: PendingTransaction, txid: str) -> None:
"""The network confirmed (or holds) a version of this row other than the current one
— an earlier version of a bumped transaction, or a candidate whose switch-over never
happened (B-77, TX-19, DAT-14). `current_txid` and `raw_tx_hex` become that
version's, and the txid is propagated everywhere it is recorded, exactly as after a
bump; without it the inputs of a replaced transaction would no longer be
recognised as the row's (DAT-13)."""
if txid == pending.current_txid:
return
version = await session.scalar(
select(PendingTransactionTxid).where(
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == pending.id
)
)
old_txid = pending.current_txid
pending.replaced_by_txid = old_txid
pending.current_txid = txid
if version is not None and version.raw_hex:
pending.raw_tx_hex = version.raw_hex # empty only for a migration-reconstructed txid
await _retarget_txid_references(session, pending, old_txid, txid)
logger.warning("pending_transaction %s: version %s found on the network, replacing %s", pending.id, txid, old_txid)
async def _retarget_txid_references(
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
) -> None:
@@ -247,9 +337,10 @@ async def _retarget_txid_references(
class RbfBumper:
def __init__(self, session_factory: async_sessionmaker, get_client):
def __init__(self, session_factory: async_sessionmaker, get_client, network: TransactionNetwork | None = None):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # B-76/B-77: verifies and fans out a replacement
async def run(self) -> None:
while True:
@@ -274,7 +365,7 @@ class RbfBumper:
for pending_id in due_ids:
try:
await bump_fee(self._session_factory, client, pending_id)
await bump_fee(self._session_factory, client, pending_id, network=self._network)
except RbfError:
logger.exception("could not bump pending_transaction %s", pending_id)
except Exception:
+15 -2
View File
@@ -10,7 +10,9 @@ from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.broadcast import adopt_version
from app.tx.pending_address import own_address_for
from app.tx.versions import txids_by_row
logger = logging.getLogger(__name__)
@@ -47,6 +49,9 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
# by scripthash below — the payout rows of one round share its pool
# address (B-80), so this also avoids asking the server the same history twice.
scripthash_by_id: dict[int, str] = {}
# B-77 (TX-06, TX-19): every txid a row ever had, not only the current one — an
# earlier version of a bumped transaction may be the one that confirms.
txids_by_id = await txids_by_row(session, {pending_id: txid for pending_id, txid, *_ in candidates})
for pending_id, _txid, kind, user_id, round_id in candidates:
try:
address = await own_address_for(session, kind, user_id, round_id)
@@ -74,11 +79,15 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
continue
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
known_txids = txids_by_id[pending_id]
# height > 0 means confirmed at that height; 0 or absent means still in
# the mempool (or the server doesn't know this txid at all yet) — either
# way, not confirmed, so keep waiting.
if entry is None or entry.get("height", 0) <= 0:
entry = next(
(e for e in history_cache[scripthash] if e.get("tx_hash") in known_txids and e.get("height", 0) > 0),
None,
)
if entry is None:
continue
async with session_factory() as session:
@@ -86,6 +95,10 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
continue
if entry["tx_hash"] != row.current_txid:
# The confirmed version is not the current one (B-77, DAT-14): realign
# the row and everything that recorded its txid *before* the handler runs.
await adopt_version(session, row, entry["tx_hash"])
row.status = "confirmed"
handler = _handlers.get(kind)
if handler is not None:
+18 -7
View File
@@ -39,7 +39,9 @@ from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.broadcast import adopt_version
from app.tx.pending_address import own_address_for
from app.tx.versions import txids_by_row, txids_of
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
@@ -98,6 +100,8 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
)
).all()
candidates = []
# B-77 (TX-15, TX-19): a row's every txid, not only the current one.
txids_by_id = await txids_by_row(session, {row.id: row.current_txid for row in rows})
for row in rows:
if not _is_due(row, now):
continue
@@ -107,15 +111,16 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
except Exception:
logger.exception("could not derive the address for pending_transaction %s", row.id)
continue
candidates.append((row.id, row.status, row.current_txid, scripthash))
candidates.append((row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id]))
resolved = 0
history_cache: dict[str, list[dict]] = {}
for row_id, status, txid, scripthash in candidates:
for row_id, status, txid, scripthash, known_txids in candidates:
try:
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
found = [entry["tx_hash"] for entry in history_cache[scripthash] if entry.get("tx_hash") in known_txids]
exists = bool(found)
except Exception:
# Transport/server problem — say nothing about this tx and try again on
# the next pass rather than abandoning a tx that may be perfectly alive.
@@ -128,6 +133,11 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
if row is None or row.status != status:
continue # something else moved it while we were asking
if exists:
if txid not in found:
# Another version of the row is the one on the network (B-77, TX-19):
# the row follows it, so the inputs and the domain row point at it.
await adopt_version(session, row, found[0])
resolved += 1
if row.status == "building":
await _promote(session, row)
resolved += 1
@@ -183,7 +193,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
row.status = "failed"
row.failure_reason = reason[:128]
released = await _release_inputs(session, row)
released = await _release_inputs(session, row, await txids_of(session, row))
if row.kind == "bet":
participant = await session.scalar(
@@ -241,7 +251,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
)
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
async def _release_inputs(session: AsyncSession, row: PendingTransaction, own_txids: set[str] | None = None) -> int:
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
counts them again. The inputs come from the stored raw tx, which is kept current
across RBF bumps, so this works for a bumped tx too."""
@@ -257,8 +267,9 @@ async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
)
# Only release what this tx actually reserved: if another tx has since spent
# the same UTXO, its claim is the live one and must not be cleared.
if utxo is not None and utxo.spent_txid == row.current_txid:
# the same UTXO, its claim is the live one and must not be cleared. "This tx"
# is any version of the row (B-77, DAT-13), not only its current one.
if utxo is not None and utxo.spent_txid in (own_txids or {row.current_txid}):
utxo.spent_txid = None
released += 1
return released
+53
View File
@@ -0,0 +1,53 @@
"""The versions of a pending transaction (B-77, DAT-70, DAT-71, TX-19).
A bumped transaction has several txids and any of them may be the one that confirms.
Everything that looks a transaction up on the chain asks for all of them."""
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingTransaction, PendingTransactionTxid
async def record_version(session: AsyncSession, pending: PendingTransaction, txid: str, raw_hex: str) -> None:
"""Write `txid` as a version of `pending` in the caller's transaction. Idempotent for
the same pair (a bump retried after an uncertain broadcast rebuilds the same
transaction); a txid already held by another row is an error — it is unique across
the platform."""
if pending.id is None:
await session.flush()
existing = await session.scalar(select(PendingTransactionTxid).where(PendingTransactionTxid.txid == txid))
if existing is not None:
if existing.pending_transaction_id != pending.id:
raise ValueError(f"txid {txid} already belongs to pending_transaction {existing.pending_transaction_id}")
return
session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=txid, raw_hex=raw_hex))
async def txids_of(session: AsyncSession, pending: PendingTransaction) -> set[str]:
"""Every txid of the row: all its recorded versions plus `current_txid` (a row
written before the table existed, or by a test, may have no version row for it)."""
rows = await session.scalars(
select(PendingTransactionTxid.txid).where(PendingTransactionTxid.pending_transaction_id == pending.id)
)
return {pending.current_txid, *rows.all()}
async def txids_by_row(session: AsyncSession, current: dict[int, str]) -> dict[int, set[str]]:
"""`txids_of` for many rows at once; `current` maps row id -> current_txid."""
result = {row_id: {txid} for row_id, txid in current.items()}
if not result:
return result
rows = await session.execute(
select(PendingTransactionTxid.pending_transaction_id, PendingTransactionTxid.txid).where(
PendingTransactionTxid.pending_transaction_id.in_(list(result))
)
)
for row_id, txid in rows.all():
result[row_id].add(txid)
return result
async def delete_versions(session: AsyncSession, pending_id: int) -> None:
"""The versions go with a `bet` row that a verified refusal deletes (DAT-38)."""
await session.execute(delete(PendingTransactionTxid).where(PendingTransactionTxid.pending_transaction_id == pending_id))
+2
View File
@@ -12,6 +12,7 @@ from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.tx.versions import record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import compute_pending_balance, recompute_balance
@@ -166,6 +167,7 @@ async def request_withdrawal(
status="building",
)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
await session.commit()
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a