Source code for btclib_node.mempool

# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.

"""`Mempool`, this node's set of transactions not yet in a block.

Reached from `Node`'s own thread alone -- `add_tx` and `remove_tx` are
called from the p2p callbacks, the rpc callbacks and `main.update_chain`,
never from `P2pManager`'s or `RpcManager`'s own asyncio loop -- so it
carries no lock of its own. The rolling minimum feerate an eviction
round leaves behind decays the way Core's own does, `_ROLLING_FEE_HALFLIFE`
below being `ROLLING_FEE_HALFLIFE` (`src/txmempool.h`).
"""

import heapq
import time
from fractions import Fraction
from typing import TYPE_CHECKING

from btclib.fee import FeeRate, fee_from_vsize

if TYPE_CHECKING:
    from collections.abc import Iterable

    from btclib.tx.tx import Tx

    from btclib_node.log import Logger

__all__ = ["Mempool"]

# Core's own `DEFAULT_INCREMENTAL_RELAY_FEE` (`src/policy/policy.h`,
# at bitcoin/bitcoin@58a7869f86): what an eviction round bumps the rolling
# minimum to, above the feerate of whatever it just evicted, so a
# transaction does not requalify at the exact rate something was just
# evicted for. A constant of this module and not `Config.min_relay_feerate`
# -- that field is BIP133's own floor, documented in `config.py` as "not
# enforced anywhere else" than the `feefilter` this node sends, and
# reading it here for a second purpose would make that comment false for
# a reason nobody asked for. Core keeps the two as separate knobs,
# `-minrelaytxfee` and `-incrementalrelayfee`, that merely share a
# default; this module does the same rather than coupling to a field
# whose own documentation disclaims the coupling.
_INCREMENTAL_RELAY_FEE_RATE = FeeRate(sats_per_kvbyte=100)

# Core's own `ROLLING_FEE_HALFLIFE` (`src/txmempool.h:212`, same commit):
# seconds for the rolling minimum to decay by half once it is decaying at
# all, shortened as this mempool empties -- `get_min_fee_rate` below.
_ROLLING_FEE_HALFLIFE = 60 * 60 * 12


