2026-07-21 10:25:57 +02:00
|
|
|
import asyncio
|
2026-10-16 14:53:10 +02:00
|
|
|
import json
|
2026-07-21 10:25:57 +02:00
|
|
|
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
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
from sqlalchemy import select, update
|
2026-07-21 10:25:57 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
|
|
2026-10-16 14:53:10 +02:00
|
|
|
from app.audit.log import write_audit_log
|
2026-09-29 16:26:01 +02:00
|
|
|
from app.db.base import begin_immediate
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
from app.db.models import (
|
2026-10-16 14:53:10 +02:00
|
|
|
AuditLog,
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
PendingTransaction,
|
|
|
|
|
PendingTransactionTxid,
|
|
|
|
|
Round,
|
|
|
|
|
RoundParticipant,
|
|
|
|
|
User,
|
|
|
|
|
UtxoEvent,
|
|
|
|
|
Withdrawal,
|
|
|
|
|
)
|
2026-07-21 10:25:57 +02:00
|
|
|
from app.electrum.client import ElectrumClient
|
2026-07-21 15:05:40 +02:00
|
|
|
from app.rounds.config import get_round_config
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
|
|
|
|
from app.tx.versions import record_version
|
Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
2026-10-16 13:55:51 +02:00
|
|
|
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
|
2026-07-21 10:25:57 +02:00
|
|
|
from app.wallet.plm_network import PLM_MAINNET
|
2026-10-16 14:53:10 +02:00
|
|
|
from app.wallet.psbt_builder import DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_POLL_INTERVAL_SECONDS = 30
|
2026-07-27 11:16:20 +02:00
|
|
|
_FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by per bump
|
|
|
|
|
|
|
|
|
|
# BIP125 rule 4: a replacement transaction must pay at least this much more, in
|
|
|
|
|
# total, per vbyte of its own size, than the transaction it replaces — Bitcoin
|
|
|
|
|
# Core's default incremental relay fee. bump_fee's delta must never fall below
|
|
|
|
|
# this regardless of what the target-rate arithmetic comes out to (B-32).
|
|
|
|
|
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
|
2026-10-16 14:53:10 +02:00
|
|
|
# B-87 (TX-24, AUD-40): why a bump could not be made — the `reason` of the `rbf_bump_failed`
|
|
|
|
|
# audit row the bumper writes.
|
|
|
|
|
REASON_CHANGE_TOO_SMALL = "change_too_small"
|
|
|
|
|
REASON_MAX_FEE_RATE = "max_fee_rate"
|
|
|
|
|
REASON_REPLACEMENT_REJECTED = "replacement_rejected"
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:25:57 +02:00
|
|
|
class RbfError(Exception):
|
2026-10-16 14:53:10 +02:00
|
|
|
def __init__(self, message: str, reason: str = REASON_CHANGE_TOO_SMALL):
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.reason = reason
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
|
2026-07-21 15:05:40 +02:00
|
|
|
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
2026-07-27 09:39:38 +02:00
|
|
|
"""Pure decision: has this pending tx gone unconfirmed for longer than the
|
|
|
|
|
configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last
|
|
|
|
|
broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially
|
|
|
|
|
unit-testable.
|
|
|
|
|
|
|
|
|
|
Deliberately measured from last_broadcast_at, not broadcast_at: this decides
|
|
|
|
|
whether *another* bump is due, which should reset after every bump (a tx just
|
|
|
|
|
rebroadcast at a higher fee deserves the same grace period again) — unlike
|
|
|
|
|
reconcile.py's abandon check, which must measure from the *first* broadcast so
|
|
|
|
|
repeated bumping can't indefinitely postpone ever giving up on a tx (B-27)."""
|
2026-07-21 10:25:57 +02:00
|
|
|
if pending.status != "pending":
|
|
|
|
|
return False
|
2026-07-27 09:39:38 +02:00
|
|
|
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
async def _signing_context(
|
|
|
|
|
session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None
|
|
|
|
|
) -> tuple:
|
2026-07-21 10:25:57 +02:00
|
|
|
"""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
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
payout (the round's own pool key, B-80). All our builders only ever spend one
|
|
|
|
|
address's UTXOs per tx."""
|
2026-07-27 15:14:58 +02:00
|
|
|
if kind == "payout":
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
round_ = await session.get(Round, round_id) if round_id is not None else None
|
|
|
|
|
if round_ is None or not round_.pool_address:
|
|
|
|
|
raise RbfError(f"payout of round {round_id}: the round has no pool address")
|
|
|
|
|
key = derive_round_pool_key(round_.id, round_.pool_address)
|
2026-07-21 10:25:57 +02:00
|
|
|
else:
|
2026-07-27 15:14:58 +02:00
|
|
|
user = await session.get(User, user_id)
|
2026-07-21 10:25:57 +02:00
|
|
|
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:
|
2026-07-27 15:14:58 +02:00
|
|
|
"""The exact integer satoshi value of the output this input spends.
|
|
|
|
|
|
|
|
|
|
Parsed directly from the raw transaction via embit rather than asking the
|
|
|
|
|
server for its own float, whole-coin-denominated "value" field (verbose=True)
|
|
|
|
|
and converting with `* 100_000_000` — embit's TransactionOutput.value is
|
|
|
|
|
already an integer number of satoshis straight from the tx's binary
|
|
|
|
|
encoding, so this never touches floating point in a codebase that is
|
|
|
|
|
otherwise strictly integer-satoshi (B-40).
|
|
|
|
|
"""
|
2026-07-21 10:25:57 +02:00
|
|
|
txid_hex = vin.txid.hex()
|
2026-07-27 15:14:58 +02:00
|
|
|
raw_hex = await client.get_transaction(txid_hex, verbose=False)
|
|
|
|
|
prevout_tx = Transaction.parse(bytes.fromhex(raw_hex))
|
|
|
|
|
return prevout_tx.vout[vin.vout].value
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
2026-10-16 13:55:51 +02:00
|
|
|
def _find_reducible_output(tx: Transaction, kind: str, own_address: str) -> int | None:
|
|
|
|
|
"""The one output an RBF bump takes the extra fee from (TX-11): the sender's own change
|
|
|
|
|
for a bet or a withdrawal; for a payout the **bump reserve** output paid to the reserve
|
|
|
|
|
address (B-79, RND-38), falling back to a change output back to the pool for a payout
|
|
|
|
|
that was already in flight before the reserve existed. Counterparty outputs — the
|
|
|
|
|
winner's, the fee address's, an external recipient's — are never candidates."""
|
|
|
|
|
if kind == "payout":
|
|
|
|
|
reserve_index = _find_change_output(tx, derive_pool_address())
|
|
|
|
|
if reserve_index is not None:
|
|
|
|
|
return reserve_index
|
|
|
|
|
return _find_change_output(tx, own_address)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 15:14:58 +02:00
|
|
|
async def bump_fee(
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
session_factory: async_sessionmaker,
|
|
|
|
|
client: ElectrumClient,
|
|
|
|
|
pending_id: int,
|
|
|
|
|
network: TransactionNetwork | None = None,
|
2026-07-27 15:14:58 +02:00
|
|
|
) -> 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
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
confirmation, not an error, or a conflict recorded below).
|
2026-07-27 15:14:58 +02:00
|
|
|
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
Four phases, so no DB session is held across the network calls this needs
|
2026-07-27 15:14:58 +02:00
|
|
|
(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.
|
2026-07-21 10:25:57 +02:00
|
|
|
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-10-16 14:53:10 +02:00
|
|
|
Only handles one shape: exactly one output to take the increase from — the sender's
|
|
|
|
|
own change, or a payout's bump reserve (B-79) — and when what would be left of it is
|
|
|
|
|
below the dust limit the output is dropped and its whole value goes to the fee, which
|
|
|
|
|
makes that replacement the last possible one (B-87, TX-11). With no such output, or
|
|
|
|
|
when even its whole value is below BIP125's minimum increase, this raises RbfError —
|
|
|
|
|
adding inputs would not help the case that used to hurt (a transaction that already
|
|
|
|
|
spends everything the sender has). Also raises RbfError, rather than bumping, once the
|
|
|
|
|
row is already at MAX_FEE_RATE_SAT_VB (B-32), and when the network explicitly refuses
|
|
|
|
|
the replacement (TX-24). Each carries a `reason` for the `rbf_bump_failed` audit row
|
|
|
|
|
(RbfBumper._record_failure).
|
2026-07-21 10:25:57 +02:00
|
|
|
"""
|
2026-07-27 15:14:58 +02:00
|
|
|
# --- Phase 1: read what's needed, close the session before any network call ---
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
pending = await session.get(PendingTransaction, pending_id)
|
|
|
|
|
if pending is None or pending.status != "pending":
|
|
|
|
|
logger.info("pending_transaction %s no longer pending; skipping bump", pending_id)
|
|
|
|
|
return None
|
|
|
|
|
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
|
|
|
|
|
raise RbfError(
|
|
|
|
|
f"pending_transaction {pending_id}: already at the maximum fee rate "
|
2026-10-16 14:53:10 +02:00
|
|
|
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further",
|
|
|
|
|
REASON_MAX_FEE_RATE,
|
2026-07-27 15:14:58 +02:00
|
|
|
)
|
2026-07-27 11:16:20 +02:00
|
|
|
|
2026-07-27 15:14:58 +02:00
|
|
|
kind = pending.kind
|
|
|
|
|
current_fee_rate = pending.fee_rate_sat_vb
|
|
|
|
|
raw_tx_hex = pending.raw_tx_hex
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
old_txid = pending.current_txid
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id)
|
2026-07-21 10:25:57 +02:00
|
|
|
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
# --- Phase 2: chain reads and signing — no DB session open, nothing sent yet ---
|
2026-07-27 15:14:58 +02:00
|
|
|
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
|
2026-07-21 10:25:57 +02:00
|
|
|
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)
|
2026-07-27 11:16:20 +02:00
|
|
|
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
|
2026-07-21 10:25:57 +02:00
|
|
|
|
2026-07-27 15:14:58 +02:00
|
|
|
target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB)
|
2026-07-27 11:16:20 +02:00
|
|
|
target_fee = vsize * target_fee_rate
|
|
|
|
|
# BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor
|
|
|
|
|
# `fee_delta` must never go below, no matter what `target_fee - old_fee` comes
|
|
|
|
|
# out to. That naive difference used to go to zero or negative whenever
|
|
|
|
|
# old_fee already exceeded target_fee (e.g. a dust change amount folded into
|
|
|
|
|
# the original fee — wallet/psbt_builder.py's DUST_LIMIT_SATS handling), and
|
|
|
|
|
# the previous fallback — a flat 1-satoshi total bump — was nowhere near this
|
|
|
|
|
# relay-mandated minimum, so the node rejected it every time. Because bump_fee
|
|
|
|
|
# raised before touching `pending`, the next tick retried with identical
|
|
|
|
|
# parameters every 30 seconds, forever (B-32).
|
|
|
|
|
min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB
|
|
|
|
|
fee_delta = max(target_fee - old_fee, min_valid_delta)
|
2026-07-21 10:25:57 +02:00
|
|
|
|
Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
2026-10-16 13:55:51 +02:00
|
|
|
change_index = _find_reducible_output(old_tx, kind, own_address)
|
2026-10-16 14:53:10 +02:00
|
|
|
if change_index is None:
|
|
|
|
|
raise RbfError(f"pending_transaction {pending_id}: there is no output to take a fee bump from")
|
|
|
|
|
reducible_value = old_tx.vout[change_index].value
|
|
|
|
|
if reducible_value < min_valid_delta:
|
|
|
|
|
# Not even giving the whole output to the fee reaches BIP125's minimum increase.
|
|
|
|
|
raise RbfError(
|
|
|
|
|
f"pending_transaction {pending_id}: its change ({reducible_value} sat) cannot even cover the "
|
|
|
|
|
f"minimum relay increase ({min_valid_delta} sat)"
|
|
|
|
|
)
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
new_vout = list(old_tx.vout)
|
2026-10-16 14:53:10 +02:00
|
|
|
bumped_change = reducible_value - fee_delta
|
|
|
|
|
if bumped_change >= DUST_LIMIT_SATS:
|
|
|
|
|
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
|
|
|
|
else:
|
|
|
|
|
# B-87 (TX-11): what would be left is below the dust limit, an output no node relays — a
|
|
|
|
|
# 294 sat change shrinks to ~153 after the very first minimum bump, and every replacement
|
|
|
|
|
# kept by it was refused, every 30 seconds, for ever. Drop the output instead and give its
|
|
|
|
|
# whole value to the fee: the replacement is valid, and it is the last one this
|
|
|
|
|
# transaction can have, since nothing is left to reduce.
|
|
|
|
|
del new_vout[change_index]
|
|
|
|
|
fee_delta = reducible_value
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
# --- Phase 3: record the replacement as a candidate, *then* broadcast ----------
|
2026-07-27 15:14:58 +02:00
|
|
|
async with session_factory() as session:
|
2026-09-29 16:26:01 +02:00
|
|
|
await begin_immediate(session) # B-88
|
2026-07-27 15:14:58 +02:00
|
|
|
pending = await session.get(PendingTransaction, pending_id)
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
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.
|
2026-10-16 14:53:10 +02:00
|
|
|
raise RbfError(
|
|
|
|
|
f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}",
|
|
|
|
|
REASON_REPLACEMENT_REJECTED,
|
|
|
|
|
)
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
# 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
|
2026-07-27 15:14:58 +02:00
|
|
|
# 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.
|
|
|
|
|
# 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.
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
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,
|
2026-10-16 14:53:10 +02:00
|
|
|
fee_rate_sat_vb=(old_fee + fee_delta) // estimate_vsize(len(new_vin), len(new_vout)),
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
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)
|
2026-07-27 15:14:58 +02:00
|
|
|
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
|
|
|
|
await session.commit()
|
2026-07-21 10:25:57 +02:00
|
|
|
|
2026-07-27 15:14:58 +02:00
|
|
|
logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid)
|
2026-07-21 10:25:57 +02:00
|
|
|
return new_txid
|
|
|
|
|
|
|
|
|
|
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 00:31:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:25:57 +02:00
|
|
|
class RbfBumper:
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
def __init__(self, session_factory: async_sessionmaker, get_client, network: TransactionNetwork | None = None):
|
2026-07-21 10:25:57 +02:00
|
|
|
self._session_factory = session_factory
|
|
|
|
|
self._get_client = get_client
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
self._network = network # B-76/B-77: verifies and fans out a replacement
|
2026-07-21 10:25:57 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-10-16 14:53:10 +02:00
|
|
|
async def _record_failure(self, pending_id: int, exc: RbfError) -> None:
|
|
|
|
|
"""B-87 (TX-24, AUD-40, AUD-09): a bump that cannot be made is visible from /admin as one
|
|
|
|
|
`rbf_bump_failed` row — in a transaction of its own, the row stays `pending` — and only
|
|
|
|
|
**once per (current txid, reason)**: the same failure on the same transaction every 30
|
|
|
|
|
seconds goes to the log alone, while a new txid (after a successful bump) or another
|
|
|
|
|
reason writes a new row."""
|
|
|
|
|
try:
|
|
|
|
|
async with self._session_factory() as session:
|
|
|
|
|
pending = await session.get(PendingTransaction, pending_id)
|
|
|
|
|
if pending is None:
|
|
|
|
|
return
|
|
|
|
|
already = (
|
|
|
|
|
await session.scalars(
|
|
|
|
|
select(AuditLog.payload_json).where(
|
|
|
|
|
AuditLog.event_type == "rbf_bump_failed",
|
|
|
|
|
AuditLog.payload_json.contains(f'"txid": "{pending.current_txid}"'),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
for payload_json in already:
|
|
|
|
|
payload = json.loads(payload_json)
|
|
|
|
|
if payload.get("txid") == pending.current_txid and payload.get("reason") == exc.reason:
|
|
|
|
|
return
|
|
|
|
|
await write_audit_log(
|
|
|
|
|
session,
|
|
|
|
|
"rbf_bump_failed",
|
|
|
|
|
{
|
|
|
|
|
"pending_transaction_id": pending.id,
|
|
|
|
|
"kind": pending.kind,
|
|
|
|
|
"txid": pending.current_txid,
|
|
|
|
|
"reason": exc.reason,
|
|
|
|
|
"message": str(exc)[:200],
|
|
|
|
|
},
|
|
|
|
|
user_id=pending.user_id,
|
|
|
|
|
round_id=pending.round_id,
|
|
|
|
|
)
|
|
|
|
|
await session.commit()
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("could not record the failed bump of pending_transaction %s", pending_id)
|
|
|
|
|
|
2026-07-21 10:25:57 +02:00
|
|
|
async def _tick(self, client: ElectrumClient) -> None:
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
async with self._session_factory() as session:
|
2026-07-21 15:05:40 +02:00
|
|
|
timeout_seconds = (await get_round_config(session)).rbf_timeout_seconds
|
2026-07-21 10:25:57 +02:00
|
|
|
candidates = (
|
|
|
|
|
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
|
|
|
|
).all()
|
2026-07-27 15:14:58 +02:00
|
|
|
due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
|
2026-07-21 10:25:57 +02:00
|
|
|
|
2026-07-27 15:14:58 +02:00
|
|
|
for pending_id in due_ids:
|
|
|
|
|
try:
|
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)
2026-10-14 14:46:43 +02:00
|
|
|
await bump_fee(self._session_factory, client, pending_id, network=self._network)
|
2026-10-16 14:53:10 +02:00
|
|
|
except RbfError as exc:
|
2026-07-27 15:14:58 +02:00
|
|
|
logger.exception("could not bump pending_transaction %s", pending_id)
|
2026-10-16 14:53:10 +02:00
|
|
|
await self._record_failure(pending_id, exc)
|
2026-07-27 15:14:58 +02:00
|
|
|
except Exception:
|
|
|
|
|
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|