Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/release-notes-22539.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Notable changes
===============

P2P and network changes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that should be mentioned in the release notes at all? Given that less than 25% of txs are opt-in-rbf and way less than that are conflicts, this shouldn't have any significant effect on real-world fee estimation. See also your findings: #22539 (comment)

Also, it doesn't change the P2P protocol or is a network change. At most it would affect your local estimates, so if the notes are kept, the section headline could be adjusted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also your findings: #22539 (comment)

I expect this to be very different with a not so low fee market (replacement-aware fee estimation to constantly be slightly higher).

the section headline could be adjusted

Sure, what do you suggest? To be honest i was not sure what headline to use and since fee estimation is ubiquitous i figured it was a notable change, but happy to change it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will show up in the section "Policy" (example: https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-22.0.md#policy), but I think we never had a "notable change" policy section. Maybe "Fee estimation changes"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here #23284

-----------------------

- Fee estimation now takes the feerate of replacement (RBF) transactions into
Comment thread
glozow marked this conversation as resolved.
account.
9 changes: 2 additions & 7 deletions src/validation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,6 @@ class MemPoolAccept
std::unique_ptr<CTxMemPoolEntry> m_entry;
std::list<CTransactionRef> m_replaced_transactions;

bool m_replacement_transaction;
CAmount m_base_fees;
CAmount m_modified_fees;
/** Total modified fees of all transactions being replaced. */
Expand Down Expand Up @@ -555,7 +554,6 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
CTxMemPool::setEntries& allConflicting = ws.m_all_conflicting;
CTxMemPool::setEntries& setAncestors = ws.m_ancestors;
std::unique_ptr<CTxMemPoolEntry>& entry = ws.m_entry;
bool& fReplacementTransaction = ws.m_replacement_transaction;
CAmount& nModifiedFees = ws.m_modified_fees;
CAmount& nConflictingFees = ws.m_conflicting_fees;
size_t& nConflictingSize = ws.m_conflicting_size;
Expand Down Expand Up @@ -778,8 +776,7 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
}


