Restructure bump_fee into three phases, drop float fee math (B-40)

bump_fee issued one get_transaction per input (up to 15s each) and
then a broadcast, all with the caller's DB session held open - exactly
the pattern already fixed elsewhere for the same reason (B-18's
_trigger_payout, B-31's refresh_user). Also, _prevout_amount computed
a prevout's satoshi value via round(value_coins * 100_000_000) on a
float the server reported, in a codebase that is otherwise strictly
integer-satoshi.

bump_fee now takes a session_factory and a pending_id instead of a
live session and row, with three phases: read what's needed (the
signing key, current fee rate, raw tx) and close the session before
any network call; do the chain reads, signing and broadcast with no
session open; reopen a session only to persist the outcome.
_prevout_amount now asks for the raw (non-verbose) transaction and
reads embit's parsed TransactionOutput.value directly - already an
exact integer, no float conversion involved at all.

A pending_transaction that's no longer "pending" by the time bump_fee
actually runs (it confirmed in the meantime, a normal race) is now a
quiet no-op returning None, rather than being folded into RbfBumper's
error-logging path.

Suite grows from 214 to 217 tests. BUGS.md moves B-40 to Previously
fixed, and trims its own now-stale claim that bump_fee still depended
on verbose=True (B-41) - it no longer does.
This commit is contained in:
2026-07-27 15:14:58 +02:00
parent 97545ad91f
commit 08c566d547
3 changed files with 231 additions and 99 deletions
+14 -26
View File
@@ -1,11 +1,11 @@
# Known bugs # Known bugs
A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high, A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high,
7 medium, 8 low), listed below as B-40 … B-49. B-25 through B-39 are fixed (see "Previously 7 medium, 8 low), listed below as B-41 … B-49. B-25 through B-40 are fixed (see "Previously
fixed" below) — no Critical-severity finding remains open; the other 10 are Medium/Low. fixed" below) — no Critical-severity finding remains open; the other 9 are Medium/Low.
The 139-test suite was green at the time of the audit, so none of these were caught by existing The 139-test suite was green at the time of the audit, so none of these were caught by existing
coverage — every fix lands with a regression test (the fifteen fixes so far brought the suite coverage — every fix lands with a regression test (the sixteen fixes so far brought the suite
from 139 to 214). from 139 to 217).
The recurring pattern across the open findings is worth stating once: the code is rigorous The recurring pattern across the open findings is worth stating once: the code is rigorous
about the failure modes that have actually been hit, and silent about the ones that have not. about the failure modes that have actually been hit, and silent about the ones that have not.
@@ -18,28 +18,15 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume
## Medium ## Medium
### B-40`bump_fee` holds a DB session open across N network calls ### B-41Confirmation/reconciliation logic depends on `verbose=True`, which is not universally supported
`tx/broadcast.py:80` issues one `get_transaction` **per input** (up to 15s each) and then a `poll_once` and `reconcile._tx_exists_on_chain` call `blockchain.transaction.get(txid, True)`.
`broadcast`, all with the session open. This is precisely the pattern B-18 removed from Several Electrum server implementations and versions reject the verbose flag ("verbose
`_trigger_payout` via its three-phase structure; it survives here. transactions are currently unsupported"). Falling back onto such a server means **no
confirmations, no reconciliation** — and the code would read that as a transport error and stay
Side note in the same function: `_prevout_amount` does `round(value_coins * 100_000_000)` on a silent. (`bump_fee`'s own `verbose=True` call was removed as part of the B-40 fix — it now reads
float from the server — acceptable at these magnitudes, but it is floating-point money the raw transaction and parses the output value with `embit` instead, so bumps are unaffected by
arithmetic in a codebase that is otherwise strictly integer-satoshi. this finding.)
**Proposed fix.** Restructure into the same three phases: read what is needed and close the
session, do the chain work, then reopen to persist. For the float: prefer the raw (non-verbose)
transaction and parse the output value as an integer with `embit`, which is what
`reconcile.py:_release_inputs` already does for inputs.
### B-41 — All confirmation logic depends on `verbose=True`, which is not universally supported
`poll_once`, `reconcile._tx_exists_on_chain` and `bump_fee` all call
`blockchain.transaction.get(txid, True)`. Several Electrum server implementations and versions
reject the verbose flag ("verbose transactions are currently unsupported"). Falling back onto
such a server means **no confirmations, no reconciliation, no bumps** — and the code would read
that as a transport error and stay silent.
Related: `reconcile.py:83` decides whether to **abandon a transaction** by substring-matching Related: `reconcile.py:83` decides whether to **abandon a transaction** by substring-matching
the error text (`"missing"`, `"not found"`, `"no such"`, `"unknown"`). It works against the error text (`"missing"`, `"not found"`, `"no such"`, `"unknown"`). It works against
@@ -133,9 +120,10 @@ already does.
- **B-37** — a withdrawal covered by unconfirmed change answered "insufficient balance" instead of distinguishing it from actually having no funds - **B-37** — a withdrawal covered by unconfirmed change answered "insufficient balance" instead of distinguishing it from actually having no funds
- **B-38** — the SSE subscriber cap was global, so one client opening enough connections degraded every other user to polling - **B-38** — the SSE subscriber cap was global, so one client opening enough connections degraded every other user to polling
- **B-39** — SQLite ran without WAL or a `busy_timeout`, so a writer could block every reader and a second writer failed immediately instead of waiting - **B-39** — SQLite ran without WAL or a `busy_timeout`, so a writer could block every reader and a second writer failed immediately instead of waiting
- **B-40** — `bump_fee` held a DB session open across N slow network calls, and computed a prevout's value from a server-reported float instead of an exact integer
See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the
B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37/B-38/B-39 fixes). Suite grew from 139 to 214 tests over the fifteen. B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37/B-38/B-39/B-40 fixes). Suite grew from 139 to 217 tests over the sixteen.
A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python
module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical, module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical,
+54 -26
View File
@@ -48,14 +48,14 @@ def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple: async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple:
"""Returns (signing_key, own_script, own_address) for the single sender that """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 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.""" payout. All our builders only ever spend one address's UTXOs per tx."""
if pending.kind == "payout": if kind == "payout":
key = derive_pool_key() key = derive_pool_key()
else: else:
user = await session.get(User, pending.user_id) user = await session.get(User, user_id)
key = derive_user_key(user.derivation_index) key = derive_user_key(user.derivation_index)
own_script = script.p2wpkh(key.to_public()) own_script = script.p2wpkh(key.to_public())
own_address = own_script.address(network=PLM_MAINNET) own_address = own_script.address(network=PLM_MAINNET)
@@ -63,10 +63,19 @@ async def _signing_context(session: AsyncSession, pending: PendingTransaction) -
async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int: async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int:
"""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).
"""
txid_hex = vin.txid.hex() txid_hex = vin.txid.hex()
tx = await client.get_transaction(txid_hex, verbose=True) raw_hex = await client.get_transaction(txid_hex, verbose=False)
value_coins = tx["vout"][vin.vout]["value"] prevout_tx = Transaction.parse(bytes.fromhex(raw_hex))
return round(value_coins * 100_000_000) return prevout_tx.vout[vin.vout].value
def _find_change_output(tx: Transaction, change_address: str) -> int | None: def _find_change_output(tx: Transaction, change_address: str) -> int | None:
@@ -76,35 +85,55 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
return None return None
async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str: async def bump_fee(
"""Rebuild `pending`'s transaction with a higher fee (same inputs, same session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
recipient outputs, the extra fee taken from the change output) and ) -> str | None:
rebroadcast. Returns the new txid. """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).
Three 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.
Only handles the common case: exactly one change output paying back to the 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 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 — 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 bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention. Also raises implemented for the MVP; it needs manual operator intervention. Also raises
RbfError, rather than bumping, once `pending` is already at MAX_FEE_RATE_SAT_VB 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 (B-32) — the reconciler abandons it if it never confirms (B-27), instead of
this retrying an ever-higher fee forever. this retrying an ever-higher fee forever.
""" """
# --- 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: if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
raise RbfError( raise RbfError(
f"pending_transaction {pending.id}: already at the maximum fee rate " f"pending_transaction {pending_id}: already at the maximum fee rate "
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further" f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
) )
old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex)) kind = pending.kind
signing_key, own_script, own_address = await _signing_context(session, pending) current_fee_rate = pending.fee_rate_sat_vb
raw_tx_hex = pending.raw_tx_hex
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id)
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin] input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
total_in = sum(input_amounts) total_in = sum(input_amounts)
old_fee = total_in - sum(o.value for o in old_tx.vout) old_fee = total_in - sum(o.value for o in old_tx.vout)
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
target_fee_rate = min(pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB) target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB)
target_fee = vsize * target_fee_rate target_fee = vsize * target_fee_rate
# BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor # 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 # `fee_delta` must never go below, no matter what `target_fee - old_fee` comes
@@ -120,7 +149,7 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
change_index = _find_change_output(old_tx, own_address) change_index = _find_change_output(old_tx, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta: 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") raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
new_vout = list(old_tx.vout) new_vout = list(old_tx.vout)
bumped_change = new_vout[change_index].value - fee_delta bumped_change = new_vout[change_index].value - fee_delta
@@ -144,6 +173,9 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
new_txid = final_tx.txid().hex() new_txid = final_tx.txid().hex()
await client.broadcast(raw_hex) await client.broadcast(raw_hex)
# --- Phase 3: persist the outcome ----------------------------------------------
async with session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
old_txid = pending.current_txid old_txid = pending.current_txid
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
pending.current_txid = new_txid pending.current_txid = new_txid
@@ -162,7 +194,7 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
await _retarget_txid_references(session, pending, old_txid, new_txid) await _retarget_txid_references(session, pending, old_txid, new_txid)
await session.commit() await session.commit()
logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, old_txid, new_txid) logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid)
return new_txid return new_txid
@@ -230,16 +262,12 @@ class RbfBumper:
candidates = ( candidates = (
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending")) await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
).all() ).all()
due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
for pending in due: for pending_id in due_ids:
async with self._session_factory() as session:
row = await session.get(PendingTransaction, pending.id)
if row is None or row.status != "pending":
continue
try: try:
await bump_fee(session, client, row) await bump_fee(self._session_factory, client, pending_id)
except RbfError: except RbfError:
logger.exception("could not bump pending_transaction %s", row.id) logger.exception("could not bump pending_transaction %s", pending_id)
except Exception: except Exception:
logger.exception("unexpected error bumping pending_transaction %s", row.id) logger.exception("unexpected error bumping pending_transaction %s", pending_id)
+137 -21
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from embit import script from embit import script
from embit.bip32 import HDKey from embit.bip32 import HDKey
from embit.transaction import Transaction from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings from app.config import settings
@@ -58,12 +58,23 @@ def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch):
class FakeClient: class FakeClient:
"""B-40: _prevout_amount now asks for the raw (non-verbose) transaction and
reads its output value as an integer via embit, rather than a verbose reply's
float "value" field — so this fake must hand back a real, parseable raw tx
whose vout[0] carries the requested amount (every test here spends vout 0 of
its fixture UTXO)."""
def __init__(self, prevout_values: dict[str, int]): def __init__(self, prevout_values: dict[str, int]):
self._prevout_values = prevout_values self._prevout_values = prevout_values
self.broadcasted: list[str] = [] self.broadcasted: list[str] = []
async def get_transaction(self, txid: str, verbose: bool = False) -> dict: async def get_transaction(self, txid: str, verbose: bool = False) -> str:
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}} assert verbose is False
fake_prevout_tx = Transaction(
vin=[TransactionInput(b"\x00" * 32, 0)],
vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))],
)
return fake_prevout_tx.serialize().hex()
async def broadcast(self, raw_tx_hex: str) -> str: async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex) self.broadcasted.append(raw_tx_hex)
@@ -130,9 +141,7 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
client = FakeClient({utxo_txid: utxo_amount}) client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session: new_txid = await bump_fee(session_factory, client, pending_id)
row = await session.get(PendingTransaction, pending_id)
new_txid = await bump_fee(session, client, row)
assert client.broadcasted assert client.broadcasted
assert new_txid != built.txid assert new_txid != built.txid
@@ -197,9 +206,7 @@ async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
client = FakeClient({utxo_txid: utxo_amount}) client = FakeClient({utxo_txid: utxo_amount})
before_bump = datetime.now(timezone.utc) before_bump = datetime.now(timezone.utc)
async with session_factory() as session: await bump_fee(session_factory, client, pending_id)
row = await session.get(PendingTransaction, pending_id)
await bump_fee(session, client, row)
async with session_factory() as session: async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id) row = await session.get(PendingTransaction, pending_id)
@@ -246,10 +253,8 @@ async def test_bump_fee_raises_when_no_change_output(session_factory):
client = FakeClient({utxo_txid: utxo_amount}) client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
with pytest.raises(RbfError): with pytest.raises(RbfError):
await bump_fee(session, client, row) await bump_fee(session_factory, client, pending_id)
async def test_bump_fee_retargets_every_stored_txid(session_factory): async def test_bump_fee_retargets_every_stored_txid(session_factory):
@@ -303,9 +308,7 @@ async def test_bump_fee_retargets_every_stored_txid(session_factory):
await session.commit() await session.commit()
pending_id = pending.id pending_id = pending.id
async with session_factory() as session: new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id)
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: async with session_factory() as session:
from sqlalchemy import select from sqlalchemy import select
@@ -378,9 +381,7 @@ async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target
inflated_excess = 50_000 inflated_excess = 50_000
client = FakeClient({utxo_txid: utxo_amount + inflated_excess}) client = FakeClient({utxo_txid: utxo_amount + inflated_excess})
async with session_factory() as session: new_txid = await bump_fee(session_factory, client, pending_id)
row = await session.get(PendingTransaction, pending_id)
new_txid = await bump_fee(session, client, row)
assert client.broadcasted assert client.broadcasted
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
@@ -442,9 +443,124 @@ async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory):
client = FakeClient({utxo_txid: utxo_amount}) client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
with pytest.raises(RbfError): with pytest.raises(RbfError):
await bump_fee(session, client, row) await bump_fee(session_factory, client, pending_id)
assert not client.broadcasted assert not client.broadcasted
# --- B-40: bump_fee must not hold a DB session open across its network calls,
# and a row that's no longer pending by the time it runs is a quiet no-op. -------
async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory):
"""The get_transaction-per-input reads and the broadcast must happen with no
DB session held open — the same shape used elsewhere for this reason (B-18,
electrum/listener.py's refresh_user for B-31) — otherwise a session sits
idle in the pool for the whole duration of what can be several slow network
round-trips."""
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(94).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "77" * 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="frank", 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
open_count = {"n": 0}
class _TrackedSession:
def __init__(self, inner):
self._inner = inner
async def __aenter__(self):
result = await self._inner.__aenter__()
open_count["n"] += 1
return result
async def __aexit__(self, *exc):
open_count["n"] -= 1
return await self._inner.__aexit__(*exc)
def tracking_session_factory():
return _TrackedSession(session_factory())
class TrackingClient(FakeClient):
async def get_transaction(self, txid, verbose=False):
assert open_count["n"] == 0, "a session was held open during a network call"
return await super().get_transaction(txid, verbose)
async def broadcast(self, raw_tx_hex):
assert open_count["n"] == 0, "a session was held open during the broadcast"
return await super().broadcast(raw_tx_hex)
client = TrackingClient({utxo_txid: utxo_amount})
await bump_fee(tracking_session_factory, client, pending_id)
assert client.broadcasted
assert open_count["n"] == 0 # nothing left open afterwards either
async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory):
"""A row can legitimately confirm (or otherwise leave "pending") between
being read as due and RbfBumper actually attempting the bump — a normal
race, not an error. Must return quietly rather than raising or touching
the network."""
async with session_factory() as session:
user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx")
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid="already-confirmed-txid",
fee_rate_sat_vb=1,
raw_tx_hex="00",
status="confirmed",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({})
result = await bump_fee(session_factory, client, pending_id)
assert result is None
assert not client.broadcasted
async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory):
client = FakeClient({})
result = await bump_fee(session_factory, client, 999_999)
assert result is None
assert not client.broadcasted