[docs] class Mempool: """The node's set of transactions not yet in a block, keyed both ways. `transactions` is by wtxid, `txid_index` maps a txid to the wtxid that holds it, and `fees` carries what each entry paid -- the module docstring above is where the single-thread invariant that lets this class carry no lock of its own is argued. `spent_by` is the fourth index, `_descendants` below is where it is read; `_feerate_heap` is the fifth, `_pop_worst_wtxid` below being where it is read and `_rebuild_feerate_heap` where it is kept from growing without bound. `_heap_current_seq` is the sixth, and is what tells a heap entry for a wtxid still held apart from one superseded by a later re-add of the same wtxid -- `_pop_worst_wtxid` again being where that is read. """ def __init__(self, logger: Logger) -> None: """Start empty, with the rolling minimum feerate at zero, undecayed.""" self.logger = logger self.transactions: dict[bytes, Tx] = {} self.txid_index: dict[bytes, bytes] = {} # wtxid -> fee in satoshi, the sum-of-inputs-less-sum-of-outputs # main.verify_mempool_acceptance already computes and would # otherwise discard. btclib-org/btclib-node#260 self.fees: dict[bytes, int] = {} # txid -> the wtxids, held in this mempool, of whatever spends an # output of that txid -- kept up to date in `add_tx` and `_pop` # rather than rebuilt at eviction time, `_descendants` below being # the reader btclib-org/btclib-node#441 added it for. A txid this # mempool never holds a spender of is simply absent, not mapped to # an empty set: `_pop` deletes the entry once its last spender # leaves rather than leaving an empty set behind for every # confirmed parent a mempool transaction ever spent. self.spent_by: dict[bytes, set[bytes]] = {} # (individual feerate, insertion order, wtxid), a min-heap # `add_tx` pushes one entry onto and `_pop_worst_wtxid` below # reads from instead of `_evict_to_limit` scanning `transactions` # whole -- the other O(n) factor btclib-org/btclib-node#441 # measured and deliberately left alone, #457 being where this # heap is argued and measured both ways. A wtxid's feerate is # fixed at push time and never mutated in place -- there is no # fee-bump or replace-by-fee path in this mempool to change what # an entry already held pays -- but a wtxid can still leave and # come back (a reorg's own `_reconcile_mempool_for_reorg`, in # `main.py`, is one path that does this), and a heap entry from # its first spell is not deleted when it leaves, only ignored # once found stale. `_heap_current_seq` below is what tells that # entry apart from the fresh one its second spell pushes; # `_pop_worst_wtxid` is where a mismatch, not mere membership in # `transactions`, is what discards it, and `_rebuild_feerate_heap` # is what keeps every kind of leftover from growing without # bound across a mempool's whole lifetime, most of which is # spent under its limit rather than evicting anything -- see # both docstrings. self._feerate_heap: list[tuple[Fraction, int, bytes]] = [] # wtxid -> the second element of whichever `_feerate_heap` entry # is this wtxid's own current one -- the value `add_tx` just # pushed, or the value `_rebuild_feerate_heap` just re-assigned, # never one a since-superseded push or rebuild left behind. A # popped heap entry answers this call correctly (not merely "is # this wtxid still held") only once its own second element is # checked against this: a re-add's fresh entry and its own # first spell's leftover entry both name a wtxid `transactions` # currently holds, and only one of the two is the current entry. self._heap_current_seq: dict[bytes, int] = {} self.size: int = 0 self.bytesize: int = 0 # Core's own `DEFAULT_MAX_MEMPOOL_SIZE_MB` # (`src/kernel/mempool_options.h`, at bitcoin/bitcoin@58a7869f86) is # 300, and this field carried 500 with no argument on record for # the difference -- inert while nothing evicted, since the exact # number only ever decided whether `add_tx` refused outright. # `_evict_to_limit` below is what first makes it a real economic # threshold rather than a wall, which is the reasoning this value # needed and did not have: matching Core's own default is that # reasoning, absent a measurement that says this tree's own # traffic wants a different one. Not exposed on `Config`: making # it configurable is a widening (a new CLI/RPC-facing knob, its # own validation, its own interaction with a live-lowered limit) # that eviction does not need to exist -- the wall this issue # closes is the missing eviction, not the fixed ceiling. # btclib-org/btclib-node#294 self.bytesize_limit: int = 300 * 1000**2 # 300vMB # Core's own external-tracking counter, `CTxMemPool::m_sequence_number` # (`src/txmempool.h:200-202`): "incremented once every time a # transaction is added or removed from the mempool for any reason". # `getrawmempool`'s `mempool_sequence` is a read of this value # (`GetSequence`, `src/txmempool.h:598-600`), not itself a bump. # Core initializes the field to 1, not 0 (`src/txmempool.h:202`), # and bumps it through `GetAndIncrementSequence`'s C++ # post-increment (`return m_sequence_number++;`, `:594-596`) -- # the value handed to a signal recipient is the one *before* the # bump, but the field itself, which is what `GetSequence` later # reads, is already past it. Starting at 1 here is what makes a # fresh mempool answer `mempool_sequence: 1`, matching Core's own # answer for zero events, and what keeps every later answer at # Core's own N+1 after N add/remove events rather than N. self.sequence: int = 1 # Core's own `rollingMinimumFeeRate`/`lastRollingFeeUpdate`/ # `blockSinceLastRollingFeeBump` (`src/txmempool.h`, same commit): # the state `get_min_fee_rate` reads and updates, and # `_track_package_removed`/`note_block_connected` write. A float, # matching Core's own `double`: this is advisory relay policy # decayed by a continuous exponential, not a satoshi amount any # consensus or acceptance rule reads exactly. self._rolling_min_fee_rate: float = 0.0 self._last_rolling_fee_update: float = 0.0 self._block_since_last_rolling_fee_bump: bool = False
[docs] def is_full(self) -> bool: """Whether `bytesize` has already reached `bytesize_limit`.""" return self.bytesize >= self.bytesize_limit
[docs] def get_missing( self, transactions: Iterable[bytes], *, wtxid: bool = False ) -> list[bytes]: """Return every id in `transactions` this mempool does not hold.""" # No `is_full` guard: that used to answer every request with # nothing at all once past the limit, which was the wall # `_evict_to_limit` now removes -- Core's own request tracking # never consults mempool occupancy before asking either, letting # `add_tx` decide per transaction instead. btclib-org/btclib-node#294 index = self.transactions if wtxid else self.txid_index return [tx_id for tx_id in transactions if tx_id not in index]
[docs] def get_tx(self, txid: bytes, *, wtxid: bool = False) -> Tx | None: """Return the transaction stored under `txid` (or wtxid), or `None`.""" key = txid if wtxid else self.txid_index.get(txid) if key is None: return None return self.transactions.get(key)
# Don't need lock because handled in same thread
[docs] def add_tx(self, tx: Tx, fee: int = 0) -> bool: """Add `tx`, evict past the limit, and say whether it stuck. A no-op, returning `False`, for a txid already held. Otherwise added provisionally and run through `_evict_to_limit`, which takes it right back out if it is itself the worst entry left once trimming is done -- so the return value is `False` there too, exactly as it would be for an outright refusal. """ # `fee` defaults to 0 rather than being required, for the # callers -- mostly in tests -- that add a transaction without # ever asking what it pays; every production caller has just # computed the real one out of main.verify_mempool_acceptance # and passes it explicitly. # # The return value is what a caller that also queues the # transaction for announcement -- p2p/callbacks.py's `tx`, # rpc/callbacks.py's `send_raw_transaction` -- gates that on: a # transaction this call did not keep is not one to tell every # other peer about, or that call answers its own `getdata` with # `notfound`. btclib-org/btclib-node#277 # # Past `is_full()`, this used to refuse outright, whatever the # transaction paid. It now adds the transaction provisionally and # runs `_evict_to_limit`, Core's own `LimitMempoolSize` shape # (`validation.cpp`, called right after a provisional add, # at bitcoin/bitcoin@58a7869f86): if this transaction is itself the # worst one held once trimming is done, eviction takes it right # back out and the return value is `False` here exactly as it # was for the old outright refusal -- `rpc/callbacks.py`'s own # "Mempool is full" answers that case whether it is reached this # way or the old way. A transaction already held under this # txid, same witness or not, is still a no-op that never touches # bytesize at all: btclib-org/btclib-node#293's own resubmission # handling reads unchanged. btclib-org/btclib-node#294 wtxid, txid = tx.hash, tx.id if txid in self.txid_index: return False self.transactions[wtxid] = tx self.txid_index[txid] = wtxid self.fees[wtxid] = fee for vin in tx.vin: self.spent_by.setdefault(vin.prev_out.tx_id, set()).add(wtxid) self.size += 1 self.bytesize += tx.vsize self.sequence += 1 # `self.sequence`, already bumped once above and unique to this # call -- it never repeats and only ever grows -- is this heap's # own tie-breaker too, so a second counter kept only for this is # not needed: two equal-feerate entries pop in the order they # were pushed, `min`'s own stability over `self.transactions`' # insertion order before this heap existed. It also doubles as # this wtxid's current heap entry's own identifier: a wtxid # re-added after leaving overwrites `_heap_current_seq[wtxid]` # with this call's own value, so the entry a leftover, unpopped # heap tuple from its first spell still carries stops matching. self._heap_current_seq[wtxid] = self.sequence heapq.heappush( self._feerate_heap, (Fraction(fee, tx.vsize), self.sequence, wtxid) ) self._evict_to_limit() return wtxid in self.transactions
[docs] def remove_tx(self, tx: Tx) -> None: """Remove `tx` by txid, a no-op if this mempool does not hold it.""" txid = tx.id if txid in self.txid_index: self._pop(self.txid_index[txid])
[docs] def contains_tx(self, tx: Tx) -> bool: """Whether `tx`'s own wtxid is currently held.""" return tx.hash in self.transactions
[docs] def meets_fee_rate(self, wtxid: bytes, min_fee_rate: int) -> bool: """Whether the entry's own fee clears a rate quoted in sat/kvB. BIP133's own comparison -- Core's `txiter->GetFee() < filterrate.GetFee(txiter->GetTxSize())`, net_processing.cpp -- against this mempool's own record of what the transaction paid, rather than recomputing it at relay time. `min_fee_rate` of zero, BIP133's and `Connection.feefilter`'s own "no filter" value, always clears; so does a wtxid this mempool holds no fee for -- already relayed out of `Mempool.add_tx`'s own default, evicted, or gone from the mempool for any other reason by the time this is asked -- since there is nothing here to withhold it for. A caller relaying only what this mempool still holds is `download.py`'s own responsibility, checked there rather than assumed here: btclib-org/btclib-node#294. """ if not min_fee_rate: return True tx = self.transactions.get(wtxid) fee = self.fees.get(wtxid) if tx is None or fee is None: return True return fee >= fee_from_vsize(tx.vsize, FeeRate(sats_per_kvbyte=min_fee_rate))
def _pop(self, wtxid: bytes) -> Tx: """Remove one entry by wtxid and return the transaction removed. The one place every removal, `remove_tx` and eviction alike, updates the bookkeeping the indices and counters above carry -- so they never drift the way two independent copies of the same accounting would. `_feerate_heap` is the exception: a removal through `remove_tx` leaves that wtxid's own entry there, stale, because finding and dropping one buried entry out of a heap is itself an O(n) scan -- exactly the cost this index exists to avoid paying on every acceptance. `_heap_current_seq` is what marks it stale despite still being physically present -- dropping this wtxid's own entry here is what a re-add's later push has to overwrite instead of finding absent -- and `_rebuild_feerate_heap` is what the check below hands the physical leftover to, rather than letting it accumulate for as long as this mempool runs. """ tx = self.transactions.pop(wtxid) self.txid_index.pop(tx.id, None) self.fees.pop(wtxid, None) self._heap_current_seq.pop(wtxid, None) # A set of the spent txids first, not a loop over `tx.vin` itself: # two inputs of one transaction spending two outputs of the same # earlier one are not unusual, and `add_tx` below records that # txid once regardless (`set.add` is idempotent), so popping it # twice here would `del` an already-deleted `spent_by` entry on # the second `vin` and raise `KeyError` on a transaction that # never did anything wrong. for spent_txid in {vin.prev_out.tx_id for vin in tx.vin}: spenders = self.spent_by[spent_txid] spenders.discard(wtxid) if not spenders: del self.spent_by[spent_txid] self.size -= 1 self.bytesize -= tx.vsize self.sequence += 1 # Bounds the heap at twice the size it would be with no stale # entries in it at all: a wtxid removed here without its own # heap entry ever being popped (every removal but the one # `_pop_worst_wtxid` itself just consumed) is one more entry # `len(self._feerate_heap)` counts and `self.size` no longer # does, and this is the one place, alongside `add_tx`'s own # push, that both numbers are already in hand to compare. if len(self._feerate_heap) > 2 * self.size: self._rebuild_feerate_heap() return tx def _descendants(self, wtxid: bytes) -> set[bytes]: """Return wtxid and every mempool transaction depending on it. `verify_mempool_acceptance` (`main.py`) admits a child whose parent is only in this mempool, not yet confirmed -- so evicting a parent without what spends it would leave a transaction here whose own prevout resolves nowhere. Answered from `spent_by`, kept up to date in `add_tx` and `_pop` rather than rebuilt here: this walks the package on the frontier, once per element of it, instead of the whole mempool once per element -- the O(n * k) cost btclib-org/btclib-node#441 measured, `n` being every entry this mempool holds and `k` the size of the package being walked. A second index does mean a second place the accounting above could drift, the risk the comment this replaces weighed against the scan -- `_pop` being the single place every removal updates it is what keeps that from happening, the same invariant that already holds `txid_index` and `fees` to the dict they index. `prev_out.tx_id` is a txid, matching what `txid_index` keys transactions by and what `verify_mempool_acceptance`'s own mempool lookup reads a prevout by. btclib-org/btclib-node#294 """ root_txid = self.transactions[wtxid].id descendants = {wtxid} frontier = [root_txid] while frontier: txid = frontier.pop() for candidate_wtxid in self.spent_by.get(txid, ()): if candidate_wtxid in descendants: continue descendants.add(candidate_wtxid) frontier.append(self.transactions[candidate_wtxid].id) return descendants def _pop_worst_wtxid(self) -> bytes: """Pop and return the currently held wtxid of the lowest feerate. `_feerate_heap` holds one entry per wtxid this mempool has ever held a push for, and a push happens once per `add_tx` call and once per `_rebuild_feerate_heap` sweep -- never updated in place, since a wtxid's feerate cannot change while it is held, there being no fee-bump or replace-by-fee path into this mempool. What can change is whether a given physical entry is still the one this wtxid is currently held under: `add_tx` on a wtxid that left and came back pushes a fresh entry with a fresh second element and overwrites `_heap_current_seq[wtxid]` to match it, so an older entry for the same wtxid, still physically in the heap because `_pop` never goes looking for it, now names a second element `_heap_current_seq` no longer agrees with. This popped a wtxid still in `self.transactions` and got the eviction order wrong on exactly that path -- checking membership alone cannot tell the two entries apart, only checking the entry `_heap_current_seq` currently names for that wtxid can -- and that check is the fix (btclib-org/btclib-node#457, second round). This discards every entry that fails the check, in ascending feerate order, until one that passes surfaces -- which always happens before the heap runs out. For every wtxid still held, exactly one entry in the heap has the second element `_heap_current_seq` currently names for it, pushed either by the `add_tx` call that last (re-)added it or by the most recent `_rebuild_feerate_heap` sweep since; that entry cannot have been popped already, since popping the entry matching a wtxid's current mapping only ever happens here, at the moment this method returns that wtxid to be evicted, and eviction is what removes the wtxid (and its mapping) from `self.transactions` -- so the invariant is "at least one matching entry per currently held wtxid", not "exactly one": a re-add can leave a second, now-permanently-stale entry for the same wtxid behind, and nothing needs it gone before this can terminate correctly. """ while True: _, seq, wtxid = heapq.heappop(self._feerate_heap) if self._heap_current_seq.get(wtxid) == seq: return wtxid def _rebuild_feerate_heap(self) -> None: """Re-derive `_feerate_heap` from scratch, dropping every stale entry. `self.transactions` and `self.fees` are inserted into and deleted from together, in `add_tx` and `_pop`, so their key order agrees at every point this can run -- `enumerate` over one, read against the other, reproduces each currently held wtxid's own original relative insertion order without this mempool having kept a separate record of it. Every entry built here is the new current one: `_heap_current_seq[wtxid]` is overwritten to the same index the rebuilt entry carries, so a heap entry from before this rebuild -- superseded here whether or not it had already gone stale on its own -- stops matching exactly the way an ordinary re-add's leftover does. The indices handed out this way, `0` upward, are smaller than `self.sequence` can ever be read as here: `self.sequence` only ever grows, by at least one per add and one per removal, so it already exceeds `self.size` -- and therefore every index below it -- at any point `_pop`'s own check calls this. A push after this rebuild still carries the current, larger `self.sequence`, so it still breaks a tie against a rebuilt entry the same way it would have against the entry the rebuild replaced. """ self._feerate_heap = [] for index, (wtxid, fee) in enumerate(self.fees.items()): self._heap_current_seq[wtxid] = index self._feerate_heap.append( (Fraction(fee, self.transactions[wtxid].vsize), index, wtxid) ) heapq.heapify(self._feerate_heap) def _evict_to_limit(self) -> None: """Evict by worst individual feerate until back at the limit. Core's own `TrimToSize` (`src/txmempool.cpp:909`, at bitcoin/bitcoin@58a7869f86) evicts the worst *chunk*, a package score `m_txgraph` computes over the whole cluster graph -- so a low-feerate parent paid for by a high-feerate child is not taken out from under it. This mempool holds no dependency graph to score packages by, only enough to answer "what depends on this" once a root is already chosen (`_descendants`), so the substitute that stays consistent picks the worst *individual* feerate instead and evicts it together with whatever depends on it -- not because that is cheapest to evict, but because it is the only choice that never leaves a remaining transaction whose own prevout no longer resolves. Ties break toward the longest-*currently*-held entry: `_heap_current_seq` (`add_tx`, `_rebuild_feerate_heap`) names each wtxid's own tiebreak value by when it was last (re-)added, not by when it was first ever seen, reproducing what `self.transactions`' own insertion order and `min`'s own stability gave before this heap replaced that scan -- a wtxid that left and came back sorts as newly held, the same way a dict does on a delete followed by a fresh insert (btclib-org/btclib-node#457). This is a deliberate, argued departure from Core, unlike `removed_rate` below, which matches it. """ while self.bytesize > self.bytesize_limit and self.transactions: worst = self._pop_worst_wtxid() package = self._descendants(worst) # Core's own `removed` (`:917-925`): the feerate of the whole # evicted chunk -- `GetWorstMainChunk`'s own aggregate fee # over its own aggregate size, not the worst entry's rate # alone -- with `m_opts.incremental_relay_feerate` added to # it by `CFeeRate::operator+=` (`policy/feerate.h:80-82`), # which sums the two rates' own sat/kvB values rather than # combining them by size. A low-fee parent evicted together # with a child overpaying for it (CPFP) bumps the rolling # minimum by their combined rate, not by the parent's own # rate alone, which the aggregate here reproduces even though # this mempool's own selection above does not chase CPFP the # way `m_txgraph`'s package score does. # # sat/kvB, exact until the float `_track_package_removed` # stores it as -- Core's own `CFeeRate` arithmetic in # `TrimToSize` is int64 rather than float, a difference this # module's own advisory, non-consensus use of the number # does not need to close. package_fee = sum(self.fees[w] for w in package) package_vsize = sum(self.transactions[w].vsize for w in package) removed_rate = Fraction(package_fee, package_vsize) * 1000 removed_rate += _INCREMENTAL_RELAY_FEE_RATE.sats_per_kvbyte for victim in package: self._pop(victim) self._track_package_removed(float(removed_rate)) def _track_package_removed(self, rate_per_kvbyte: float) -> None: """Core's own `trackPackageRemoved` (`txmempool.cpp`, same commit). The rolling minimum only ever rises here, and every rise restarts `get_min_fee_rate`'s own decay: an eviction round always answers "no decay yet" until a block has passed since the last one of these, `note_block_connected` being what sets that back to true. """ if rate_per_kvbyte > self._rolling_min_fee_rate: self._rolling_min_fee_rate = rate_per_kvbyte self._block_since_last_rolling_fee_bump = False
[docs] def note_block_connected(self) -> None: """Restart the rolling minimum's decay clock for one connected block. Core's own `removeForBlock` (`src/txmempool.cpp:405-427`, same commit) sets `lastRollingFeeUpdate`/`blockSinceLastRollingFeeBump` this way for every block, whether or not that block held any transaction this mempool was also holding -- called once per block from `main.update_chain`'s own connect loop, and not folded into `remove_tx`, which already runs once per transaction inside that same loop rather than once per block. """ self._last_rolling_fee_update = time.time() self._block_since_last_rolling_fee_bump = True
[docs] def get_min_fee_rate(self) -> FeeRate: """Return the rolling minimum feerate, decayed since it last moved. Core's own `GetMinFee` (`src/txmempool.cpp:877`, same commit), with `sizelimit` read from `self.bytesize_limit` rather than threaded through as an argument, since this mempool already owns that number instead of a caller supplying it each call. No decay at all until a block has passed since the value last rose (`_block_since_last_rolling_fee_bump`) -- an eviction round with no block in between only ever raises it, `_track_package_removed`'s own guard. Past that, every 12-hour half-life (`_ROLLING_FEE_HALFLIFE`) erodes it toward zero, the half-life itself shortened while this mempool is well under its limit -- `self.bytesize` standing in for Core's own `DynamicMemoryUsage()`, both being how full the mempool actually is rather than how many transactions it holds -- and the value floored at `_INCREMENTAL_RELAY_FEE_RATE` once it decays, or zeroed once it decays under half of that: below that floor it is not a small minimum, it is none. `round` rather than Core's own `llround` (ties-to-even against ties-away-from-zero) is the one place this departs from `GetMinFee`'s own arithmetic -- a tie only a decayed float lands on exactly, and advisory relay policy this module does not thread through consensus does not need closed to the bit. """ if ( not self._block_since_last_rolling_fee_bump or not self._rolling_min_fee_rate ): return FeeRate(sats_per_kvbyte=round(self._rolling_min_fee_rate)) now = time.time() if now > self._last_rolling_fee_update + 10: halflife: float = _ROLLING_FEE_HALFLIFE if self.bytesize < self.bytesize_limit / 4: halflife /= 4 elif self.bytesize < self.bytesize_limit / 2: halflife /= 2 elapsed = now - self._last_rolling_fee_update self._rolling_min_fee_rate /= 2 ** (elapsed / halflife) self._last_rolling_fee_update = now if ( self._rolling_min_fee_rate < _INCREMENTAL_RELAY_FEE_RATE.sats_per_kvbyte / 2 ): self._rolling_min_fee_rate = 0.0 return FeeRate(sats_per_kvbyte=0) return FeeRate( sats_per_kvbyte=max( round(self._rolling_min_fee_rate), _INCREMENTAL_RELAY_FEE_RATE.sats_per_kvbyte, ) )