Skip to content

Commit e6e4c18

Browse files
committed
Merge bitcoin/bitcoin#30162: test: MiniWallet: respect passed feerate for padded txs (using target_weight)
39d135e test: MiniWallet: respect fee_rate for target_weight, use in mempool_limit.py (Sebastian Falbesoner) b2f0a9f test: add framework functional test for MiniWallet's tx padding (Sebastian Falbesoner) c17550b test: MiniWallet: fix tx padding (`target_weight`) for large sizes, improve accuracy (Sebastian Falbesoner) Pull request description: MiniWallet allows to create padded transactions that are equal or slightly above a certain `target_weight` (first introduced in PR #25379, commit 1d6b438), which can be useful especially for mempool-related tests, e.g. for policy limit checks or scenarios to trigger mempool eviction. Currently the `target_weight` parameter doesn't play together with `fee_rate` though, as the fee calculation is incorrectly based on the tx vsize before the padding output is added, so the fee-rate is consequently far off. This means users are forced to pass an absolute fee, which can be quite inconvenient and leads to lots of duplicated "calculate absolute fee from fee-rate and vsize" code with the pattern `fee = (feerate / 1000) * (weight // 4)` on the call-sites. This PR first improves the tx padding itself to be more accurate, adds a functional test for it, and fixes the `fee_rate` treatment for the `{create,send}_self_transfer` methods. (Next step would be to enable this also for the `_self_transfer_multi` methods, but those currently don't even offer a `fee_rate` parameter). Finally, the ability to pass both `target_weight` and `fee_rate` is used in the `mempool_limit.py` functional test. There might be more use-cases in other tests, that could be done in a follow-up. ACKs for top commit: rkrux: tACK [39d135e](bitcoin/bitcoin@39d135e) ismaelsadeeq: Code Review ACK 39d135e 🚀 glozow: light review ACK 39d135e Tree-SHA512: 6bf8e853a921576d463291d619cdfd6a7e74cf92f61933a563800ac0b3c023a06569b581243166906f56b3c5e8858fec2d8a6910d55899e904221f847eb0953d
2 parents ba5dd96 + 39d135e commit e6e4c18

File tree

4 files changed

+81
-17
lines changed

4 files changed

+81
-17
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2024 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Test MiniWallet."""
6+
from test_framework.blocktools import COINBASE_MATURITY
7+
from test_framework.test_framework import BitcoinTestFramework
8+
from test_framework.util import (
9+
assert_greater_than_or_equal,
10+
)
11+
from test_framework.wallet import (
12+
MiniWallet,
13+
MiniWalletMode,
14+
)
15+
16+
17+
class FeatureFrameworkMiniWalletTest(BitcoinTestFramework):
18+
def set_test_params(self):
19+
self.num_nodes = 1
20+
21+
def test_tx_padding(self):
22+
"""Verify that MiniWallet's transaction padding (`target_weight` parameter)
23+
works accurately enough (i.e. at most 3 WUs higher) with all modes."""
24+
for mode_name, wallet in self.wallets:
25+
self.log.info(f"Test tx padding with MiniWallet mode {mode_name}...")
26+
utxo = wallet.get_utxo(mark_as_spent=False)
27+
for target_weight in [1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 4000000,
28+
989, 2001, 4337, 13371, 23219, 49153, 102035, 223419, 3999989]:
29+
tx = wallet.create_self_transfer(utxo_to_spend=utxo, target_weight=target_weight)["tx"]
30+
self.log.debug(f"-> target weight: {target_weight}, actual weight: {tx.get_weight()}")
31+
assert_greater_than_or_equal(tx.get_weight(), target_weight)
32+
assert_greater_than_or_equal(target_weight + 3, tx.get_weight())
33+
34+
def run_test(self):
35+
node = self.nodes[0]
36+
self.wallets = [
37+
("ADDRESS_OP_TRUE", MiniWallet(node, mode=MiniWalletMode.ADDRESS_OP_TRUE)),
38+
("RAW_OP_TRUE", MiniWallet(node, mode=MiniWalletMode.RAW_OP_TRUE)),
39+
("RAW_P2PK", MiniWallet(node, mode=MiniWalletMode.RAW_P2PK)),
40+
]
41+
for _, wallet in self.wallets:
42+
self.generate(wallet, 10)
43+
self.generate(wallet, COINBASE_MATURITY)
44+
45+
self.test_tx_padding()
46+
47+
48+
if __name__ == '__main__':
49+
FeatureFrameworkMiniWalletTest().main()

test/functional/mempool_limit.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def test_rbf_carveout_disallowed(self):
5959
mempoolmin_feerate = node.getmempoolinfo()["mempoolminfee"]
6060
tx_A = self.wallet.send_self_transfer(
6161
from_node=node,
62-
fee=(mempoolmin_feerate / 1000) * (A_weight // 4) + Decimal('0.000001'),
62+
fee_rate=mempoolmin_feerate,
6363
target_weight=A_weight,
6464
utxo_to_spend=rbf_utxo,
6565
confirmed_only=True
@@ -77,7 +77,7 @@ def test_rbf_carveout_disallowed(self):
7777
non_cpfp_carveout_weight = 40001 # EXTRA_DESCENDANT_TX_SIZE_LIMIT + 1
7878
tx_C = self.wallet.create_self_transfer(
7979
target_weight=non_cpfp_carveout_weight,
80-
fee = (mempoolmin_feerate / 1000) * (non_cpfp_carveout_weight // 4) + Decimal('0.000001'),
80+
fee_rate=mempoolmin_feerate,
8181
utxo_to_spend=tx_B["new_utxo"],
8282
confirmed_only=True
8383
)
@@ -109,7 +109,7 @@ def test_mid_package_eviction(self):
109109
# happen in the middle of package evaluation, as it can invalidate the coins cache.
110110
mempool_evicted_tx = self.wallet.send_self_transfer(
111111
from_node=node,
112-
fee=(mempoolmin_feerate / 1000) * (evicted_weight // 4) + Decimal('0.000001'),
112+
fee_rate=mempoolmin_feerate,
113113
target_weight=evicted_weight,
114114
confirmed_only=True
115115
)
@@ -135,11 +135,11 @@ def test_mid_package_eviction(self):
135135
parent_weight = 100000
136136
num_big_parents = 3
137137
assert_greater_than(parent_weight * num_big_parents, current_info["maxmempool"] - current_info["bytes"])
138-
parent_fee = (100 * mempoolmin_feerate / 1000) * (parent_weight // 4)
138+
parent_feerate = 100 * mempoolmin_feerate
139139

140140
big_parent_txids = []
141141
for i in range(num_big_parents):
142-
parent = self.wallet.create_self_transfer(fee=parent_fee, target_weight=parent_weight, confirmed_only=True)
142+
parent = self.wallet.create_self_transfer(fee_rate=parent_feerate, target_weight=parent_weight, confirmed_only=True)
143143
parent_utxos.append(parent["new_utxo"])
144144
package_hex.append(parent["hex"])
145145
big_parent_txids.append(parent["txid"])
@@ -314,18 +314,20 @@ def run_test(self):
314314
target_weight_each = 200000
315315
assert_greater_than(target_weight_each * 2, node.getmempoolinfo()["maxmempool"] - node.getmempoolinfo()["bytes"])
316316
# Should be a true CPFP: parent's feerate is just below mempool min feerate
317-
parent_fee = (mempoolmin_feerate / 1000) * (target_weight_each // 4) - Decimal("0.00001")
317+
parent_feerate = mempoolmin_feerate - Decimal("0.000001") # 0.1 sats/vbyte below min feerate
318318
# Parent + child is above mempool minimum feerate
319-
child_fee = (worst_feerate_btcvb) * (target_weight_each // 4) - Decimal("0.00001")
319+
child_feerate = (worst_feerate_btcvb * 1000) - Decimal("0.000001") # 0.1 sats/vbyte below worst feerate
320320
# However, when eviction is triggered, these transactions should be at the bottom.
321321
# This assertion assumes parent and child are the same size.
322322
miniwallet.rescan_utxos()
323-
tx_parent_just_below = miniwallet.create_self_transfer(fee=parent_fee, target_weight=target_weight_each)
324-
tx_child_just_above = miniwallet.create_self_transfer(utxo_to_spend=tx_parent_just_below["new_utxo"], fee=child_fee, target_weight=target_weight_each)
323+
tx_parent_just_below = miniwallet.create_self_transfer(fee_rate=parent_feerate, target_weight=target_weight_each)
324+
tx_child_just_above = miniwallet.create_self_transfer(utxo_to_spend=tx_parent_just_below["new_utxo"], fee_rate=child_feerate, target_weight=target_weight_each)
325325
# This package ranks below the lowest descendant package in the mempool
326-
assert_greater_than(worst_feerate_btcvb, (parent_fee + child_fee) / (tx_parent_just_below["tx"].get_vsize() + tx_child_just_above["tx"].get_vsize()))
327-
assert_greater_than(mempoolmin_feerate, (parent_fee) / (tx_parent_just_below["tx"].get_vsize()))
328-
assert_greater_than((parent_fee + child_fee) / (tx_parent_just_below["tx"].get_vsize() + tx_child_just_above["tx"].get_vsize()), mempoolmin_feerate / 1000)
326+
package_fee = tx_parent_just_below["fee"] + tx_child_just_above["fee"]
327+
package_vsize = tx_parent_just_below["tx"].get_vsize() + tx_child_just_above["tx"].get_vsize()
328+
assert_greater_than(worst_feerate_btcvb, package_fee / package_vsize)
329+
assert_greater_than(mempoolmin_feerate, tx_parent_just_below["fee"] / (tx_parent_just_below["tx"].get_vsize()))
330+
assert_greater_than(package_fee / package_vsize, mempoolmin_feerate / 1000)
329331
res = node.submitpackage([tx_parent_just_below["hex"], tx_child_just_above["hex"]])
330332
for wtxid in [tx_parent_just_below["wtxid"], tx_child_just_above["wtxid"]]:
331333
assert_equal(res["tx-results"][wtxid]["error"], "mempool full")

test/functional/test_framework/wallet.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from copy import deepcopy
88
from decimal import Decimal
99
from enum import Enum
10+
import math
1011
from typing import (
1112
Any,
1213
Optional,
@@ -33,10 +34,13 @@
3334
CTxInWitness,
3435
CTxOut,
3536
hash256,
37+
ser_compact_size,
38+
WITNESS_SCALE_FACTOR,
3639
)
3740
from test_framework.script import (
3841
CScript,
3942
LEAF_VERSION_TAPSCRIPT,
43+
OP_1,
4044
OP_NOP,
4145
OP_RETURN,
4246
OP_TRUE,
@@ -52,6 +56,7 @@
5256
from test_framework.util import (
5357
assert_equal,
5458
assert_greater_than_or_equal,
59+
get_fee,
5560
)
5661
from test_framework.wallet_util import generate_keypair
5762

@@ -119,13 +124,16 @@ def _bulk_tx(self, tx, target_weight):
119124
"""Pad a transaction with extra outputs until it reaches a target weight (or higher).
120125
returns the tx
121126
"""
122-
tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN, b'a'])))
127+
tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN])))
128+
# determine number of needed padding bytes by converting weight difference to vbytes
123129
dummy_vbytes = (target_weight - tx.get_weight() + 3) // 4
124-
tx.vout[-1].scriptPubKey = CScript([OP_RETURN, b'a' * dummy_vbytes])
125-
# Lower bound should always be off by at most 3
130+
# compensate for the increase of the compact-size encoded script length
131+
# (note that the length encoding of the unpadded output script needs one byte)
132+
dummy_vbytes -= len(ser_compact_size(dummy_vbytes)) - 1
133+
tx.vout[-1].scriptPubKey = CScript([OP_RETURN] + [OP_1] * dummy_vbytes)
134+
# Actual weight should be at most 3 higher than target weight
126135
assert_greater_than_or_equal(tx.get_weight(), target_weight)
127-
# Higher bound should always be off by at most 3 + 12 weight (for encoding the length)
128-
assert_greater_than_or_equal(target_weight + 15, tx.get_weight())
136+
assert_greater_than_or_equal(target_weight + 3, tx.get_weight())
129137

130138
def get_balance(self):
131139
return sum(u['value'] for u in self._utxos)
@@ -367,6 +375,10 @@ def create_self_transfer(
367375
vsize = Decimal(168) # P2PK (73 bytes scriptSig + 35 bytes scriptPubKey + 60 bytes other)
368376
else:
369377
assert False
378+
if target_weight and not fee: # respect fee_rate if target weight is passed
379+
# the actual weight might be off by 3 WUs, so calculate based on that (see self._bulk_tx)
380+
max_actual_weight = target_weight + 3
381+
fee = get_fee(math.ceil(max_actual_weight / WITNESS_SCALE_FACTOR), fee_rate)
370382
send_value = utxo_to_spend["value"] - (fee or (fee_rate * vsize / 1000))
371383

372384
# create tx

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@
361361
'feature_addrman.py',
362362
'feature_asmap.py',
363363
'feature_fastprune.py',
364+
'feature_framework_miniwallet.py',
364365
'mempool_unbroadcast.py',
365366
'mempool_compatibility.py',
366367
'mempool_accept_wtxid.py',

0 commit comments

Comments
 (0)