I ended up writing some feerate diagram tests to check my understanding of how it works. It might be helpful for reviewers and/or if you’re interested in taking them:
0diff --git a/test/functional/mempool_cluster.py b/test/functional/mempool_cluster.py
1index ee8005a1cd2..c86e6a1b5c0 100755
2--- a/test/functional/mempool_cluster.py
3+++ b/test/functional/mempool_cluster.py
4@@ -7,6 +7,8 @@
5 from decimal import Decimal
6
7 from test_framework.mempool_util import (
8+ assert_equal_feerate_diagram,
9+ check_feerate_diagram_monotonically_decreasing,
10 DEFAULT_CLUSTER_LIMIT,
11 DEFAULT_CLUSTER_SIZE_LIMIT_KVB,
12 )
13@@ -71,17 +73,6 @@ class MempoolClusterTest(BitcoinTestFramework):
14 assert node.getmempoolcluster(parent_tx['txid'])['txcount'] == cluster_count
15 return all_results
16
17- def check_feerate_diagram(self, node):
18- """Sanity check the feerate diagram."""
19- feeratediagram = node.getmempoolfeeratediagram()
20- last_val = [0, 0]
21- for x in feeratediagram:
22- # The vsize is always positive, except for the first iteration
23- assert x['vsize'] > 0 or x['fee'] == 0
24- # Monotonically decreasing fee per vsize
25- assert_greater_than_or_equal(last_val[0] * x['fee'], last_val[1] * x['vsize'])
26- last_val = [x['vsize'], x['fee']]
27-
28 def test_limit_enforcement(self, cluster_submitted, target_vsize_per_tx=None):
29 """
30 the cluster may change as a result of these transactions, so cluster_submitted is mutated accordingly
31@@ -149,7 +140,7 @@ class MempoolClusterTest(BitcoinTestFramework):
32 def test_cluster_count_limit(self, max_cluster_count):
33 node = self.nodes[0]
34 cluster_submitted = self.add_chain_cluster(node, max_cluster_count)
35- self.check_feerate_diagram(node)
36+ check_feerate_diagram_monotonically_decreasing(node.getmempoolfeeratediagram())
37 for result in cluster_submitted:
38 assert_equal(node.getmempoolcluster(result["txid"])['txcount'], max_cluster_count)
39
40@@ -292,6 +283,151 @@ class MempoolClusterTest(BitcoinTestFramework):
41 assert tx_replacer_sponsor["txid"] in node.getrawmempool()
42 assert_equal(node.getmempoolcluster(tx_replacer["txid"])['txcount'], 2)
43
44+ [@cleanup](/bitcoin-bitcoin/contributor/cleanup/)
45+ def test_feerate_diagram(self):
46+ node = self.nodes[0]
47+ self.log.info("Test that the feerate diagram shows chunks correctly")
48+
49+ # 1 sat/vB as Decimal BTC/kvB
50+ feerate_1000sat_kvb = Decimal(1000) / COIN
51+
52+ def sats_to_btc(sats):
53+ """Convert int sats to Decimal BTC with 8 decimal places"""
54+ return Decimal(sats) / Decimal(1e8)
55+
56+ # txA (0sat / 500vB) <- txB (1000sat / 500vB)
57+ # Use v3 to allow 0 fee
58+ txA = self.wallet.create_self_transfer(confirmed_only=True, fee=0, fee_rate=0, version=3, target_vsize=500)
59+ txB = self.wallet.create_self_transfer(utxo_to_spend=txA["new_utxo"], fee=sats_to_btc(1000), version=3, target_vsize=500)
60+ result_ab = node.submitpackage([txA["hex"], txB["hex"]])
61+ assert_equal(result_ab["package_msg"], "success")
62+
63+ # one cluster, one chunk with feerate 1sat/vB
64+ assert_equal(result_ab["tx-results"][txA["wtxid"]]["fees"]["effective-feerate"], feerate_1000sat_kvb)
65+ assert_equal(result_ab["tx-results"][txB["wtxid"]]["fees"]["effective-feerate"], feerate_1000sat_kvb)
66+ assert_equal(node.getmempoolcluster(txA["txid"])['txcount'], 2)
67+ expected_feerate_diagram_ab = [
68+ [0, 0],
69+ [1000, 1000], # [txA, txB] 1sat/vB
70+ ]
71+ assert_equal_feerate_diagram(expected_feerate_diagram_ab, node.getmempoolfeeratediagram())
72+
73+ # txC (0sat / 1000vB) <- txD (2000sat / 1000vB)
74+ # Use v3 to allow 0 fee
75+ txC = self.wallet.create_self_transfer(confirmed_only=True, fee=0, fee_rate=0, version=3, target_vsize=1000)
76+ txD = self.wallet.create_self_transfer(utxo_to_spend=txC["new_utxo"], fee=sats_to_btc(2000), version=3, target_vsize=1000)
77+ result_cd = node.submitpackage([txC["hex"], txD["hex"]])
78+
79+ # one cluster, one chunks with feerate 1sat/vB
80+ assert_equal(result_cd["package_msg"], "success")
81+ assert_equal(result_cd["tx-results"][txC["wtxid"]]["fees"]["effective-feerate"], feerate_1000sat_kvb)
82+ assert_equal(result_cd["tx-results"][txD["wtxid"]]["fees"]["effective-feerate"], feerate_1000sat_kvb)
83+ assert_equal(node.getmempoolcluster(txC["txid"])['txcount'], 2)
84+ # Same chunk feerate as [txA, txB], but [txC, txD] has larger vsize.
85+ expected_feerate_diagram_cd = [
86+ [0, 0],
87+ [1000, 1000], # [txA, txB] 1sat/vB
88+ [3000, 3000], # [txC, txD] 1sat/vB
89+ ]
90+ assert_equal_feerate_diagram(expected_feerate_diagram_cd, node.getmempoolfeeratediagram())
91+
92+ self.log.info("Test that the feerate diagram uses modified fees")
93+ # txE (800sat / 400vB)
94+ # 799sat of fees will come from prioritisetransaction
95+ txE = self.wallet.create_self_transfer(confirmed_only=True, fee=sats_to_btc(1), target_vsize=400)
96+ node.prioritisetransaction(txid=txE["txid"], fee_delta=799)
97+ node.sendrawtransaction(txE["hex"])
98+ assert_equal(node.getmempoolcluster(txE["txid"])['txcount'], 1)
99+ expected_feerate_diagram_e = [
100+ [0, 0],
101+ [800, 400], # [txE] 2sat/vB
102+ [1800, 1400], # [txA, txB] 1sat/vB
103+ [3800, 3400], # [txC, txD] 1sat/vB
104+ ]
105+ assert_equal_feerate_diagram(expected_feerate_diagram_e, node.getmempoolfeeratediagram())
106+
107+ # txF (750sat / 500vB) <- txG (6250sat / 500vB)
108+ txF = self.wallet.create_self_transfer(confirmed_only=True, fee=sats_to_btc(750), target_vsize=500)
109+ txG = self.wallet.create_self_transfer(utxo_to_spend=txF["new_utxo"], fee=sats_to_btc(6250), target_vsize=500)
110+
111+ # Submit them individually to see txF's chunk feerate change.
112+ node.sendrawtransaction(txF["hex"])
113+ assert_equal(node.getmempoolcluster(txF["txid"])['txcount'], 1)
114+ # txF has a feerate of 1.5sat/vB, so it's in the middle
115+ expected_feerate_diagram_f = [
116+ [0, 0],
117+ [800, 400], # [txE] 2sat/vB
118+ [1550, 900], # [txF] 1.5sat/vB
119+ [2550, 1900], # [txA, txB] 1sat/vB
120+ [4550, 3900], # [txC, txD] 1sat/vB
121+ ]
122+ assert_equal_feerate_diagram(expected_feerate_diagram_f, node.getmempoolfeeratediagram())
123+
124+ # txG bumps txF's chunk feerate to 7sat/vB
125+ node.sendrawtransaction(txG["hex"])
126+ assert_equal(node.getmempoolcluster(txF["txid"])['txcount'], 2)
127+ expected_feerate_diagram_g = [
128+ [0, 0],
129+ [7000, 1000], # [txF, txG] 7sat/vB
130+ [7800, 1400], # [txE] 2sat/vB
131+ [8800, 2400], # [txA, txB] 1sat/vB
132+ [10800, 4400], # [txC, txD] 1sat/vB
133+ ]
134+ assert_equal_feerate_diagram(expected_feerate_diagram_g, node.getmempoolfeeratediagram())
135+
136+ self.log.info("Test that prioritisetransaction on a mempool entry affects the feerate diagram")
137+ # Prioritise txD to make its chunk feerate a little more than 8sat/vB
138+ node.prioritisetransaction(txid=txD["txid"], fee_delta=14012)
139+ expected_feerate_diagram_d_prio = [
140+ [0, 0],
141+ [16012, 2000], # [txC, txD] 8.006sat/vB
142+ [23012, 3000], # [txF, txG] 7sat/vB
143+ [23812, 3400], # [txE] 2sat/vB
144+ [24812, 4400], # [txA, txB] 1sat/vB
145+ ]
146+ assert_equal_feerate_diagram(expected_feerate_diagram_d_prio, node.getmempoolfeeratediagram())
147+
148+ # De-prioritise txG to split up the chunk, putting txF behind txE and txG at the very end.
149+ node.prioritisetransaction(txid=txG["txid"], fee_delta=-6195)
150+ expected_feerate_diagram_g_deprio = [
151+ [0, 0],
152+ [16012, 2000], # [txC, txD] 8.006sat/vB
153+ [16812, 2400], # [txE] 2sat/vB
154+ [17562, 2900], # [txF] 1.5sat/vB
155+ [18562, 3900], # [txA, txB] 1sat/vB
156+ [18617, 4400], # [txG] 0.11sat/vB
157+ ]
158+ assert_equal_feerate_diagram(expected_feerate_diagram_g_deprio, node.getmempoolfeeratediagram())
159+
160+ # txH (30sat / 300vB) Spend txE and txG to merge their clusters, but keeping the chunking the same.
161+ txH = self.wallet.create_self_transfer_multi(utxos_to_spend=[txE["new_utxo"], txG["new_utxo"]], fee_per_output=30, target_vsize=300)
162+ node.sendrawtransaction(txH["hex"])
163+ # The cluster is now EFGH
164+ assert_equal(node.getmempoolcluster(txE["txid"])['txcount'], 4)
165+ expected_feerate_diagram_h = [
166+ [0, 0],
167+ [16012, 2000], # [txC, txD] 8.006sat/vB
168+ [16812, 2400], # [txE] 2sat/vB
169+ [17562, 2900], # [txF] 1.5sat/vB
170+ [18562, 3900], # [txA, txB] 1sat/vB
171+ [18617, 4400], # [txG] 0.11sat/vB
172+ [18647, 4700], # [txH] 0.1sat/vB
173+ ]
174+ assert_equal_feerate_diagram(expected_feerate_diagram_h, node.getmempoolfeeratediagram())
175+
176+ # txI (2150sat / 200vB) bumps txF, txG, txH to 1.99sat/vB, combining them into a single chunk
177+ txI = self.wallet.create_self_transfer(utxo_to_spend=txH["new_utxos"][0], fee=sats_to_btc(2150), target_vsize=200)
178+ node.sendrawtransaction(txI["hex"])
179+ # The cluster is now EFGHI
180+ assert_equal(node.getmempoolcluster(txI["txid"])['txcount'], 5)
181+ expected_feerate_diagram_i = [
182+ [0, 0],
183+ [16012, 2000], # [txC, txD] 8.006sat/vB
184+ [16812, 2400], # [txE] 2sat/vB
185+ [19797, 3900], # [txF, txG, txH, txI] 1.99sat/vB
186+ [20797, 4900], # [txA, txB] 1sat/vB
187+ ]
188+ assert_equal_feerate_diagram(expected_feerate_diagram_i, node.getmempoolfeeratediagram())
189
190 def run_test(self):
191 node = self.nodes[0]
192@@ -299,6 +435,7 @@ class MempoolClusterTest(BitcoinTestFramework):
193 self.generate(self.wallet, 400)
194
195 self.test_cluster_limit_rbf(DEFAULT_CLUSTER_LIMIT)
196+ self.test_feerate_diagram()
197
198 for cluster_size_limit_kvb in [10, 20, 33, 100, DEFAULT_CLUSTER_SIZE_LIMIT_KVB]:
199 self.log.info(f"-> Resetting node with -limitclustersize={cluster_size_limit_kvb}")
200diff --git a/test/functional/test_framework/mempool_util.py b/test/functional/test_framework/mempool_util.py
201index 89e2558307e..2bce87bda2f 100644
202--- a/test/functional/test_framework/mempool_util.py
203+++ b/test/functional/test_framework/mempool_util.py
204@@ -10,6 +10,7 @@ from .blocktools import (
205 )
206 from .messages import (
207 COutPoint,
208+ COIN,
209 CTransaction,
210 CTxIn,
211 CTxInWitness,
212@@ -22,6 +23,7 @@ from .script import (
213 from .util import (
214 assert_equal,
215 assert_greater_than,
216+ assert_greater_than_or_equal,
217 create_lots_of_big_transactions,
218 gen_return_txouts,
219 )
220@@ -131,3 +133,28 @@ def create_large_orphan():
221 tx.wit.vtxinwit[0].scriptWitness.stack = [CScript(b'X' * 390000)]
222 tx.vout = [CTxOut(100, CScript([OP_RETURN, b'a' * 20]))]
223 return tx
224+
225+def check_feerate_diagram_monotonically_decreasing(feerate_diagram):
226+ """Sanity check the feerate diagram."""
227+ last_val = [0, 0]
228+ for x in feerate_diagram:
229+ # The vsize is always positive, except for the first iteration
230+ assert x['vsize'] > 0 or x['fee'] == 0
231+ # Monotonically decreasing fee per vsize
232+ assert_greater_than_or_equal(last_val[0] * x['vsize'], last_val[1] * x['fee'])
233+ last_val = [x['vsize'], x['fee']]
234+
235+def assert_equal_feerate_diagram(expected, actual):
236+ """Check that expected and actual are equal, handling Decimal values and giving helpful error messages.
237+ expected: list of [fee, vsize] pairs where fee is an integer number of satoshis
238+ actual: list of { "fee": Decimal, "vsize": int } from the getmempoolfeeratediagram RPC
239+ Also sanity checks that the actual feerates are monotonically decreasing.
240+ """
241+ assert_equal(len(expected), len(actual))
242+ for i in range(len(expected)):
243+ # We convert the Decimal to an integer number to avoid Decimal comparisons.
244+ # For example, Decimal('0') == Decimal('0E-8') and Decimal('0.0001') == Decimal('0.00010000')
245+ assert_equal(expected[i][0], int(actual[i]["fee"] * COIN))
246+ assert_equal(expected[i][1], actual[i]["vsize"])
247+
248+ check_feerate_diagram_monotonically_decreasing(actual)