Merge ElementsProject/elements#1576: Fix intermittent failure in feature_pegin_subsidy.py
What changed, and why it matters
This commit only updates test scripts to fix flaky automated tests. It changes how test code calculates expected pegin subsidy values and how test nodes bind to network ports when using Bitcoin Core as a parent chain. No production node code is modified, so this does not create or fix any security vulnerability in the Elements software itself.
No security action required. Treat as a normal test reliability improvement. Reviewers may optionally verify the Python helper compute_expected_subsidy accurately reflects the C++ CheckPeginSubsidyAndMinimum implementation to ensure the test remains meaningful.
Security signals we found
No changes to production source code
Changes limited to test/functional/ Python test scripts
Test assertion hardening: dynamic subsidy computation instead of hardcoded constants
Test network setup hardening: explicit bind addresses to prevent port collisions
No cryptographic, consensus, or RPC behavior changes in node code
Evidence from the diff
The merge commit updates two functional test files: feature_fedpeg.py and feature_pegin_subsidy.py. It replaces hardcoded pegin subsidy constants with a Python helper that mirrors the node’s CheckPeginSubsidyAndMinimum logic, eliminating intermittent failures caused by variable DER signature lengths. It also adds explicit -bind arguments for bitcoind parent nodes to avoid port collisions with sidechain nodes and removes expected_stderr handling tied to that collision. These are test-framework and test-assertion changes only; no consensus, validation, wallet, or P2P production code is touched.
Changed components
test/functional/feature_fedpeg.pytest/functional/feature_pegin_subsidy.pyInspect captured patch +154 / −38
### test/functional/feature_fedpeg.py
@@ -10,6 +10,7 @@
get_datadir_path,
rpc_port,
p2p_port,
+ tor_port,
assert_raises_rpc_error,
assert_equal,
find_vout_for_address,
@@ -82,7 +83,16 @@ def setup_network(self, split=False):
"-keypool=1",
"-listenonion=0",
"-addresstype=legacy", # To make sure bitcoind gives back p2pkh no matter version
- "-fallbackfee=0.0002"
+ "-fallbackfee=0.0002",
+ "-deprecatedrpc=create_bdb", # Required to create legacy (BDB) wallets on newer bitcoind
+ # bitcoind reads bitcoin.conf, not the elements.conf the test framework
+ # writes with bind=127.0.0.1, so the framework's collision-avoiding auto
+ # -bind is skipped. Without an explicit -bind, bitcoind binds P2P on
+ # 0.0.0.0:port and 127.0.0.1:port+1 (for incoming Tor connections), and
+ # port+1 collides with the next node's port. Bind explicitly to avoid
+ # the port+1 default.
+ "-bind=127.0.0.1:%s" % p2p_port(n),
+ "-bind=127.0.0.1:%s=onion" % tor_port(n),
])
else:
extra_args.extend([
@@ -198,8 +208,11 @@ def run_test(self):
WSH_OP_TRUE = self.nodes[0].decodescript("51")["segwit"]["hex"]
# We just randomize the keys a bit to get another valid fedpegscript
tweaked = sidechain.tweakfedpegscript("f00dbabe")
- assert sidechain.getaddressinfo(tweaked['p2wsh'])['iswitness']
- assert not sidechain.getaddressinfo(tweaked['p2shwsh'])['iswitness']
+ # tweakfedpegscript returns parent-chain-encoded addresses, so decode them
+ # with the parent node when the parent is bitcoin (bcrt prefix, not ert).
+ # addr_node = parent if self.options.parent_bitcoin else sidechain
+ assert parent.getaddressinfo(tweaked['p2wsh'])['iswitness']
+ assert not parent.getaddressinfo(tweaked['p2shwsh'])['iswitness']
new_fedpegscript = tweaked["script"]
if self.options.post_transition:
print("Running test post-transition")
@@ -224,7 +237,7 @@ def run_test(self):
assert_equal(sidechain.decodescript(addrs["claim_script"])["type"], "witness_v0_keyhash")
current_fedpegscript = sidechain.getsidechaininfo()["current_fedpegscripts"][0]
tweaked = sidechain.tweakfedpegscript(addrs["claim_script"], current_fedpegscript)
- if sidechain.getaddressinfo(addr)['iswitness']:
+ if parent.getaddressinfo(addr)['iswitness']:
assert_equal(tweaked['p2wsh'], addr)
else:
assert_equal(tweaked['p2shwsh'], addr)
### test/functional/feature_pegin_subsidy.py
@@ -5,6 +5,7 @@
# test/functional/feature_pegin_subsidy.py --parent_bitcoin --parent_binpath="/path/to/bitcoind" --nosandbox
from decimal import Decimal
+import math
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_raises_rpc_error,
@@ -13,6 +14,7 @@
get_datadir_path,
rpc_port,
p2p_port,
+ tor_port,
assert_equal,
)
from test_framework import util
@@ -29,6 +31,85 @@ def get_new_unconfidential_address(node, addr_type="bech32"):
return val_addr["address"]
+COIN_SATS = Decimal(100_000_000)
+
+
+WITNESS_SCALE_FACTOR = 4
+FEDPEG_T = 11 # multisig threshold for self.fedpegscript (11-of-15)
+
+
+def _cfeerate_construct(fee_sats, num_bytes):
+ """CFeeRate(nFeePaid, num_bytes) -> nSatoshisPerK, per src/policy/feerate.cpp:
+ nSatoshisPerK = nFeePaid * 1000 / nSize (C++ integer division)."""
+ if num_bytes > 0:
+ return (fee_sats * 1000) // num_bytes
+ return 0
+
+
+def _cfeerate_get_fee(sat_per_k, num_bytes):
+ """CFeeRate::GetFee(num_bytes), per src/policy/feerate.cpp:
+ ceil(nSatoshisPerK * nSize / 1000.0), with a floor of 1 sat if the
+ naive result rounds to zero and the rate is positive."""
+ fee = math.ceil(sat_per_k * num_bytes / 1000.0)
+ if fee == 0 and num_bytes != 0:
+ if sat_per_k > 0:
+ fee = 1
+ elif sat_per_k < 0:
+ fee = -1
+ return int(fee)
+
+
+def compute_expected_subsidy(parent, fedpegscript_hex, parent_pegged_asset_hex, pegin_txids, validating=True):
+ """Replicates CheckPeginSubsidyAndMinimum's expected_subsidy computation
+ exactly (src/validation.cpp), reading the parent-chain transaction
+ real fee/vsize."""
+ fedpegscript_bytes = len(bytes.fromhex(fedpegscript_hex))
+ weight = WITNESS_SCALE_FACTOR * (32 + 4 + 1 + 4) + (FEDPEG_T * 72 + fedpegscript_bytes)
+ vbytes = (weight + WITNESS_SCALE_FACTOR - 1) // WITNESS_SCALE_FACTOR
+
+ parent_fee_sats = 0
+ parent_vsize = 0
+ per_tx_debug = []
+ if validating:
+ for txid in pegin_txids:
+ gt = parent.gettransaction(txid)
+ blockhash = gt["blockhash"]
+ result = parent.getrawtransaction(txid, 2, blockhash)
+ tx_vsize = result["vsize"]
+ fee_field = result.get("fee", 0)
+ if isinstance(fee_field, dict):
+ tx_fee_btc = fee_field.get(parent_pegged_asset_hex, 0)
+ else:
+ tx_fee_btc = fee_field
+ tx_fee_sats = round(Decimal(str(tx_fee_btc)) * COIN_SATS)
+ parent_vsize += tx_vsize
+ parent_fee_sats += tx_fee_sats
+ per_tx_debug.append({"txid": txid, "vsize": tx_vsize, "fee_btc": tx_fee_btc, "fee_sats": tx_fee_sats})
+
+ sat_per_k = _cfeerate_construct(int(parent_fee_sats), parent_vsize)
+ sat_per_k = max(sat_per_k, 1000) # std::max(parent_feerate, CFeeRate{1000})
+
+ expected_subsidy_sats = _cfeerate_get_fee(sat_per_k, len(pegin_txids) * vbytes)
+ debug = {
+ "validating": validating,
+ "fedpegscript_bytes": fedpegscript_bytes,
+ "weight": weight,
+ "vbytes": vbytes,
+ "parent_fee_sats": int(parent_fee_sats),
+ "parent_vsize": parent_vsize,
+ "sat_per_k": sat_per_k,
+ "per_tx": per_tx_debug,
+ }
+ return Decimal(expected_subsidy_sats) / COIN_SATS, debug
+
+
+def get_expected_subsidy(self, pegin_txids, validating=True):
+ expected, _debug = compute_expected_subsidy(
+ self.nodes[0], self.fedpegscript, self.parent_pegged_asset, pegin_txids, validating=validating
+ )
+ return expected
+
+
class PeginSubsidyTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
@@ -84,11 +165,16 @@ def setup_network(self, split=False):
"-addresstype=legacy", # To make sure bitcoind gives back p2pkh no matter version
"-fallbackfee=0.0002",
"-deprecatedrpc=create_bdb",
+ # bitcoind reads bitcoin.conf, not the elements.conf the test framework
+ # writes with bind=127.0.0.1, so the framework's collision-avoiding auto
+ # -bind is skipped. Without an explicit -bind, bitcoind binds P2P on
+ # 0.0.0.0:port and 127.0.0.1:port+1 (for incoming Tor connections), and
+ # port+1 == p2p_port(1) collides with the first sidechain node. Bind
+ # explicitly to avoid the port+1 default.
+ "-bind=127.0.0.1:%s" % p2p_port(0),
+ "-bind=127.0.0.1:%s=onion" % tor_port(0),
]
)
- self.expected_stderr = (
- f"Error: Unable to bind to 127.0.0.1:{p2p_port(1)} on this computer. Elements Core is probably already running."
- )
else:
extra_args.extend(
[
@@ -99,7 +185,6 @@ def setup_network(self, split=False):
"-dustrelayfee=0.00003000", # use the Bitcoin default dust relay fee rate for the parent nodes
]
)
- self.expected_stderr = ""
self.add_nodes(1, [extra_args], chain=[parent_chain], binary=parent_binary)
self.start_node(0)
@@ -113,8 +198,10 @@ def setup_network(self, split=False):
)
self.parentgenesisblockhash = self.nodes[0].getblockhash(0)
+ self.parent_pegged_asset = None
if not self.options.parent_bitcoin:
parent_pegged_asset = self.nodes[0].getsidechaininfo()["pegged_asset"]
+ self.parent_pegged_asset = parent_pegged_asset
# Setup sidechain nodes
# use the current liquidv1 fedpegscript for testing purposes
@@ -305,7 +392,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegintx = sidechain.createrawpegin(bitcoin_txhex, txoutproof, claim_script)
signed = sidechain.signrawtransactionwithwallet(pegintx["hex"])
assert_equal(signed["complete"], True)
- pegin_txid = sidechain.sendrawtransaction(signed["hex"])
+ pegin_txid = sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 2)
self.generate(sidechain, 1, sync_fun=sync_sidechain)
@@ -315,7 +402,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegintx = sidechain.createrawpegin(bitcoin_txhex, txoutproof, claim_script)
signed = sidechain.signrawtransactionwithwallet(pegintx["hex"])
assert_equal(signed["complete"], True)
- pegin_txid = sidechain.sendrawtransaction(signed["hex"])
+ pegin_txid = sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 2)
self.generate(sidechain, 1, sync_fun=sync_sidechain)
@@ -421,7 +508,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
},
]
fee = Decimal("0.00000363")
- subsidy = Decimal("0.00000395")
+ subsidy = get_expected_subsidy(self, [txid]) - Decimal(1) / COIN_SATS
outputs = [
{addr: Decimal("1.0") - fee - subsidy},
{changeaddr: utxo["amount"]},
@@ -487,17 +574,15 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegin_txid = sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 3)
- # WSH input 41 bytes * 4 = 164 weight
- # Witness (11 * 72 bytes signatures + 626 bytes script size) = 1418 weight
- # (164 + 1418 + 3) / 4 = 396 vbytes
- assert_equal(pegin_tx["decoded"]["vout"][1]["value"], Decimal("0.00000396"))
+ expected_subsidy = get_expected_subsidy(self, [txid])
+ assert_equal(pegin_tx["decoded"]["vout"][1]["value"], expected_subsidy)
self.generate(sidechain, 1, sync_fun=sync_sidechain)
self.log.info("createrawpegin after enforcement, with validatepegin, above threshold")
txid, vout, txoutproof, bitcoin_txhex, claim_script = parent_pegin(parent, sidechain, amount=3.0, feerate=2.0)
pegintx = sidechain.createrawpegin(bitcoin_txhex, txoutproof, claim_script)
signed = sidechain.signrawtransactionwithwallet(pegintx["hex"])
- pegin_txid = sidechain.sendrawtransaction(signed["hex"])
+ pegin_txid = sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 2)
self.generate(sidechain, 1, sync_fun=sync_sidechain)
@@ -516,11 +601,10 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegintx = sidechain2.createrawpegin(bitcoin_txhex, txoutproof, claim_script, feerate)
signed = sidechain2.signrawtransactionwithwallet(pegintx["hex"])
- pegin_txid = sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
- self.generate(sidechain, 1, sync_fun=sync_sidechain)
+ pegin_txid = sidechain2.sendrawtransaction(signed["hex"], maxburnamount="1.0")
pegin_tx = sidechain2.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 3)
- assert_equal(pegin_tx["decoded"]["vout"][1]["value"], Decimal("0.00000792"))
+ assert_equal(pegin_tx["decoded"]["vout"][1]["value"], get_expected_subsidy(self, [txid]))
self.generate(sidechain2, 1, sync_fun=sync_sidechain)
self.log.info("createrawpegin after enforcement, without validatepegin, above threshold")
@@ -538,7 +622,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegin_txid = sidechain.claimpegin(bitcoin_txhex, txoutproof, claim_script)
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 3)
- assert_equal(pegin_tx["decoded"]["vout"][1]["value"], Decimal("0.00000792"))
+ assert_equal(pegin_tx["decoded"]["vout"][1]["value"], get_expected_subsidy(self, [txid]))
self.generate(sidechain, 1, sync_fun=sync_sidechain)
self.log.info("claimpegin after enforcement, with validatepegin, above threshold")
@@ -563,7 +647,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegin_txid = sidechain2.claimpegin(bitcoin_txhex, txoutproof, claim_script, feerate)
pegin_tx = sidechain2.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 3)
- assert_equal(pegin_tx["decoded"]["vout"][1]["value"], Decimal("0.00000792"))
+ assert_equal(pegin_tx["decoded"]["vout"][1]["value"], get_expected_subsidy(self, [txid]))
self.generate(sidechain2, 1, sync_fun=sync_sidechain)
self.log.info("claimpegin after enforcement, without validatepegin, above threshold")
@@ -630,8 +714,12 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
]
fee = Decimal("0.00000363")
addr = get_new_unconfidential_address(sidechain)
- # subsidy less than 1 sat/vb
- subsidy = Decimal("0.00000395")
+ sidechain2_threshold = get_expected_subsidy(self, [txid], validating=False)
+ sidechain_threshold = get_expected_subsidy(self, [txid], validating=True)
+ assert sidechain2_threshold < sidechain_threshold
+
+ # subsidy one satoshi below sidechain2's (lower) threshold: both reject
+ subsidy = sidechain2_threshold - Decimal(1) / COIN_SATS
outputs = [
{addr: Decimal("1.0") - fee - subsidy},
{"burn": subsidy},
@@ -650,8 +738,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
assert_equal(accept[0]["allowed"], False)
assert_equal(accept[0]["reject-reason"], "pegin-subsidy-too-low")
- # subsidy for 1 sat/vb accepted by sidechain2, but rejected by validating node
- subsidy = Decimal("0.00000396")
+ subsidy = sidechain2_threshold
outputs = [
{addr: Decimal("1.0") - fee - subsidy},
{"burn": subsidy},
@@ -675,7 +762,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
pegin_txid = sidechain.claimpegin(bitcoin_txhex, txoutproof, claim_script)
pegin_tx = sidechain.gettransaction(pegin_txid, True, True)
assert_equal(len(pegin_tx["decoded"]["vout"]), 3)
- assert_equal(pegin_tx["decoded"]["vout"][1]["value"], Decimal("0.00000396"))
+ assert_equal(pegin_tx["decoded"]["vout"][1]["value"], get_expected_subsidy(self, [txid]))
# check manually constructed peg-in from a sub 1 sat/vb parent
txid, vout, txoutproof, bitcoin_txhex, claim_script = parent_pegin(parent, sidechain, amount=1, feerate=0.1)
@@ -690,8 +777,10 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
]
fee = Decimal("0.00000363")
addr = get_new_unconfidential_address(sidechain)
- # subsidy too low
- subsidy = Decimal("0.00000395")
+ required_subsidy = get_expected_subsidy(self, [txid])
+
+ # subsidy one satoshi below the real required minimum: too low
+ subsidy = required_subsidy - Decimal(1) / COIN_SATS
outputs = [
{addr: Decimal("1.0") - fee - subsidy},
{"burn": subsidy},
@@ -705,8 +794,8 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
assert_equal(accept[0]["allowed"], False)
assert_equal(accept[0]["reject-reason"], "pegin-subsidy-too-low")
- # subsidy accepted
- subsidy = Decimal("0.00000396")
+ # subsidy at the real required minimum: accepted
+ subsidy = required_subsidy
outputs = [
{addr: Decimal("1.0") - fee - subsidy},
{"burn": subsidy},
@@ -747,7 +836,10 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
},
]
fee = Decimal("0.00000363")
- subsidy = Decimal("0.00001583")
+ required_subsidy = get_expected_subsidy(self, [txid1, txid2])
+
+ # subsidy one satoshi below the real required minimum
+ subsidy = required_subsidy - Decimal(1) / COIN_SATS
outputs = [
{addr1: Decimal("0.5") - fee - subsidy},
{addr2: 1.0},
@@ -761,7 +853,8 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
assert_equal(accept[0]["allowed"], False)
assert_equal(accept[0]["reject-reason"], "pegin-subsidy-too-low")
- subsidy = Decimal("0.00001584")
+ # subsidy at the real required minimum
+ subsidy = required_subsidy
outputs = [
{addr1: Decimal("0.5") - fee - subsidy},
{addr2: 1.0},
@@ -808,7 +901,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
signed = sidechain.signrawtransactionwithwallet(raw)
accept = sidechain.testmempoolaccept([signed["hex"]])
assert_equal(accept[0]["allowed"], True)
- sidechain.sendrawtransaction(signed["hex"])
+ sidechain.sendrawtransaction(signed["hex"], maxburnamount="1.0")
self.generate(sidechain2, 1, sync_fun=sync_sidechain)
# minimum peg-in amount is 1.0
@@ -837,7 +930,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
},
]
fee = Decimal("0.00000363")
- subsidy = Decimal("0.00000396")
+ subsidy = get_expected_subsidy(self, [txid]) + Decimal(10) / COIN_SATS
outputs = [
{addr: Decimal("0.99999999") - fee - subsidy},
{"burn": subsidy},
@@ -875,8 +968,12 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
# dust error
# restart node1 with no min peg-in amount
- self.stop_node(1, expected_stderr=self.expected_stderr) # when running with bitcoind as parent node this stderr can occur
+ self.stop_node(1)
self.start_node(1, extra_args=sidechain.extra_args + ["-peginminamount=0"])
+ self.stop_node(2)
+ self.start_node(2, extra_args=sidechain2.extra_args + ["-peginminamount=0"])
+ self.connect_nodes(1, 2)
+ self.sync_all([sidechain, sidechain2])
self.log.info("claimpegin dust error")
amount = Decimal("0.00000546") if self.options.parent_bitcoin else Decimal("0.00000645")
txid, vout, txoutproof, bitcoin_txhex, claim_script = parent_pegin(parent, sidechain, amount)
@@ -901,9 +998,15 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
},
]
fee = Decimal("0.00000363")
- subsidy = Decimal("0.00001194")
+ required_min_subsidy = get_expected_subsidy(self, [txid])
+ target_primary_output_sats = Decimal(13) # dust-sized remainder
+ subsidy = Decimal("0.00001570") - fee - target_primary_output_sats / COIN_SATS
+ assert subsidy >= required_min_subsidy, (
+ f"subsidy {subsidy} would be below the required minimum {required_min_subsidy} "
+ "-- dust-test arithmetic needs revisiting"
+ )
outputs = [
- {addr: Decimal("0.00001570") - fee - subsidy}, # 14 sats is dust at 0.1 sat/vb dustrelayfee
+ {addr: Decimal("0.00001570") - fee - subsidy},
{"burn": subsidy},
{"fee": fee},
]
@@ -928,7 +1031,7 @@ def parent_pegin(parent, node, amount=1.0, feerate=DEFAULT_FEERATE):
# Manually stop sidechains first, then the parent chain.
self.stop_node(2)
- self.stop_node(1, expected_stderr=self.expected_stderr) # when running with bitcoind as parent node this stderr can occur
+ self.stop_node(1)
self.stop_node(0)
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.