fReplacementTransaction = setConflicts.size();
if (fReplacementTransaction) {
if (!setConflicts.empty()) {
CFeeRate newFeeRate(nModifiedFees, nSize);
// It's possible that the replacement pays more fees than its direct conflicts but not more
// than all conflicts (i.e. the direct conflicts have high-fee descendants). However, if the
Expand Down Expand Up @@ -884,7 +881,6 @@ bool MemPoolAccept::Finalize(const ATMPArgs& args, Workspace& ws)
const CAmount& nModifiedFees = ws.m_modified_fees;
const CAmount& nConflictingFees = ws.m_conflicting_fees;
const size_t& nConflictingSize = ws.m_conflicting_size;
const bool fReplacementTransaction = ws.m_replacement_transaction;
Comment thread
darosior marked this conversation as resolved.
Outdated
std::unique_ptr<CTxMemPoolEntry>& entry = ws.m_entry;

// Remove conflicting transactions from the mempool
Expand All @@ -900,11 +896,10 @@ bool MemPoolAccept::Finalize(const ATMPArgs& args, Workspace& ws)
m_pool.RemoveStaged(allConflicting, false, MemPoolRemovalReason::REPLACED);

// This transaction should only count for fee estimation if:
// - it isn't a BIP 125 replacement transaction (may not be widely supported)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One risk I could see is that someone running with a full-rbf patch might pay a higher fee now. So I am wondering if this needs to be re-introduced with a full-rbf patch?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can only increase the fees for anyone, but if it does it means that it was necessary (a higher feerate was needed in order to have this transaction go through). How could a full-rbf patch wrongly increase estimates?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-fee non-miner-policy txs (In this case full-rbf'ed) might fill your mempool, but never get confirmed, thus raising your estimates.

This is a general problem and I was just wondering if the patch should be re-introduced for full-rbf (or other similar policy changes in the future).

Copy link
Copy Markdown
Member Author

@darosior darosior Oct 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh for people running it without knowing that some miners do as well.

I think stopping (again) to consider replacement transactions would be a step backward. However it could make sense for a full-RBF patch to stop considering replacement to not-opted-in transactions for fee estimation until the full-RBF policy is reasonably deployed on the network. Since we only implement explicit signaling it sounds like the complexity of doing so would be reasonable.

// - it's not being re-added during a reorg which bypasses typical mempool fee limits
// - the node is not behind
// - the transaction is not dependent on any other transactions in the mempool
bool validForFeeEstimation = !fReplacementTransaction && !bypass_limits && IsCurrentForFeeEstimation(m_active_chainstate) && m_pool.HasNoInputsOf(tx);
bool validForFeeEstimation = !bypass_limits && IsCurrentForFeeEstimation(m_active_chainstate) && m_pool.HasNoInputsOf(tx);

// Store transaction in memory
m_pool.addUnchecked(*entry, setAncestors, validForFeeEstimation);
Expand Down
149 changes: 121 additions & 28 deletions test/functional/feature_fee_estimation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test fee estimation code."""
from decimal import Decimal
import os
import random

from test_framework.messages import (
Expand Down Expand Up @@ -155,6 +156,21 @@ def check_estimates(node, fees_seen):
check_raw_estimates(node, fees_seen)
check_smart_estimates(node, fees_seen)


def send_tx(node, utxo, feerate):
"""Broadcast a 1in-1out transaction with a specific input and feerate (sat/vb)."""
overhead, op, scriptsig, nseq, value, spk = 10, 36, 5, 4, 8, 24
tx_size = overhead + op + scriptsig + nseq + value + spk
fee = tx_size * feerate

tx = CTransaction()
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]), SCRIPT_SIG[utxo["vout"]])]
tx.vout = [CTxOut(int(utxo["amount"] * COIN) - fee, P2SH_1)]
Comment on lines +162 to +168
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid manual tx size calculation with magic numbers, you could simply create the CTransaction first (without the fee), determine tx size via len(tx.serialize()) and then subtract the fee.

Suggested change
overhead, op, scriptsig, nseq, value, spk = 10, 36, 5, 4, 8, 24
tx_size = overhead + op + scriptsig + nseq + value + spk
fee = tx_size * feerate
tx = CTransaction()
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]), SCRIPT_SIG[utxo["vout"]])]
tx.vout = [CTxOut(int(utxo["amount"] * COIN) - fee, P2SH_1)]
tx = CTransaction()
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]), SCRIPT_SIG[utxo["vout"]])]
tx.vout = [CTxOut(int(utxo["amount"] * COIN), P2SH_1)]
fee = len(tx.serialize()) * feerate
tx.vout[0].nValue -= fee

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right since we use legacy txs in this test it's indeed much cleaner. However i'd like to not invalidate 4 ACKs on a cosmetic change. Do you mind if i make it a follow-up in #23075 ?

txid = node.sendrawtransaction(tx.serialize().hex())

return txid
Comment thread
glozow marked this conversation as resolved.


class EstimateFeeTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 3
Expand Down Expand Up @@ -212,48 +228,36 @@ def transact_and_mine(self, numblocks, mining_node):
newmem.append(utx)
self.memutxo = newmem

def run_test(self):
self.log.info("This test is time consuming, please be patient")
self.log.info("Splitting inputs so we can generate tx's")

# Start node0
self.start_node(0)
def initial_split(self, node):
"""Split two coinbase UTxOs into many small coins"""
self.txouts = []
self.txouts2 = []
# Split a coinbase into two transaction puzzle outputs
split_inputs(self.nodes[0], self.nodes[0].listunspent(0), self.txouts, True)
split_inputs(node, node.listunspent(0), self.txouts, True)

# Mine
while len(self.nodes[0].getrawmempool()) > 0:
self.generate(self.nodes[0], 1)
while len(node.getrawmempool()) > 0:
self.generate(node, 1)

# Repeatedly split those 2 outputs, doubling twice for each rep
# Use txouts to monitor the available utxo, since these won't be tracked in wallet
reps = 0
while reps < 5:
# Double txouts to txouts2
while len(self.txouts) > 0:
split_inputs(self.nodes[0], self.txouts, self.txouts2)
while len(self.nodes[0].getrawmempool()) > 0:
self.generate(self.nodes[0], 1)
split_inputs(node, self.txouts, self.txouts2)
while len(node.getrawmempool()) > 0:
self.generate(node, 1)
# Double txouts2 to txouts
while len(self.txouts2) > 0:
split_inputs(self.nodes[0], self.txouts2, self.txouts)
while len(self.nodes[0].getrawmempool()) > 0:
self.generate(self.nodes[0], 1)
split_inputs(node, self.txouts2, self.txouts)
while len(node.getrawmempool()) > 0:
self.generate(node, 1)
reps += 1
self.log.info("Finished splitting")

# Now we can connect the other nodes, didn't want to connect them earlier
# so the estimates would not be affected by the splitting transactions
self.start_node(1)
self.start_node(2)
self.connect_nodes(1, 0)
self.connect_nodes(0, 2)
self.connect_nodes(2, 1)

self.sync_all()

def sanity_check_estimates_range(self):
"""Populate estimation buckets, assert estimates are in a sane range and
are strictly increasing as the target decreases."""
self.fees_per_kb = []
self.memutxo = []
self.confutxo = self.txouts # Start with the set of confirmed txouts after splitting
Expand All @@ -279,11 +283,100 @@ def run_test(self):
self.log.info("Final estimates after emptying mempools")
check_estimates(self.nodes[1], self.fees_per_kb)

# check that the effective feerate is greater than or equal to the mempoolminfee even for high mempoolminfee
self.log.info("Test fee rate estimation after restarting node with high MempoolMinFee")
def test_feerate_mempoolminfee(self):
high_val = 3*self.nodes[1].estimatesmartfee(1)['feerate']
self.restart_node(1, extra_args=[f'-minrelaytxfee={high_val}'])
check_estimates(self.nodes[1], self.fees_per_kb)
self.restart_node(1)

def sanity_check_rbf_estimates(self, utxos):
"""During 5 blocks, broadcast low fee transactions. Only 10% of them get
confirmed and the remaining ones get RBF'd with a high fee transaction at
the next block.
The block policy estimator should return the high feerate.
"""
# The broadcaster and block producer
node = self.nodes[0]
miner = self.nodes[1]
# In sat/vb
low_feerate = 1
high_feerate = 10
# Cache the utxos of which to replace the spender after it failed to get
# confirmed
utxos_to_respend = []
txids_to_replace = []

assert len(utxos) >= 250
for _ in range(5):
# Broadcast 45 low fee transactions that will need to be RBF'd
for _ in range(45):
u = utxos.pop(0)
txid = send_tx(node, u, low_feerate)
utxos_to_respend.append(u)
txids_to_replace.append(txid)
# Broadcast 5 low fee transaction which don't need to
for _ in range(5):
send_tx(node, utxos.pop(0), low_feerate)
Comment on lines +317 to +319
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused about why there needs to be 10% of the transactions confirming with low feerate? Why can't the test be:

  • broadcast low fee transactions, don't let any of them confirm
  • RBF the low fee transactions with high fee ones, confirm them
  • assert that fee estimate only returns higher feerate

(edit: ended up just writing it glozow@10b89fc)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused about why there needs to be 10% of the transactions confirming with low feerate?

There does not need to, i just wanted to simulate a more realistic scenario than just triggering the specific behaviour.

# Mine the transactions on another node
self.sync_mempools(wait=.1, nodes=[node, miner])
for txid in txids_to_replace:
miner.prioritisetransaction(txid=txid, fee_delta=-COIN)
self.generate(miner, 1)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mines the replacement transactions from a previous iteration of the loop - is that intentional?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is. The point is to simulate a somewhat real life scenario "broadcast a low fee tx, it does not confirm, it gets RBF'd, the replacement gets confirmed". You would not RBF before a single block was mined

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok, putting it that way makes sense

self.sync_blocks(wait=.1, nodes=[node, miner])
# RBF the low-fee transactions
while True:
try:
u = utxos_to_respend.pop(0)
send_tx(node, u, high_feerate)
except IndexError:
break
Comment on lines +327 to +332
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a bit shorter and more pythonic without exception:

Suggested change
while True:
try:
u = utxos_to_respend.pop(0)
send_tx(node, u, high_feerate)
except IndexError:
break
while utxos_to_respond:
u = utxos_to_respend.pop(0)
send_tx(node, u, high_feerate)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed 🤦‍♂️ but same rationale as above, as ugly as it is i'd rather address it in #23075


# Mine the last replacement txs
self.sync_mempools(wait=.1, nodes=[node, miner])
self.generate(miner, 1)
self.sync_blocks(wait=.1, nodes=[node, miner])

# Only 10% of the transactions were really confirmed with a low feerate,
# the rest needed to be RBF'd. We must return the 90% conf rate feerate.
high_feerate_kvb = Decimal(high_feerate) / COIN * 10**3
est_feerate = node.estimatesmartfee(2)["feerate"]
assert est_feerate == high_feerate_kvb

def run_test(self):
self.log.info("This test is time consuming, please be patient")
self.log.info("Splitting inputs so we can generate tx's")

# Split two coinbases into many small utxos
self.start_node(0)
self.initial_split(self.nodes[0])
self.log.info("Finished splitting")

# Now we can connect the other nodes, didn't want to connect them earlier
# so the estimates would not be affected by the splitting transactions
self.start_node(1)
self.start_node(2)
self.connect_nodes(1, 0)
self.connect_nodes(0, 2)
self.connect_nodes(2, 1)
self.sync_all()

self.log.info("Testing estimates with single transactions.")
self.sanity_check_estimates_range()

# check that the effective feerate is greater than or equal to the mempoolminfee even for high mempoolminfee
self.log.info("Test fee rate estimation after restarting node with high MempoolMinFee")
self.test_feerate_mempoolminfee()

self.log.info("Restarting node with fresh estimation")
self.stop_node(0)
fee_dat = os.path.join(self.nodes[0].datadir, self.chain, "fee_estimates.dat")
os.remove(fee_dat)
self.start_node(0)
self.connect_nodes(0, 1)
self.connect_nodes(0, 2)

self.log.info("Testing estimates with RBF.")
self.sanity_check_rbf_estimates(self.confutxo + self.memutxo)

self.log.info("Testing that fee estimation is disabled in blocksonly.")
self.restart_node(0, ["-blocksonly"])
Expand Down